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 = { 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 的第二行第五欄 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 }