Files
jsc-marketplace/plugins/statusbar/hooks/format.ts
T
rootandClaude Sonnet 5.5 ba5b50ad1d refactor: 拆分 cockpit 為可個別安裝的 statusbar 與 companion
- statusbar:輸入框上方狀態列,指令 /statusbar
- companion:小說角色陪伴,指令 /companion,自行渲染角色行
- companion 資料目錄改為 ~/.companion
- 整併 M4 檔案至 companion;M2 實作移至 hooks/companion/persona
- README 移除本機資料夾安裝,補上更新與移除指令

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 13:57:24 +08:00

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
}