- jsc-mod-statusline:/jsc-mod-statusline:enable、compact、disable - jsc-mod-companion:/jsc-mod-companion:status、switch、disable、setup、todo、quiet - 同步更新 plugin.json、marketplace.json 與各 README - 補上 statusline band 測試缺少的 ui.render 底層處理 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
235 lines
8.3 KiB
TypeScript
235 lines
8.3 KiB
TypeScript
import type { Git, Sys, Usage, Window } from '../types'
|
|
|
|
export type Seg = { text: string; color?: string; dim?: boolean; bold?: boolean }
|
|
|
|
export type HudData = {
|
|
model: string
|
|
cwd: string
|
|
home?: string
|
|
git: Git | null
|
|
usage: Usage
|
|
sys: Sys
|
|
tzOffsetMin: number
|
|
effort?: string | null
|
|
}
|
|
|
|
// 收合層級:0 完整 → 1 去進度條 → 2 目錄只留末段 → 3 去 context 用量 → 4 去磁碟 → 5 去重置時間 → 6 極窄
|
|
export const MAX_LEVEL = 6
|
|
|
|
export const levelColor = (p: number) => (p >= 85 ? 'error' : p >= 60 ? 'warning' : 'success')
|
|
|
|
export function bar(p: number, cells = 5): string {
|
|
const n = Math.max(0, Math.min(cells, Math.round((p / 100) * cells)))
|
|
return '▰'.repeat(n) + '▱'.repeat(cells - n)
|
|
}
|
|
|
|
export function prettyModel(raw: string): string {
|
|
const s = raw.replace(/\[1m\]/i, '').trim()
|
|
const m = /claude-(opus|sonnet|haiku|fable)-(\d+)(?:-(\d+))?/i.exec(s)
|
|
const fam = m?.[1]
|
|
if (!m || !fam) return s
|
|
const name = fam.charAt(0).toUpperCase() + fam.slice(1).toLowerCase()
|
|
return m[3] ? `${name} ${m[2]}.${m[3]}` : `${name} ${m[2]}`
|
|
}
|
|
|
|
// 模型等級:冷色空心 → 暖色實心,避開表示用量門檻的綠/黃/紅
|
|
const TIERS: Record<string, { glyph: string; color?: string }> = {
|
|
haiku: { glyph: '○', color: '#7f9bb3' },
|
|
sonnet: { glyph: '◇', color: '#4f9ee8' },
|
|
opus: { glyph: '◆', color: 'claude' },
|
|
fable: { glyph: '✦', color: '#a77bf0' },
|
|
}
|
|
|
|
export function modelStyle(raw: string): { glyph: string; color?: string } {
|
|
const fam = /(haiku|sonnet|opus|fable)/i.exec(raw)?.[1]?.toLowerCase()
|
|
return (fam && TIERS[fam]) || { glyph: '◆' }
|
|
}
|
|
|
|
export function tokens(n: number): string {
|
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
|
|
if (n >= 1000) return `${Math.round(n / 1000)}k`
|
|
return String(n)
|
|
}
|
|
|
|
const pad = (n: number) => String(n).padStart(2, '0')
|
|
|
|
const shift = (iso: string, off: number) => new Date(Date.parse(iso) + off * 60_000)
|
|
|
|
export const clock = (iso: string, off: number) => {
|
|
const d = shift(iso, off)
|
|
return `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`
|
|
}
|
|
|
|
export const day = (iso: string, off: number) => {
|
|
const d = shift(iso, off)
|
|
return `${d.getUTCMonth() + 1}/${d.getUTCDate()}`
|
|
}
|
|
|
|
// `+0800` → 480
|
|
export function parseOffset(z: string): number {
|
|
const m = /^([+-])(\d{2})(\d{2})$/.exec(z.trim())
|
|
if (!m) return 0
|
|
const v = Number(m[2]) * 60 + Number(m[3])
|
|
return m[1] === '-' ? -v : v
|
|
}
|
|
|
|
export function shortDir(cwd: string, home: string | undefined, lastOnly: boolean): string {
|
|
const p = home && (cwd === home || cwd.startsWith(home + '/')) ? '~' + cwd.slice(home.length) : cwd
|
|
if (!lastOnly) return p
|
|
const last = p.split('/').filter(Boolean).pop()
|
|
return last && last !== p ? `…/${last}` : p
|
|
}
|
|
|
|
const WIDE = /[ᄀ-ᅟ⺀-가-힣豈-︰-﹏-⦆¢-₩]/
|
|
|
|
export function width(s: string): number {
|
|
let w = 0
|
|
for (const ch of s) w += WIDE.test(ch) ? 2 : 1
|
|
return w
|
|
}
|
|
|
|
export const lineWidth = (segs: Seg[]) => segs.reduce((w, s) => w + width(s.text), 0)
|
|
|
|
function rate(label: string, w: Window | undefined, level: number, reset: (iso: string) => string): Seg[] {
|
|
if (!w) return [{ text: `${label} —`, dim: true }]
|
|
const c = levelColor(w.percent)
|
|
const out: Seg[] = [{ text: `${label} `, dim: true }]
|
|
if (level < 1) out.push({ text: `${bar(w.percent)} `, color: c })
|
|
out.push({ text: `${Math.round(w.percent)}%`, color: c })
|
|
if (level < 5 && w.resetsAt) out.push({ text: ` ↻${reset(w.resetsAt)}`, dim: true })
|
|
return out
|
|
}
|
|
|
|
function full(d: HudData, level: number): Seg[] {
|
|
const groups: Seg[][] = []
|
|
const ms = modelStyle(d.model)
|
|
const model: Seg[] = [{ text: `${ms.glyph} ${prettyModel(d.model)}`, color: ms.color, bold: true }]
|
|
if (d.effort && level < 5) model.push({ text: ` ·${d.effort}`, dim: true })
|
|
groups.push(model)
|
|
groups.push([{ text: shortDir(d.cwd, d.home, level >= 2), color: 'suggestion' }])
|
|
if (d.git) {
|
|
const g: Seg[] = [{ text: '⎇ ', dim: true }, { text: d.git.branch, bold: true }]
|
|
if (d.git.dirty) g.push({ text: ` ±${d.git.dirty}`, color: 'warning' })
|
|
if (d.git.ahead) g.push({ text: ` ↑${d.git.ahead}`, dim: true })
|
|
if (d.git.behind) g.push({ text: ` ↓${d.git.behind}`, dim: true })
|
|
groups.push(g)
|
|
}
|
|
groups.push(rate('5h', d.usage.fiveHour, level, iso => clock(iso, d.tzOffsetMin)))
|
|
groups.push(rate('7d', d.usage.sevenDay, level, iso => day(iso, d.tzOffsetMin)))
|
|
|
|
const u = d.usage
|
|
if (u.ctxPercent === undefined) {
|
|
groups.push([{ text: 'ctx —', dim: true }])
|
|
} else {
|
|
const c: Seg[] = [{ text: 'ctx ', dim: true }, { text: `${u.ctxPercent}%`, color: levelColor(u.ctxPercent) }]
|
|
if (level < 3 && u.ctxTokens !== undefined && u.ctxWindow)
|
|
c.push({ text: ` ${tokens(u.ctxTokens)}/${tokens(u.ctxWindow)}`, dim: true })
|
|
groups.push(c)
|
|
}
|
|
|
|
const s = d.sys
|
|
const sys: Seg[] = []
|
|
const add = (label: string, p: number | undefined) => {
|
|
if (p === undefined) return
|
|
if (sys.length) sys.push({ text: ' ' })
|
|
sys.push({ text: `${label} `, dim: true }, { text: `${Math.round(p)}%`, color: levelColor(p) })
|
|
}
|
|
add('CPU', s.cpu)
|
|
add('MEM', s.memUsed !== undefined && s.memTotal ? (s.memUsed / s.memTotal) * 100 : undefined)
|
|
if (level < 4) add('DSK', s.disk)
|
|
if (sys.length) groups.push(sys)
|
|
|
|
const line: Seg[] = []
|
|
groups.forEach((g, i) => {
|
|
if (i) line.push({ text: ' │ ', dim: true })
|
|
line.push(...g)
|
|
})
|
|
return line
|
|
}
|
|
|
|
function ultra(d: HudData): Seg[] {
|
|
const u = d.usage
|
|
const ms = modelStyle(d.model)
|
|
const out: Seg[] = [{ text: `${ms.glyph} ${prettyModel(d.model).split(' ')[0]}`, color: ms.color, bold: true }]
|
|
if (d.git) out.push({ text: ' ⎇', dim: true }, { text: d.git.branch, bold: true })
|
|
const num = (label: string, p: number | undefined) => {
|
|
out.push({ text: ` ${label}`, dim: true })
|
|
out.push(p === undefined ? { text: '—', dim: true } : { text: String(Math.round(p)), color: levelColor(p) })
|
|
}
|
|
num('5h', u.fiveHour?.percent)
|
|
num('7d', u.sevenDay?.percent)
|
|
num('ctx', u.ctxPercent)
|
|
if (d.sys.cpu !== undefined) num('cpu', d.sys.cpu)
|
|
return out
|
|
}
|
|
|
|
export function build(d: HudData, level: number): Seg[] {
|
|
return level >= MAX_LEVEL ? ultra(d) : full(d, level)
|
|
}
|
|
|
|
// 由完整版往下收合,回傳第一個放得進 columns 的版本
|
|
export function fit(d: HudData, columns: number, compact = false): Seg[] {
|
|
if (compact) return ultra(d)
|
|
for (let level = 0; level < MAX_LEVEL; level++) {
|
|
const line = full(d, level)
|
|
if (lineWidth(line) <= columns) return line
|
|
}
|
|
return ultra(d)
|
|
}
|
|
|
|
// git status --porcelain=v2 -b
|
|
export function parseGit(out: string): Git | null {
|
|
let head = ''
|
|
let oid = ''
|
|
let ahead = 0
|
|
let behind = 0
|
|
let dirty = 0
|
|
for (const line of out.split('\n')) {
|
|
if (!line) continue
|
|
if (line.startsWith('# branch.head ')) head = line.slice(14)
|
|
else if (line.startsWith('# branch.oid ')) oid = line.slice(13)
|
|
else if (line.startsWith('# branch.ab ')) {
|
|
const m = /\+(\d+) -(\d+)/.exec(line)
|
|
if (m) [ahead, behind] = [Number(m[1]), Number(m[2])]
|
|
} else if (!line.startsWith('#')) dirty++
|
|
}
|
|
if (!head) return null
|
|
const branch = head === '(detached)' ? oid.slice(0, 7) || 'detached' : head
|
|
return { branch, dirty, ahead, behind }
|
|
}
|
|
|
|
export type CpuSample = { idle: number; total: number }
|
|
|
|
export function parseCpu(stat: string): CpuSample | null {
|
|
const line = stat.split('\n').find(l => l.startsWith('cpu '))
|
|
if (!line) return null
|
|
const v = line.trim().split(/\s+/).slice(1).map(Number)
|
|
const idle = (v[3] ?? 0) + (v[4] ?? 0)
|
|
const total = v.reduce((a, b) => a + b, 0)
|
|
return { idle, total }
|
|
}
|
|
|
|
export function cpuPercent(prev: CpuSample, cur: CpuSample): number | undefined {
|
|
const dt = cur.total - prev.total
|
|
if (dt <= 0) return undefined
|
|
return Math.max(0, Math.min(100, (1 - (cur.idle - prev.idle) / dt) * 100))
|
|
}
|
|
|
|
export function parseMem(meminfo: string): { used: number; total: number } | null {
|
|
const kb = (k: string) => {
|
|
const m = new RegExp(`^${k}:\\s+(\\d+)`, 'm').exec(meminfo)
|
|
return m ? Number(m[1]) * 1024 : undefined
|
|
}
|
|
const total = kb('MemTotal')
|
|
const avail = kb('MemAvailable')
|
|
if (!total || avail === undefined) return null
|
|
return { used: total - avail, total }
|
|
}
|
|
|
|
// df -P <path> 的第二行第五欄
|
|
export function parseDf(out: string): number | undefined {
|
|
const row = out.trim().split('\n')[1]
|
|
const m = row && /(\d+)%/.exec(row)
|
|
return m ? Number(m[1]) : undefined
|
|
}
|