refactor: statusbar、companion、herdr 外掛改名加上 jsc- 前綴

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
root
2026-10-13 07:43:50 +00:00
co-authored by Claude Sonnet 5.5
parent f10b168c28
commit 58e2b2d26a
53 changed files with 28 additions and 25 deletions
@@ -0,0 +1,10 @@
{
"name": "jsc-statusbar",
"version": "0.0.1",
"description": "輸入框上方的單行狀態列:模型、目錄、分支、5h/7d 額度與重置、context、CPU/記憶體/磁碟",
"license": "MIT",
"types": "./types/index.d.ts",
"author": {
"name": "JSC"
}
}
+234
View File
@@ -0,0 +1,234 @@
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
}
+1
View File
@@ -0,0 +1 @@
{ "modules": ["./register.tsx"] }
+114
View File
@@ -0,0 +1,114 @@
import { describe, expect, test } from 'claude-code/testing'
import type { HudData } from './format'
import { bar, day, clock, fit, lineWidth, parseCpu, cpuPercent, parseDf, parseGit, parseMem, parseOffset, prettyModel, shortDir, tokens } from './format'
const RESET_5H = '2026-10-12T07:40:00Z'
const RESET_7D = '2026-10-16T01:00:00Z'
const DATA: HudData = {
model: 'claude-opus-5-5[1m]',
cwd: '/home/dev/work/luopan',
home: '/home/dev',
git: { branch: 'issue-278', dirty: 3, ahead: 1, behind: 0 },
usage: {
fiveHour: { percent: 42, resetsAt: RESET_5H },
sevenDay: { percent: 71, resetsAt: RESET_7D },
ctxTokens: 312_000,
ctxWindow: 1_000_000,
ctxPercent: 31,
},
sys: { cpu: 23, memUsed: 63, memTotal: 100, disk: 78 },
tzOffsetMin: 480,
effort: 'high',
}
const text = (d: HudData, columns: number, compact = false) => fit(d, columns, compact).map(s => s.text).join('')
describe('format', () => {
test('wide line shows every field on one line', async () => {
const line = text(DATA, 200)
expect(line).toBe(
'◆ Opus 5.5 ·high │ ~/work/luopan │ ⎇ issue-278 ±3 ↑1 │ 5h ▰▰▱▱▱ 42% ↻15:40 │ 7d ▰▰▰▰▱ 71% ↻10/16 │ ctx 31% 312k/1.0M │ CPU 23% MEM 63% DSK 78%',
)
expect(line.includes('\n')).toBe(false)
})
test('narrow widths collapse and always fit', async () => {
for (const cols of [140, 120, 100, 90, 80, 60]) {
const segs = fit(DATA, cols)
expect(lineWidth(segs) <= cols || cols < 50).toBe(true)
}
expect(text(DATA, 40)).toBe('◆ Opus ⎇issue-278 5h42 7d71 ctx31 cpu23')
expect(text(DATA, 200, true)).toBe('◆ Opus ⎇issue-278 5h42 7d71 ctx31 cpu23')
})
test('missing figures show a dash', async () => {
const line = text({ ...DATA, git: null, usage: {}, sys: {}, effort: null }, 200)
expect(line).toBe('◆ Opus 5.5 │ ~/work/luopan │ 5h — │ 7d — │ ctx —')
})
test('helpers', async () => {
expect(bar(0)).toBe('▱▱▱▱▱')
expect(bar(100)).toBe('▰▰▰▰▰')
expect(prettyModel('claude-sonnet-5-5')).toBe('Sonnet 5.5')
expect(prettyModel('Opus 5.5')).toBe('Opus 5.5')
expect(tokens(950)).toBe('950')
expect(tokens(200_000)).toBe('200k')
expect(clock(RESET_5H, 480)).toBe('15:40')
expect(day(RESET_7D, 480)).toBe('10/16')
expect(day('2026-10-15T20:00:00Z', 480)).toBe('10/16')
expect(parseOffset('+0800\n')).toBe(480)
expect(parseOffset('-0530')).toBe(-330)
expect(shortDir('/srv/x/y', '/home/dev', true)).toBe('…/y')
expect(shortDir('/home/dev', '/home/dev', false)).toBe('~')
})
test('model tiers', async () => {
const head = (model: string) => fit({ ...DATA, model }, 200)[0]
expect(head('claude-haiku-5-5')).toEqual({ text: '○ Haiku 5.5', color: '#7f9bb3', bold: true })
expect(head('claude-sonnet-5-5')).toEqual({ text: '◇ Sonnet 5.5', color: '#4f9ee8', bold: true })
expect(head('claude-opus-5-5')).toEqual({ text: '◆ Opus 5.5', color: 'claude', bold: true })
expect(head('claude-fable-5-1')).toEqual({ text: '✦ Fable 5.1', color: '#a77bf0', bold: true })
expect(head('some-other-model')).toEqual({ text: '◆ some-other-model', color: undefined, bold: true })
expect(text({ ...DATA, model: 'claude-fable-5-1' }, 40)).toBe('✦ Fable ⎇issue-278 5h42 7d71 ctx31 cpu23')
})
test('parsers', async () => {
expect(parseGit('# branch.oid abc1234ff\n# branch.head main\n# branch.ab +2 -1\n1 .M N... a\n? new.txt\n')).toEqual({
branch: 'main',
dirty: 2,
ahead: 2,
behind: 1,
})
expect(parseGit('# branch.oid abc1234ff\n# branch.head (detached)\n')?.branch).toBe('abc1234')
const a = parseCpu('cpu 100 0 100 700 100 0 0 0 0 0\ncpu0 1')
const b = parseCpu('cpu 150 0 150 750 150 0 0 0 0 0\n')
expect(a && b && cpuPercent(a, b)).toBe(50)
expect(parseMem('MemTotal: 1000 kB\nMemFree: 1 kB\nMemAvailable: 250 kB\n')).toEqual({ used: 768000, total: 1024000 })
expect(parseDf('Filesystem 1024-blocks Used Available Capacity Mounted on\n/dev/sdc 100 78 22 78% /\n')).toBe(78)
})
})
describe('band', () => {
test('draws one line above the prompt and hides with /hud off', async ($, on) => {
on('session.model', () => ({ value: 'claude-opus-5-5' }))
on('session.cwd', () => ({ value: '/home/dev/work/luopan' }))
const props = {
hasSurvey: false,
isWorking: false,
maxRows: 10,
bodyColumns: 200,
scroll: { offset: 0, bodyRows: 10 },
view: {},
}
for (const surface of ['terminal', 'desktop'] as const) {
const ui = await $.ui.mount({ plugin: 'hud', surface, component: 'AbovePrompt', props })
expect(await ui.find({ type: 'Text', text: /◆ Opus 5\.5/ })).toBeDefined()
expect(await ui.find({ type: 'Text', text: /5h —/ })).toBeDefined()
await ui.unmount()
}
const ran = await $.command.run({ command: 'hud', args: 'off' } as Parameters<typeof $.command.run>[0])
expect(JSON.stringify(ran)).toContain('隱藏')
})
})
+174
View File
@@ -0,0 +1,174 @@
import { atom, read, update } from 'claude-code'
import type { EngineInterface, Register, SessionContextUsage, SessionRateLimit } from 'claude-code'
import type { Mode, Sys, Usage } from '../types'
import { fit, cpuPercent, parseCpu, parseDf, parseGit, parseMem, parseOffset } from './format'
import type { CpuSample } from './format'
const usageAtom = atom({ plugin: 'statusbar', key: 'usage' } as const, {})
const gitAtom = atom({ plugin: 'statusbar', key: 'git' } as const, null)
const sysAtom = atom({ plugin: 'statusbar', key: 'sys' } as const, {})
const modeAtom = atom({ plugin: 'statusbar', key: 'mode' } as const, 'auto')
const alertedAtom = atom({ plugin: 'statusbar', key: 'alerted' } as const, [])
const effortAtom = atom({ plugin: 'statusbar', key: 'effort' } as const, null)
const SYS_MS = 3000
const GIT_MS = 15_000
const DISK_EVERY = 10 // 每 10 次系統取樣(30 秒)讀一次磁碟
const ALERT_AT = 90
const GIT_TOOLS = new Set(['Bash', 'Edit', 'Write', 'NotebookEdit'])
const LABEL: Record<string, string> = { five_hour: '5 小時', seven_day: '7 天' }
function toUsage(context: SessionContextUsage, rateLimits: SessionRateLimit[]): Usage {
const win = (kind: string) => {
const r = rateLimits.find(x => x.kind === kind)
return r && { percent: r.percentUsed, resetsAt: r.resetsAt }
}
return {
fiveHour: win('five_hour'),
sevenDay: win('seven_day'),
ctxTokens: context.tokens,
ctxWindow: context.window,
ctxPercent: context.percent,
}
}
let home: string | undefined
let tzOffsetMin = 0
let prevCpu: CpuSample | null = null
let ticks = 0
async function refreshGit($: EngineInterface) {
try {
const r = await $.process.run(['git', 'status', '--porcelain=v2', '-b'], { timeoutMs: 5000 })
const git = r.exitCode === 0 ? parseGit(r.stdout) : null
await update($, gitAtom, () => git)
} catch {
await update($, gitAtom, () => null)
}
}
async function refreshSys($: EngineInterface) {
const next: Sys = { ...(await read($, sysAtom)) }
try {
const cur = parseCpu(await $.fs.read('/proc/stat'))
if (cur) {
if (prevCpu) next.cpu = cpuPercent(prevCpu, cur) ?? next.cpu
prevCpu = cur
}
} catch {}
try {
const mem = parseMem(await $.fs.read('/proc/meminfo'))
if (mem) [next.memUsed, next.memTotal] = [mem.used, mem.total]
} catch {}
if (ticks++ % DISK_EVERY === 0) {
try {
const r = await $.process.run(['df', '-P', await $.session.cwd()], { timeoutMs: 5000 })
if (r.exitCode === 0) next.disk = parseDf(r.stdout)
} catch {}
}
await update($, sysAtom, () => next)
}
// 每個額度視窗(以重置時間區分)只提醒一次
async function alert($: EngineInterface, rateLimits: SessionRateLimit[]) {
const done = await read($, alertedAtom)
for (const r of rateLimits) {
const id = `${r.kind}@${r.resetsAt ?? ''}`
if (r.percentUsed < ALERT_AT || done.includes(id)) continue
await update($, alertedAtom, list => [...list, id].slice(-20))
const label = LABEL[r.kind] ?? r.kind
$.ui.toast(`${label}額度已用 ${Math.round(r.percentUsed)}%`)
}
}
async function runHud($: EngineInterface, args: string) {
const arg = args.trim()
const mode: Mode = arg === 'off' ? 'off' : arg === 'compact' ? 'compact' : 'auto'
await update($, modeAtom, () => mode)
const said: Record<Mode, string> = { auto: '狀態列已開啟。', off: '狀態列已隱藏。', compact: '狀態列改用極窄版。' }
return { text: said[mode] }
}
export const register: Register = on => {
on('session.start', async ($, e, next) => {
await $.command.register({
name: 'statusbar',
description: '切換輸入框上方的狀態列',
argumentHint: 'on | off | compact',
})
home = await $.env.get('HOME')
try {
const r = await $.process.run(['date', '+%z'], { timeoutMs: 3000 })
tzOffsetMin = parseOffset(r.stdout)
} catch {}
const u = await $.session.usage()
await update($, usageAtom, () => toUsage(u.context, u.rateLimits))
void refreshGit($)
void refreshSys($)
$.clock.every(SYS_MS, () => void refreshSys($))
$.clock.every(GIT_MS, () => void refreshGit($))
return next(e)
})
on('session.measure', async ($, e, next) => {
await update($, usageAtom, () => toUsage(e.context, e.rateLimits))
if (e.changed.includes('rateLimits')) await alert($, e.rateLimits)
return next(e)
})
// 思考等級只在送出模型請求時看得到;只記主對話的,不記 subagent
on('turn.step', async function* ($, e, next) {
if (!e.agentId) {
const effort = e.effort === undefined ? null : String(e.effort)
if (effort !== (await read($, effortAtom))) await update($, effortAtom, () => effort)
}
return yield* next(e)
})
on('tool.call', async ($, e, next) => {
const ran = await next(e)
if (GIT_TOOLS.has(e.tool)) void refreshGit($)
return ran
})
on('command.run', { command: 'statusbar' }, ($, e) => runHud($, e.args))
on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
const mode = await read($, modeAtom)
const rest = await next(e)
if (e.props.hasSurvey || mode === 'off') return rest
const data = {
model: await $.session.model(),
cwd: await $.session.cwd(),
home,
git: await read($, gitAtom),
usage: await read($, usageAtom),
sys: await read($, sysAtom),
tzOffsetMin,
effort: await read($, effortAtom),
}
const line = fit(data, e.props.bodyColumns, mode === 'compact')
const { Box, Text } = $.ui.resolve(e)
// 引擎在 band 與輸入框之間已留一行空白,上方補一行讓上下間距相同
return (
<Box marginTop={1} flexDirection="column">
<Text wrap="truncate">
{line.map(s => (
<Text color={s.color} dimColor={s.dim} bold={s.bold}>
{s.text}
</Text>
))}
</Text>
{rest}
</Box>
)
})
}
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "./.claude-plugin/types/tsconfig.json"
}
+28
View File
@@ -0,0 +1,28 @@
export type Window = { percent: number; resetsAt?: string }
export type Usage = {
fiveHour?: Window
sevenDay?: Window
ctxTokens?: number
ctxWindow?: number
ctxPercent?: number
}
export type Git = { branch: string; dirty: number; ahead: number; behind: number }
export type Sys = { cpu?: number; memUsed?: number; memTotal?: number; disk?: number }
export type Mode = 'auto' | 'compact' | 'off'
declare module 'claude-code' {
interface PluginState {
statusbar: {
usage: Usage
git: Git | null
sys: Sys
mode: Mode
alerted: string[]
effort: string | null
}
}
}