From a0b4008fd5c4df10b9700c4ec7976914ebfb0093 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Mon, 12 Oct 2026 11:29:31 +0800 Subject: [PATCH] feat: initial commit for jsc-marketplace with hud plugin and MIT license --- .claude-plugin/marketplace.json | 14 ++ .gitignore | 1 + LICENSE | 21 +++ README.md | 27 +++ plugins/hud/.claude-plugin/plugin.json | 10 ++ plugins/hud/hooks/format.ts | 234 +++++++++++++++++++++++++ plugins/hud/hooks/hooks.json | 1 + plugins/hud/hooks/hud.test.tsx | 114 ++++++++++++ plugins/hud/hooks/register.tsx | 173 ++++++++++++++++++ plugins/hud/tsconfig.json | 3 + plugins/hud/types/index.d.ts | 28 +++ 11 files changed, 626 insertions(+) create mode 100644 .claude-plugin/marketplace.json create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 plugins/hud/.claude-plugin/plugin.json create mode 100644 plugins/hud/hooks/format.ts create mode 100644 plugins/hud/hooks/hooks.json create mode 100644 plugins/hud/hooks/hud.test.tsx create mode 100644 plugins/hud/hooks/register.tsx create mode 100644 plugins/hud/tsconfig.json create mode 100644 plugins/hud/types/index.d.ts diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..0eb6641 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,14 @@ +{ + "name": "jsc-marketplace", + "description": "JSC 自用 Claude Code mods 的 marketplace。", + "owner": { + "name": "JSC" + }, + "plugins": [ + { + "name": "hud", + "source": "./plugins/hud", + "description": "輸入框上方單行 HUD:模型、目錄、分支、5h/7d 額度與重置、context、CPU/記憶體/磁碟" + } + ] +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..067b5a7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +**/.claude-plugin/types/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..81c3213 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Jeffery (jiantw83) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..95e97b9 --- /dev/null +++ b/README.md @@ -0,0 +1,27 @@ +# jsc-marketplace + +JSC 自用 Claude Code mods。 + +| Mod | 說明 | +| --- | --- | +| [hud](plugins/hud) | 輸入框上方單行 HUD:模型、目錄、分支、5h/7d 額度與重置、context、CPU/記憶體/磁碟 | + +## 安裝 + +本機資料夾: + +```bash +claude plugin marketplace add /root/jsc-marketplace +claude plugin install hud@jsc-marketplace +``` + +或透過 Gitea 遠端: + +```bash +claude plugin marketplace add https://gitea.jsc.idv.tw/jiantw83/jsc-marketplace.git +claude plugin install hud@jsc-marketplace +``` + +## 授權 + +[MIT License](LICENSE) diff --git a/plugins/hud/.claude-plugin/plugin.json b/plugins/hud/.claude-plugin/plugin.json new file mode 100644 index 0000000..819444c --- /dev/null +++ b/plugins/hud/.claude-plugin/plugin.json @@ -0,0 +1,10 @@ +{ + "name": "hud", + "version": "0.1.0", + "description": "輸入框上方單行 HUD:模型、目錄、分支、5h/7d 額度與重置、context、CPU/記憶體/磁碟", + "license": "MIT", + "types": "./types/index.d.ts", + "author": { + "name": "JSC" + } +} diff --git a/plugins/hud/hooks/format.ts b/plugins/hud/hooks/format.ts new file mode 100644 index 0000000..0d93cdd --- /dev/null +++ b/plugins/hud/hooks/format.ts @@ -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 = { + 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 +} diff --git a/plugins/hud/hooks/hooks.json b/plugins/hud/hooks/hooks.json new file mode 100644 index 0000000..5a63f64 --- /dev/null +++ b/plugins/hud/hooks/hooks.json @@ -0,0 +1 @@ +{ "modules": ["./register.tsx"] } diff --git a/plugins/hud/hooks/hud.test.tsx b/plugins/hud/hooks/hud.test.tsx new file mode 100644 index 0000000..9f040f4 --- /dev/null +++ b/plugins/hud/hooks/hud.test.tsx @@ -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[0]) + expect(JSON.stringify(ran)).toContain('隱藏') + }) +}) diff --git a/plugins/hud/hooks/register.tsx b/plugins/hud/hooks/register.tsx new file mode 100644 index 0000000..b3be63f --- /dev/null +++ b/plugins/hud/hooks/register.tsx @@ -0,0 +1,173 @@ +import { atom, read, update } from 'claude-code' +import type { EngineInterface, Register, SessionContextUsage, SessionRateLimit } from 'claude-code' + +import type { Mode, Sys, Usage } from '../types' +import { cpuPercent, fit, parseCpu, parseDf, parseGit, parseMem, parseOffset } from './format' +import type { CpuSample } from './format' + +const usageAtom = atom({ plugin: 'hud', key: 'usage' } as const, {}) +const gitAtom = atom({ plugin: 'hud', key: 'git' } as const, null) +const sysAtom = atom({ plugin: 'hud', key: 'sys' } as const, {}) +const modeAtom = atom({ plugin: 'hud', key: 'mode' } as const, 'auto') +const alertedAtom = atom({ plugin: 'hud', key: 'alerted' } as const, []) +const effortAtom = atom({ plugin: 'hud', 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 = { 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)}%`) + } +} + +export const register: Register = on => { + on('session.start', async ($, e, next) => { + await $.command.register({ + name: 'hud', + 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: 'hud' }, async ($, e) => { + const arg = e.args.trim() + const mode: Mode = arg === 'off' ? 'off' : arg === 'compact' ? 'compact' : 'auto' + await update($, modeAtom, () => mode) + const said: Record = { auto: '狀態列已開啟。', off: '狀態列已隱藏。', compact: '狀態列改用極窄版。' } + + return { text: said[mode] } + }) + + on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => { + const mode = await read($, modeAtom) + if (e.props.hasSurvey || mode === 'off') return next(e) + + const line = fit( + { + 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), + }, + e.props.bodyColumns, + mode === 'compact', + ) + const { Box, Text } = $.ui.resolve(e) + + // 引擎在 band 與輸入框之間已留一行空白,上方補一行讓上下間距相同 + return ( + + + {line.map(s => ( + + {s.text} + + ))} + + + ) + }) +} diff --git a/plugins/hud/tsconfig.json b/plugins/hud/tsconfig.json new file mode 100644 index 0000000..8681ec0 --- /dev/null +++ b/plugins/hud/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./.claude-plugin/types/tsconfig.json" +} diff --git a/plugins/hud/types/index.d.ts b/plugins/hud/types/index.d.ts new file mode 100644 index 0000000..e8852b4 --- /dev/null +++ b/plugins/hud/types/index.d.ts @@ -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 { + hud: { + usage: Usage + git: Git | null + sys: Sys + mode: Mode + alerted: string[] + effort: string | null + } + } +}