A Tron-style HUD for Claude Code: a per-turn ledger (tokens, tools, skills, agents, Jira card, cost), limit bars, an agent radar, a cross-session summary and…

A Tron-style HUD for Claude Code. It records every session in detail, shows your limits and spend before they surprise you, tracks subagents live, and has a light-cycle game for the minutes Claude is working.

Needs Claude Code 2.1.287 or later. Terminal only for the drawings (it uses the Raster element); other surfaces get a text line.
claude --plugin-dir /path/to/claudegrid claude plugin validate /path/to/claudegrid # lists every event it hooks and every API call it makes
A mod runs with your machine access. Read the validate output before installing any mod, this one included.
Band above the prompt. Card, session, turn, clock; 5-hour and 7-day limit bars; tokens, cost, today's spend against your budget, context, model, and how many subagents are running.
Press /grid (Esc closes; 1 ledger, 2 GRID RUN, 3 SUMMARY, 4 SYSTEM, t theme). A data log of every tool call, a live line per subagent, per-turn token bars, tool time, and a step table with every model request's token split.

Cost, tokens and tools by card, day, model, skill, agent, tool or project. ↑ ↓ select, Enter opens, v changes view, r changes range.

CPU, memory, thermal state and the busiest processes, sampled every 3 seconds while the tab is open. Useful when an agent starts a build or the simulator.

Light cycles against Clu. w a s d steer, p pause, g new run, l level. It pauses and says CLAUDE DONE when Claude finishes a turn, so you never miss a reply.

Alerts (toasts, once per window): limits at 80% and 90%, daily budget at 80% and 100%, each subagent finishing.
/grid help lists these. If another mod already owns /grid, this one registers /claudegrid, then /cgrid.
| command | does | |
|---|---|---|
/grid | open the console | |
/grid text | session summary in the transcript | |
/grid summary [dimension] [CARD-KEY] [range] | the SUMMARY tab, pre-filtered (add text for the transcript version) | |
/grid card [KEY], /grid cards | one Jira card across every session, or every card by cost | |
/grid system | the SYSTEM tab (/grid system text for the transcript) | |
/grid export [path] | every turn as CSV (default ~/.claude/claudegrid/claudegrid.csv) | |
/grid today, `/grid budget N\ | off` | spend today; daily budget in USD |
| `/grid theme [soft\ | neon]` | SOFT (default) or the bolder NEON |
| `/grid prompts on\ | off` | save prompt text in the ledger files |
/grid billing, /grid probe | how this session is billed; what this build exposes to a mod |
Hotkeys only work while the pane has keyboard focus: open it with /grid when the prompt is empty, or Ctrl+X then Tab.
Every completed turn is one row in ~/.claude/claudegrid/<session>/rows.json, with full detail in turn-NNNN.json next to it. The SUMMARY reads every session's file and stays live: other sessions are picked up within 15 seconds while the tab is on screen, and nothing is scanned while the pane is closed. Cross-session books (spend per day, totals per card) live in the mod's own $.store.
Token counts are exact API numbers; per-tool result tokens are estimates; tool time includes permission waits. Cost is reported for the whole session, so it is not split per tool or model. On a subscription login it shows as COST~, an API-equivalent estimate rather than what you pay.
The SYSTEM tab runs read-only commands as you (top, vm_stat, sysctl, pmset, ps on a Mac; /proc and ps on Linux). It shows no temperature, only the thermal state on a Mac. The macOS path is tested against sample output, not on a Mac; if a number looks wrong, /grid system text prints the readings.
Everything stays on your machine. To tell what the cost figure means, the mod checks whether ANTHROPIC_API_KEY (or the Bedrock/Vertex flags) is set; it only tests that the value is non-empty and discards it. Prompts are trimmed to 200 characters (/grid prompts off stops saving them), tool arguments are stored as one redacted line, and process names are shown on screen only. The Jira card comes from your branch name (feature/ABC-123-thing), falling back to a key in your prompt.
/grid text ends with a game line and any last error. session.json has seenFields, the fields each event actually carried, for checking against your Claude Code version. Events carry no agent id, so with several subagents running the oldest is marked done first.
hooks/game.ts: LEVELS, HARD_FOCUS. hooks/canvas.ts: themes.
MIT licensed. Copyright (c) 2026 Jatin Mishra.
claudegrid is an independent community project. It is not affiliated with, endorsed by or sponsored by Anthropic or The Walt Disney Company. Claude and Claude Code are trademarks of Anthropic. Tron is a trademark of Disney Enterprises, Inc.; this project only borrows the look (neon grid, light cycles) as a homage, and contains no Disney artwork, sound or code.
hooks/register.ts 1292 lines1// claudegrid v1.3 — granular session ledger for Claude Code, drawn like a light-cycle HUD.
2//
3// Records per turn: prompt, Jira card, timing, every model request (model + the four token
4// counts + stop reason), every tool call, skills, subagents, and cost.
5// Files: ~/.claude/claudegrid/<sessionId>/turn-0001.json … and session.json.
6//
7// UI: a framed band above the prompt, plus `/grid` which opens the console.
8// Both are drawn with a Raster (terminal only); other surfaces get a plain text line.
9
10import {
11 jiraKey, jiraKeyFromPrompt, summarizeArgs, safeLen, estTokens, mcpServer,
12 fmtTokens, fmtDur, fmtMs, fmtUsd, cacheHit, trunc, clamp,
13} from './lib.ts'
14import { renderBand, renderConsole, plainLine, layout } from './hud.ts'
15import { setTheme, nextThemeName, THEMES, T } from './canvas.ts'
16import { newGame, turn as steer, step as stepGame, togglePause, restart, nextLevel, LEVELS } from './game.ts'
17import { rowFromRecord, parseSummaryArgs, renderSummary, toCsv } from './summary.ts'
18import { renderSummaryPane, mainAggs, RANGE_PRESETS, SVIEWS } from './summaryview.ts'
19import type { SView } from './summaryview.ts'
20import { parseVmStat, parseTopCpu, parseSwapUsage, parsePressure, parsePmsetTherm, parseTempText, parseMeminfo, parseProcStat, cpuBetween, parseLoad, pickLinuxTemp, parsePs } from './sysmon.ts'
21import type { Sample } from './sysmon.ts'
22import { renderSystemPane, systemText } from './sysview.ts'
23import type { Row } from './summary.ts'
24import type { Snapshot, LogEntry, StepEntry } from './hud.ts'
25
26const SCHEMA_VERSION = 3 // the per-turn ledger files are unchanged since v0.3
27const COMMANDS = ['grid', 'claudegrid', 'cgrid'] // first one Claude Code accepts wins (a clash with another mod falls through)
28const PANE = 'claudegrid'
29const TICK_MS = 50
30const SYS_EVERY_MS = 3000 // how often the SYSTEM tab samples while it is on screen
31
32const S: any = {
33 inited: false,
34 id: 's' + Date.now().toString(36),
35 dir: '', cwd: '', branch: '', jira: null as string | null,
36 startedAt: Date.now(),
37 lastPrompt: '',
38 turns: 0, bgCount: 0, seq: 0, dupCompletes: 0, lastActivity: Date.now(),
39 active: new Set<string>(), records: {} as Record<string, any>, mainKey: '', fallback: null as any,
40 lastRecord: null as any, lastFile: '', lastOrigin: '',
41 paneOpen: false, paneSeen: 0, timer: null as any, tick: 0, timerTicks: 0,
42 tab: 'ledger' as 'ledger' | 'game' | 'summary' | 'system', game: null as any, acc: 0,
43 sys: { sample: null as Sample | null, cpuHist: [] as number[], memHist: [] as number[], tempHist: [] as number[], busy: false, lastAt: 0, error: '', platform: '' as '' | 'darwin' | 'linux' | 'other', memTotal: 0, cores: 0, prevStat: null as any, tempTool: '' as '' | 'osx-cpu-temp' | 'istats' | 'none', thermal: null as any, thermalAt: 0 },
44 sum: { others: [] as Row[], notes: [] as string[], otherSessions: 0, cache: {} as Record<string, { sig: string; rows: Row[] }>, loaded: false, loading: false, loadedAt: 0, note: '', view: 'overview' as SView, days: 30 as number | null, sel: 0, detailCard: null as string | null, pageRows: 10, ringEl: '', lastFocusStep: 0, backView: 'overview' as SView, backSel: 0 },
45 totals: { in: 0, out: 0, cacheRead: 0, cacheWrite: 0, steps: 0, tools: 0, toolMs: 0, toolFail: 0 },
46 models: {} as Record<string, number>,
47 tools: {} as Record<string, { n: number; ms: number; fail: number; estTokens: number }>,
48 skills: {} as Record<string, { n: number; chars: number }>,
49 agents: {} as Record<string, { n: number; models: Record<string, number> }>,
50 cost: null as any, costUsd: 0, cumBySeq: {} as Record<number, number>,
51 context: null as any, rateLimits: null as any,
52 log: [] as LogEntry[],
53 stepLog: [] as StepEntry[],
54 turnLog: [] as { label: string; out: number; ms: number; seq: number }[],
55 seenFields: {} as Record<string, string[]>,
56 lastError: '',
57 cmd: 'grid',
58 agentRuns: [] as { type: string; desc: string; t0: number; t1: number | null }[],
59 acct: { usd: 0, in: 0, out: 0, cacheRead: 0, cacheWrite: 0, seq: 0 },
60 today: 0, budget: null as number | null, logPrompts: true,
61 cardLife: null as any, cardCounted: new Set<string>(), alerted: new Set<string>(),
62 doneFlash: false, acctLoaded: false, usageTriedAt: 0,
63 envBilling: null as null | 'api' | 'cloud', probe: {} as Record<string, any>,
64 home: '', root: '', rows: [] as Row[], lastRow: null as Row | null, lastRowSeq: -1,
65}
66
67// ---------- utilities ----------
68const str = (v: any): string => (typeof v === 'string' ? v : '')
69const num = (v: any): number => (typeof v === 'number' && isFinite(v) ? v : 0)
70const pad = (n: number): string => String(n).padStart(4, '0')
71// Working = a turn is open AND something happened in the last two minutes.
72const isWorking = (): boolean => S.active.size > 0 && Date.now() - S.lastActivity < 120_000
73const touch = () => { S.lastActivity = Date.now() }
74// The pane counts as visible if it was drawn in the last 2.5s. This does not depend on this module having
75// opened it, so it still works after a hot reload (which resets module state while the pane stays open).
76const paneVisible = (): boolean => Date.now() - S.paneSeen < 2500
77const redraw = ($: any) => { try { $.ui.invalidate('ui.render') } catch { /* cosmetic */ } }
78
79function seen(name: string, obj: any) {
80 if (S.seenFields[name] || !obj || typeof obj !== 'object') return
81 try { S.seenFields[name] = Object.keys(obj) } catch { /* ignore */ }
82}
83
84function pushLog(kind: LogEntry['kind'], name: string, detail: string, ms: number, ok: boolean) {
85 S.log.push({ t: Date.now(), kind, name, detail, ms, ok })
86 if (S.log.length > 80) S.log.splice(0, S.log.length - 80)
87}
88
89async function sh($: any, argv: string[]): Promise<string> {
90 try {
91 const r = await $.process.run(argv)
92 return (typeof r === 'string' ? r : str(r?.stdout)).trim()
93 } catch { return '' }
94}
95
96async function save($: any, name: string, data: unknown, compact = false) {
97 if (!S.dir) return
98 try { await $.fs.write(`${S.dir}/${name}`, compact ? JSON.stringify(data) : JSON.stringify(data, null, 2)) }
99 catch (err) { S.lastError = `write ${name}: ${String(err)}` }
100}
101
102const costFor = (seq: number): number =>
103 Math.max(0, (S.cumBySeq[seq] ?? S.cumBySeq[seq - 1] ?? 0) - (S.cumBySeq[seq - 1] ?? 0))
104
105function rate(kind: string): number | null {
106 const r = Array.isArray(S.rateLimits) ? S.rateLimits.find((x: any) => x?.kind === kind) : null
107 return r && typeof r.percentUsed === 'number' ? r.percentUsed : null
108}
109
110// How far through a limit window we are (drawn as a marker on the bar).
111const WINDOW_MS: Record<string, number> = { five_hour: 5 * 3600_000, seven_day: 7 * 86_400_000 }
112function limit(kind: string): { pct: number; elapsed: number; resetsAt: string } | null {
113 const r = Array.isArray(S.rateLimits) ? S.rateLimits.find((x: any) => x?.kind === kind) : null
114 if (!r || typeof r.percentUsed !== 'number') return null
115 const reset = Date.parse(str(r.resetsAt))
116 const win = WINDOW_MS[kind]
117 const elapsed = isFinite(reset) && win ? clamp(1 - (reset - Date.now()) / win, 0, 1) : 0
118 return { pct: r.percentUsed, elapsed, resetsAt: str(r.resetsAt) }
119}
120
121// Read cost, context and plan limits from the session. session.measure only fires after a turn, so without
122// this the limits would show dashes until the first turn finishes.
123async function refreshUsage($: any): Promise<boolean> {
124 S.usageTriedAt = Date.now()
125 try {
126 const u = await $.session.usage()
127 seen('session.usage', u)
128 S.cost = u?.cost ?? S.cost
129 S.costUsd = num(u?.cost?.usd) || S.costUsd
130 S.context = u?.context ?? S.context
131 if (Array.isArray(u?.rateLimits)) S.rateLimits = u.rateLimits
132 return true
133 } catch (err) {
134 S.lastError = `usage: ${String(err)}`
135 return false
136 }
137}
138
139// Called from the draw hooks: if the limits still haven't loaded, fetch them now (at most every 5 seconds).
140function ensureUsage($: any) {
141 if (S.rateLimits || Date.now() - S.usageTriedAt < 5000) return
142 refreshUsage($).then(() => redraw($)).catch(() => {})
143}
144
145// How is this session billed? That decides what the cost figure means.
146// - ANTHROPIC_API_KEY set -> Claude Code bills the API with it (so COST is real money)
147// - Bedrock / Vertex flags -> billed by the cloud provider
148// - otherwise, 5h/7d limits present -> a subscription login (COST is an API-equivalent estimate, shown as COST~)
149// $.env.get returns the value, so the mod only tests whether it is non-empty and discards it: it is never
150// stored, logged, written to a file, or shown.
151async function detectEnvBilling($: any) {
152 // Claude Code's validator wants a literal variable name at every $.env.get call, so each one is spelled out.
153 const set = (v: unknown): boolean => typeof v === 'string' && v.length > 0
154 let bedrock = false
155 let vertex = false
156 let key = false
157 try { bedrock = set(await $.env.get('CLAUDE_CODE_USE_BEDROCK')) } catch { /* not set */ }
158 try { vertex = set(await $.env.get('CLAUDE_CODE_USE_VERTEX')) } catch { /* not set */ }
159 try { key = set(await $.env.get('ANTHROPIC_API_KEY')) } catch { /* not set */ }
160 if (bedrock || vertex) S.envBilling = 'cloud'
161 else if (key) S.envBilling = 'api'
162}
163const billingMode = (): 'subscription' | 'api' | 'cloud' | 'unknown' =>
164 S.envBilling ?? (Array.isArray(S.rateLimits) && S.rateLimits.length ? 'subscription' : 'unknown')
165const billingNote = (): string => ({
166 subscription: 'subscription login (5h/7d limits present): COST~ is an API-equivalent estimate, not what you pay',
167 api: 'ANTHROPIC_API_KEY is set: Claude Code bills the API, so COST is real charges',
168 cloud: 'Bedrock/Vertex is enabled: billed by your cloud provider, so COST is real charges',
169 unknown: 'could not tell (no plan limits and no API key found): COST is Claude Code\'s own figure',
170})[billingMode()]
171
172// A one-off look at what else this Claude Code build exposes to a mod, in case it names the plan.
173// It records the SHAPE only (key names, row names, a few short harmless values), never settings values.
174async function probeApi($: any) {
175 const shape = (v: any): any => {
176 if (Array.isArray(v)) {
177 const first = v[0]
178 const rowNames = v.map((r: any) => (r && typeof r === 'object' ? str(r.key) || str(r.label) || str(r.id) || str(r.name) : '')).filter(Boolean).slice(0, 80)
179 return { type: 'array', length: v.length, itemKeys: first && typeof first === 'object' ? Object.keys(first).slice(0, 30) : typeof first, rowNames }
180 }
181 if (v && typeof v === 'object') return { type: 'object', keys: Object.keys(v).slice(0, 80) }
182 return { type: typeof v }
183 }
184 const tryCall = async (name: string, fn: () => any, keepValue = false) => {
185 try {
186 const v = await fn()
187 S.probe[name] = keepValue && (typeof v === 'string' || Array.isArray(v)) ? { value: Array.isArray(v) ? v.slice(0, 10) : v.slice(0, 40) } : shape(v)
188 } catch (err) { S.probe[name] = { error: String(err).slice(0, 120) } }
189 }
190 await tryCall('settings.read', () => $.settings.read())
191 await tryCall('config.list', () => $.config.list())
192 await tryCall('session.version', () => $.session.version(), true)
193 await tryCall('session.surfaces', () => $.session.surfaces(), true)
194 await tryCall('session.model', () => $.session.model(), true)
195 // names anywhere in what we saw that look plan-related
196 const names: string[] = []
197 const walk = (o: any) => {
198 if (Array.isArray(o)) o.forEach(walk)
199 else if (o && typeof o === 'object') for (const [k, v] of Object.entries(o)) { names.push(k); walk(v) }
200 else if (typeof o === 'string') names.push(o)
201 }
202 walk(S.probe)
203 walk(S.seenFields)
204 S.probe.planHints = [...new Set(names.filter((n) => /plan|subscri|tier|account|billing|seat|org|oauth|login/i.test(n)))].slice(0, 30)
205}
206
207const toast = async ($: any, msg: string) => { try { await $.ui.toast(msg) } catch { /* cosmetic */ } }
208const storeGet = async ($: any, k: string): Promise<any> => { try { return await $.store.get(k) } catch { return undefined } }
209const storeSet = async ($: any, k: string, v: any) => {
210 try { await $.store.set(k, v) } catch (err) { S.lastError = `store ${k}: ${String(err)}` }
211}
212const dayKey = (): string => {
213 const d = new Date()
214 const p = (n: number) => String(n).padStart(2, '0')
215 return `day:${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
216}
217
218// Cross-session books, kept in $.store: spend per day, and tokens/cost per Jira card.
219// Called after each turn with the change since the last call, so nothing is counted twice.
220async function account($: any) {
221 if (!S.acctLoaded) {
222 // After a hot reload this module's state starts from zero but the session's cost does not. The cost
223 // already booked is remembered per session, so a reload can't count it twice. (Tokens are counted
224 // from what this module instance has seen, so they start from zero either way.)
225 S.acctLoaded = true
226 const saved = await storeGet($, 'acct:' + S.id)
227 if (saved && typeof saved.usd === 'number') S.acct.usd = saved.usd
228 }
229 const t = S.totals
230 const d = {
231 usd: Math.max(0, S.costUsd - S.acct.usd), in: t.in - S.acct.in, out: t.out - S.acct.out,
232 cacheRead: t.cacheRead - S.acct.cacheRead, cacheWrite: t.cacheWrite - S.acct.cacheWrite, turns: S.seq - S.acct.seq,
233 }
234 if (!d.usd && !d.in && !d.out && !d.cacheRead && !d.cacheWrite && !d.turns) return
235 S.acct = { usd: S.costUsd, in: t.in, out: t.out, cacheRead: t.cacheRead, cacheWrite: t.cacheWrite, seq: S.seq }
236 await storeSet($, 'acct:' + S.id, { usd: S.costUsd })
237
238 const dk = dayKey()
239 const day = (await storeGet($, dk)) ?? { usd: 0 }
240 day.usd = (day.usd ?? 0) + d.usd
241 await storeSet($, dk, day)
242 S.today = day.usd
243
244 if (S.jira) {
245 const ck = 'card:' + S.jira
246 const c = (await storeGet($, ck)) ?? { usd: 0, in: 0, out: 0, cacheRead: 0, cacheWrite: 0, turns: 0, sessions: 0, firstAt: new Date().toISOString() }
247 c.usd += d.usd; c.in += d.in; c.out += d.out; c.cacheRead += d.cacheRead; c.cacheWrite += d.cacheWrite; c.turns += d.turns
248 if (!S.cardCounted.has(ck)) { S.cardCounted.add(ck); c.sessions += 1 }
249 c.lastAt = new Date().toISOString()
250 await storeSet($, ck, c)
251 S.cardLife = c
252 }
253}
254
255// One toast per threshold per window: limits at 80% and 90%, and the daily budget.
256async function alerts($: any) {
257 const once = async (key: string, msg: string) => { if (!S.alerted.has(key)) { S.alerted.add(key); await toast($, msg) } }
258 for (const [kind, name] of [['five_hour', '5-hour'], ['seven_day', '7-day']] as const) {
259 const l = limit(kind)
260 if (!l) continue
261 for (const th of [80, 90]) if (l.pct >= th) await once(`${kind}:${th}:${l.resetsAt}`, `${name} limit at ${Math.round(l.pct)}%`)
262 }
263 if (S.budget && S.today >= S.budget * 0.8) await once(`budget80:${dayKey()}`, `Daily budget 80% used (${fmtUsd(S.today)} of ${fmtUsd(S.budget)})`)
264 if (S.budget && S.today >= S.budget) await once(`budget100:${dayKey()}`, `Daily budget reached (${fmtUsd(S.today)})`)
265}
266
267function snapshot(): Snapshot {
268 const lastSeq = S.turnLog.length ? S.turnLog[S.turnLog.length - 1].seq : 0
269 return {
270 id: S.id, jira: S.jira, branch: S.branch, working: isWorking(), now: Date.now(), startedAt: S.startedAt,
271 turns: S.turns, steps: S.totals.steps, toolCalls: S.totals.tools, toolFail: S.totals.toolFail,
272 tokens: { in: S.totals.in, out: S.totals.out, cacheRead: S.totals.cacheRead, cacheWrite: S.totals.cacheWrite },
273 costUsd: S.costUsd, turnCostUsd: costFor(lastSeq),
274 models: S.models, toolStats: S.tools, skills: S.skills, agents: S.agents,
275 ctxPct: num(S.context?.percent), fiveHour: rate('five_hour'), sevenDay: rate('seven_day'),
276 log: S.log, stepLog: S.stepLog,
277 turnLog: S.turnLog.map((t: any) => ({ label: t.label, out: t.out, ms: t.ms, usd: costFor(t.seq) })),
278 fiveHourElapsed: limit('five_hour')?.elapsed ?? null, sevenDayElapsed: limit('seven_day')?.elapsed ?? null,
279 todayUsd: S.today, budgetUsd: S.budget,
280 cardLife: S.cardLife ? { usd: S.cardLife.usd, out: S.cardLife.out, turns: S.cardLife.turns, sessions: S.cardLife.sessions } : null,
281 agentRuns: S.agentRuns.slice(-8), claudeDone: S.doneFlash, costMode: billingMode(),
282 }
283}
284
285function sessionSummary() {
286 return {
287 schemaVersion: SCHEMA_VERSION, sessionId: S.id, cwd: S.cwd, branch: S.branch, jira: S.jira,
288 startedAt: new Date(S.startedAt).toISOString(), elapsedMs: Date.now() - S.startedAt, turns: S.turns,
289 totals: S.totals, models: S.models, tools: S.tools, skills: S.skills, agents: S.agents,
290 cost: S.cost, records: S.turnLog.map((t: any) => ({ label: t.label, out: t.out, ms: t.ms, costUsd: costFor(t.seq) })),
291 context: S.context, rateLimits: S.rateLimits, duplicateCompletes: S.dupCompletes, billing: billingMode(), probe: S.probe, seenFields: S.seenFields, lastError: S.lastError || undefined,
292 }
293}
294
295// A record is one turn.start..turn.complete span. Records are keyed by turnId so a background
296// subagent's turn (or a turn that completes twice) never overwrites the main one.
297function newRecord(kind: 'turn' | 'bg', turnId: any, key?: string) {
298 const rec: any = {
299 schemaVersion: SCHEMA_VERSION, sessionId: S.id, kind,
300 turn: kind === 'turn' ? S.turns : null, bg: kind === 'bg' ? ++S.bgCount : null,
301 turnId: turnId ?? null, prompt: '', origin: null, jira: S.jira, branch: S.branch,
302 startedAt: new Date().toISOString(), t0: Date.now(), steps: [], tools: [], skills: [], agents: [],
303 }
304 if (key) S.records[key] = rec
305 return rec
306}
307
308const label = (rec: any): string => (rec.kind === 'turn' ? 'T' + rec.turn : 'B' + rec.bg)
309
310// The record tool/skill/agent events attach to (they carry no turnId): the latest started turn.
311function mainRec() {
312 return (S.mainKey && S.records[S.mainKey]) || (S.fallback ??= newRecord('bg', null))
313}
314
315function recFor(turnId: any) {
316 if (turnId == null) return mainRec()
317 return S.records[String(turnId)] ?? newRecord('bg', turnId, String(turnId))
318}
319
320// "<agent-message …>" and similar system prompts are noise in a log; say what they are.
321function cleanPrompt(raw: string): string {
322 const t = raw.trim()
323 if (t.startsWith('<')) {
324 const inner = t.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim()
325 return inner ? 'system: ' + trunc(inner, 80) : 'system message'
326 }
327 return trunc(t, 200)
328}
329
330// ---------- the central ledger ----------
331// Each session keeps rows.json: one compact row per completed turn. A summary reads every session's rows.json
332// from ~/.claude/claudegrid/, so nothing is shared between running sessions and nothing can be overwritten by another.
333const baseName = (p: string): string => p.split(/[\\/]/).filter(Boolean).pop() ?? ''
334
335async function readJson($: any, path: string): Promise<any> {
336 try {
337 const r = await $.fs.read(path)
338 const t = typeof r === 'string' ? r : str(r?.content) || str(r?.text)
339 return t ? JSON.parse(t) : null
340 } catch { return null }
341}
342
343function pushRow(rec: any): Row {
344 const row = rowFromRecord(rec, { session: S.id, project: baseName(S.cwd), billing: billingMode() })
345 const i = S.rows.findIndex((r: Row) => r.id === row.id)
346 if (i >= 0) S.rows[i] = row
347 else S.rows.push(row)
348 return row
349}
350
351// After a hot reload this module starts empty while the session's files do not. Pick the numbering and the
352// running totals back up from rows.json, so turn files are never overwritten and the HUD doesn't drop to zero.
353async function restoreOwnRows($: any) {
354 if (!S.dir) return
355 const rows = await readJson($, `${S.dir}/rows.json`)
356 if (!Array.isArray(rows) || rows.length === 0) return
357 S.rows = rows.filter((r: any) => r && r.id)
358 for (const r of S.rows as Row[]) {
359 if (r.kind === 'turn') S.turns = Math.max(S.turns, r.n)
360 else S.bgCount = Math.max(S.bgCount, r.n)
361 S.totals.in += r.in; S.totals.out += r.out; S.totals.cacheRead += r.cacheRead; S.totals.cacheWrite += r.cacheWrite
362 S.totals.steps += r.steps; S.totals.tools += r.toolCalls; S.totals.toolMs += r.toolMs; S.totals.toolFail += r.fails
363 for (const [m, c] of Object.entries(r.models)) S.models[m] = (S.models[m] ?? 0) + c
364 for (const [name, c] of Object.entries(r.tools)) {
365 const t = (S.tools[name] ??= { n: 0, ms: 0, fail: 0, estTokens: 0 })
366 t.n += c
367 }
368 for (const k of r.skills) (S.skills[k] ??= { n: 0, chars: 0 }).n += 1
369 for (const a of r.agents) (S.agents[a] ??= { n: 0, models: {} }).n += 1
370 }
371 S.seq = S.rows.length
372 S.acct = { ...S.acct, in: S.totals.in, out: S.totals.out, cacheRead: S.totals.cacheRead, cacheWrite: S.totals.cacheWrite, seq: S.seq }
373}
374
375const isDir = (e: any): boolean => !str(e?.name).startsWith('.') && (/dir/i.test(str(e?.kind)) || (!str(e?.kind) && !str(e?.name).includes('.')))
376
377// Sessions recorded before rows.json existed: rebuild their rows from the per-turn files (once; the result is cached).
378async function backfill($: any, base: string, session: string): Promise<Row[]> {
379 let files: any[] = []
380 try { files = await $.fs.list(base) } catch { return [] }
381 const project = baseName(str((await readJson($, `${base}/session.json`))?.cwd))
382 const out: Row[] = []
383 for (const f of files) {
384 if (!/^(turn|bg)-\d+\.json$/.test(str(f?.name))) continue
385 const rec = await readJson($, `${base}/${f.name}`)
386 if (rec && rec.endedAt) out.push(rowFromRecord(rec, { session: str(rec.sessionId) || session, project }))
387 }
388 return out
389}
390
391// A cheap fingerprint of one session folder: rows.json's size and modification time, or the folder's own time while it has no
392// rows.json yet. If it hasn't changed since the last scan, the rows read last time are still right.
393async function sigOf($: any, base: string): Promise<string> {
394 try { const st = await $.fs.stat(`${base}/rows.json`); return `r${st.size}:${Math.round(st.mtimeMs)}` } catch { /* no rows.json yet */ }
395 try { const st = await $.fs.stat(base); return `d${Math.round(st.mtimeMs)}` } catch { return '' }
396}
397
398// Rows from every OTHER session folder. This session is not read from disk: its rows live in memory (S.rows), which is what keeps the
399// summary live between scans. `force` re-reads everything; otherwise only folders whose fingerprint changed are read again.
400async function scanOthers($: any, force = false): Promise<{ rows: Row[]; notes: string[]; sessions: number }> {
401 const CAP = 800
402 const seen = new Set<string>()
403 const rows: Row[] = []
404 const add = (list: Row[]) => { for (const r of list) if (r && r.id && !seen.has(r.id)) { seen.add(r.id); rows.push(r) } }
405 let entries: any[] = []
406 try { entries = await $.fs.list(S.root) } catch (err) { return { rows: [], notes: [`Could not read ${S.root}: ${String(err)}`], sessions: 0 } }
407 const dirs = entries.filter(isDir).map((e: any) => str(e.name))
408 let backfilled = 0
409 let upgraded = 0
410 for (const name of dirs.slice(0, CAP)) {
411 if (name === S.id) continue
412 const base = `${S.root}/${name}`
413 const sig = await sigOf($, base)
414 const hit = S.sum.cache[name]
415 if (!force && hit && sig !== '' && hit.sig === sig) { add(hit.rows); continue }
416 let list = await readJson($, `${base}/rows.json`)
417 if (!Array.isArray(list)) {
418 list = await backfill($, base, name)
419 if (list.length) {
420 backfilled++
421 try { await $.fs.write(`${base}/rows.json`, JSON.stringify(list)) } catch { /* cache only */ }
422 }
423 } else if (list.some((r: any) => r && r.v !== 2)) {
424 // Rows saved before per-tool and per-model figures existed: upgrade them from the turn files where those survive.
425 // A row with no turn file behind it is kept as it was.
426 const fresh = await backfill($, base, name)
427 if (fresh.length) {
428 const byId = new Map(fresh.map((r) => [r.id, r]))
429 list = list.map((r: any) => (r && r.v !== 2 && byId.has(r.id) ? byId.get(r.id) : r))
430 upgraded++
431 try { await $.fs.write(`${base}/rows.json`, JSON.stringify(list)) } catch { /* cache only */ }
432 }
433 }
434 S.sum.cache[name] = { sig: await sigOf($, base), rows: list }
435 add(list)
436 }
437 for (const k of Object.keys(S.sum.cache)) if (!dirs.includes(k)) delete S.sum.cache[k] // a folder that was deleted
438 const notes: string[] = []
439 if (dirs.length > CAP) notes.push(`only the first ${CAP} of ${dirs.length} session folders were read`)
440 if (backfilled) notes.push(`${backfilled} older sessions were rebuilt from their turn files`)
441 if (upgraded) notes.push(`${upgraded} sessions were upgraded with per-tool and per-model figures`)
442 // Data copied in the wrong shape is the commonest reason for an empty summary: say so instead of showing nothing.
443 const loose = entries.filter((e: any) => /^(turn|bg)-\d+\.json$/.test(str(e?.name))).length
444 if (loose) notes.push(`${loose} loose turn files sit directly in ${S.root}; each session needs its own folder (<session-id>/turn-0001.json, session.json, ...)`)
445 return { rows, notes, sessions: new Set(rows.map((r) => r.session)).size }
446}
447
448// Every row there is: other sessions as last scanned, plus this session live from memory.
449function allRows(): Row[] {
450 return S.sum.others.length ? [...S.sum.others, ...S.rows] : S.rows.slice()
451}
452
453function sumNote(): string {
454 const sessions = S.sum.otherSessions + (S.rows.length ? 1 : 0)
455 return [`${sessions} sessions in ${S.root}`, ...S.sum.notes].join(' · ')
456}
457
458// What the SUMMARY tab draws and its keys act on, built fresh each time so a finished turn shows up at once.
459function sumState() {
460 return { ...S.sum, rows: allRows(), note: sumNote(), now: Date.now() }
461}
462
463async function loadAllRows($: any): Promise<{ rows: Row[]; note: string }> {
464 const o = await scanOthers($)
465 S.sum.others = o.rows; S.sum.notes = o.notes; S.sum.otherSessions = o.sessions
466 return { rows: allRows(), note: sumNote() }
467}
468
469// Move the selected row in the SUMMARY table by `delta`, staying inside the list.
470function moveSel(delta: number) {
471 const len = mainAggs(sumState()).length
472 S.sum.sel = clamp(S.sum.sel + delta, 0, Math.max(0, len - 1))
473}
474
475async function loadSummary($: any, force = false) {
476 if (S.sum.loading) return
477 S.sum.loading = true
478 redraw($)
479 try {
480 const o = await scanOthers($, force)
481 S.sum.others = o.rows
482 S.sum.notes = o.notes
483 S.sum.otherSessions = o.sessions
484 S.sum.loaded = true
485 S.sum.loadedAt = Date.now()
486 } catch (err) { S.lastError = `summary: ${String(err)}` } finally {
487 S.sum.loading = false
488 redraw($)
489 }
490}
491
492async function exportCsv($: any, target: string): Promise<string> {
493 if (!S.root) return 'The ledger folder is unavailable (no home directory found).'
494 const { rows, note } = await loadAllRows($)
495 const path = (target || `${S.root}/claudegrid.csv`).replace(/^~(?=[\\/])/, S.home)
496 let csv = toCsv(rows)
497 let kept = rows.length
498 if (csv.length > 3_900_000) { // the file API stops at 4 MiB: keep the newest turns
499 const newest = [...rows].sort((x, y) => (x.at < y.at ? 1 : -1)).slice(0, Math.floor(rows.length * (3_800_000 / csv.length)))
500 csv = toCsv(newest); kept = newest.length
501 }
502 try { await $.fs.write(path, csv) } catch (err) { return `Could not write ${path}: ${String(err)}` }
503 return `Wrote ${kept} turns${kept < rows.length ? ` (the newest; ${rows.length} exist)` : ''} to ${path}\n${note}`
504}
505
506async function openPane($: any): Promise<boolean> {
507 try {
508 await refreshUsage($)
509 await $.ui.open({ id: PANE, title: 'ClaudeGrid', focus: true, closeOnEscape: true })
510 S.paneOpen = true
511 return true
512 } catch (err) { S.lastError = `open pane: ${String(err)}`; return false }
513}
514
515// ---------- system readings (the SYSTEM tab) ----------
516// Read-only: macOS through vm_stat, top, sysctl, pmset and ps; Linux through /proc and /sys. Nothing is stored and nothing is sent anywhere.
517
518async function run1($: any, argv: string[], timeoutMs = 5000): Promise<string> {
519 try { const r = await $.process.run(argv, { timeoutMs }); return str(r?.stdout) } catch { return '' } // a missing command is just "no reading"
520}
521
522async function readText($: any, path: string): Promise<string> {
523 try { const r = await $.fs.read(path); return typeof r === 'string' ? r : str(r?.content) || str(r?.text) } catch { return '' }
524}
525
526async function sampleDarwin($: any, out: Sample) {
527 if (!S.sys.memTotal) S.sys.memTotal = Number((await run1($, ['sysctl', '-n', 'hw.memsize'])).trim()) || 0
528 if (!S.sys.cores) S.sys.cores = Number((await run1($, ['sysctl', '-n', 'hw.ncpu'])).trim()) || 0
529 // `top -l 2` takes about a second (the second sample is the live one), so everything else runs alongside it
530 const [vm, swap, press, top, load] = await Promise.all([
531 run1($, ['vm_stat']), run1($, ['sysctl', '-n', 'vm.swapusage']), run1($, ['sysctl', '-n', 'kern.memorystatus_vm_pressure_level']),
532 run1($, ['top', '-l', '2', '-n', '0', '-s', '1'], 10_000), run1($, ['sysctl', '-n', 'vm.loadavg']),
533 ])
534 out.cores = S.sys.cores || null
535 out.mem = parseVmStat(vm, S.sys.memTotal)
536 if (!out.mem) out.notes.push('Memory: vm_stat gave nothing readable.')
537 out.swap = parseSwapUsage(swap)
538 out.pressure = parsePressure(press)
539 out.cpu = parseTopCpu(top)
540 if (!out.cpu) out.notes.push('CPU: top gave no usage line.')
541 out.load = parseLoad(load)
542 if (Date.now() - S.sys.thermalAt > 10_000) { // thermal state changes slowly
543 S.sys.thermal = parsePmsetTherm(await run1($, ['pmset', '-g', 'therm']))
544 S.sys.thermalAt = Date.now()
545 }
546 out.thermal = S.sys.thermal
547 // a temperature only if a tool that can read one is installed; which one is found once, then remembered
548 if (S.sys.tempTool !== 'none') {
549 let c: number | null = null
550 if (S.sys.tempTool === '' || S.sys.tempTool === 'osx-cpu-temp') { c = parseTempText(await run1($, ['osx-cpu-temp'])); if (c != null) S.sys.tempTool = 'osx-cpu-temp' }
551 if (c == null && (S.sys.tempTool === '' || S.sys.tempTool === 'istats')) { c = parseTempText(await run1($, ['istats', 'cpu', 'temp', '--value-only'])); if (c != null) S.sys.tempTool = 'istats' }
552 if (c != null) out.temp = { c, source: S.sys.tempTool }
553 else if (S.sys.tempTool === '') S.sys.tempTool = 'none'
554 }
555 if (!out.temp) out.notes.push('Temperature: macOS gives no sensor reading without admin rights, so thermal state is shown. osx-cpu-temp (Intel Macs) adds degrees C.')
556}
557
558async function linuxTemp($: any): Promise<{ c: number; source: string } | null> {
559 const zones: { type: string; milli: number }[] = []
560 try {
561 for (const e of await $.fs.list('/sys/class/thermal')) {
562 const n = str(e?.name)
563 if (!n.startsWith('thermal_zone')) continue
564 zones.push({ type: (await readText($, `/sys/class/thermal/${n}/type`)).trim(), milli: Number((await readText($, `/sys/class/thermal/${n}/temp`)).trim()) })
565 }
566 } catch { /* no thermal zones */ }
567 try {
568 for (const e of await $.fs.list('/sys/class/hwmon')) {
569 const n = str(e?.name)
570 if (!n.startsWith('hwmon')) continue
571 const name = (await readText($, `/sys/class/hwmon/${n}/name`)).trim()
572 const milli = Number((await readText($, `/sys/class/hwmon/${n}/temp1_input`)).trim())
573 if (milli) zones.push({ type: name, milli })
574 }
575 } catch { /* no hwmon */ }
576 return pickLinuxTemp(zones)
577}
578
579async function sampleLinux($: any, out: Sample) {
580 let prev = S.sys.prevStat as ReturnType<typeof parseProcStat> | null
581 if (!prev) { prev = parseProcStat(await readText($, '/proc/stat')); await $.clock.sleep(250) } // CPU use needs two reads: take a first one now
582 const cur = parseProcStat(await readText($, '/proc/stat'))
583 S.sys.prevStat = cur
584 const was = prev.find((x) => x.name === 'cpu')
585 const now = cur.find((x) => x.name === 'cpu')
586 if (was && now) out.cpu = cpuBetween(was, now)
587 if (!out.cpu) out.notes.push('CPU: /proc/stat could not be read.')
588 const cores = cur.filter((x) => x.name !== 'cpu')
589 out.cores = cores.length || null
590 out.perCore = cores.length > 1 ? cores.map((c) => { const p = prev.find((x) => x.name === c.name); return (p ? cpuBetween(p, c)?.total : 0) ?? 0 }) : null
591 const mi = parseMeminfo(await readText($, '/proc/meminfo'))
592 out.mem = mi?.mem ?? null
593 out.swap = mi?.swap ?? null
594 if (!out.mem) out.notes.push('Memory: /proc/meminfo could not be read.')
595 out.load = parseLoad(await readText($, '/proc/loadavg'))
596 out.temp = await linuxTemp($)
597 if (!out.temp) out.notes.push('Temperature: no thermal zone or hwmon sensor found (a virtual machine or container often has none).')
598}
599
600async function pollSystem($: any) {
601 if (S.sys.busy) return
602 S.sys.busy = true
603 S.sys.error = ''
604 try {
605 if (!S.sys.platform) {
606 const u = (await run1($, ['uname', '-s'])).trim()
607 S.sys.platform = u === 'Darwin' ? 'darwin' : u === 'Linux' ? 'linux' : 'other'
608 }
609 const out: Sample = { at: Date.now(), platform: S.sys.platform, cores: null, cpu: null, perCore: null, load: null, mem: null, swap: null, pressure: null, temp: null, thermal: null, procs: [], notes: [] }
610 if (S.sys.platform === 'darwin') await sampleDarwin($, out)
611 else if (S.sys.platform === 'linux') await sampleLinux($, out)
612 else out.notes.push('The SYSTEM tab reads macOS and Linux.')
613 const ps = S.sys.platform === 'darwin'
614 ? await run1($, ['ps', '-A', '-o', 'pid=', '-o', 'pcpu=', '-o', 'rss=', '-o', 'comm=', '-r'])
615 : await run1($, ['ps', '-eo', 'pid=,pcpu=,rss=,comm=', '--sort=-pcpu'])
616 out.procs = parsePs(ps)
617 if (out.procs.length === 0) out.notes.push('Processes: ps gave no list.')
618 const keep = (a: number[], v: number | null | undefined) => { if (v != null) { a.push(v); if (a.length > 120) a.splice(0, a.length - 120) } }
619 keep(S.sys.cpuHist, out.cpu?.total)
620 keep(S.sys.memHist, out.mem ? (100 * out.mem.used) / out.mem.total : null)
621 keep(S.sys.tempHist, out.temp?.c)
622 S.sys.sample = out
623 } catch (err) {
624 S.sys.error = String(err).slice(0, 160)
625 } finally {
626 S.sys.busy = false
627 S.sys.lastAt = Date.now()
628 redraw($)
629 }
630}
631
632function sysState() {
633 return { sample: S.sys.sample, cpuHist: S.sys.cpuHist, memHist: S.sys.memHist, tempHist: S.sys.tempHist, loading: S.sys.busy, error: S.sys.error, now: Date.now(), everyMs: SYS_EVERY_MS }
634}
635
636// ---------- recorders ----------
637function addStep(e: any, result: any, ms: number) {
638 const cur = recFor(e?.turnId)
639 const u = result?.usage ?? {}
640 const model = str(u?.model) || str(result?.model) || str(e?.model) || 'unknown'
641 const usage = {
642 in: num(u.input_tokens), out: num(u.output_tokens),
643 cacheRead: num(u.cache_read_input_tokens), cacheWrite: num(u.cache_creation_input_tokens),
644 }
645 cur.steps.push({
646 i: cur.steps.length, index: e?.index ?? null, model, effort: e?.effort ?? null,
647 messageCount: e?.messageCount ?? null, stopReason: result?.stopReason ?? null,
648 toolUses: Array.isArray(result?.toolUses) ? result.toolUses.length : num(result?.toolUses),
649 ms, usage,
650 })
651 S.totals.in += usage.in; S.totals.out += usage.out
652 S.totals.cacheRead += usage.cacheRead; S.totals.cacheWrite += usage.cacheWrite
653 S.totals.steps += 1
654 S.models[model] = (S.models[model] ?? 0) + 1
655 const last = cur.steps[cur.steps.length - 1]
656 S.stepLog.push({ n: S.totals.steps, label: label(cur), stop: str(last.stopReason), out: usage.out, in: usage.in, cr: usage.cacheRead, cw: usage.cacheWrite, ms, tools: last.toolUses })
657 if (S.stepLog.length > 120) S.stepLog.splice(0, S.stepLog.length - 120)
658}
659
660function skillName(e: any): string {
661 return str(e?.skill) || str(e?.name) || str(e?.skillName) || str(e?.input?.skill) || str(e?.command) || str(e?.args) || 'unknown'
662}
663
664function addSkill(cur: any, name: string, via: 'tool' | 'prompt', chars: number) {
665 if (cur.skills.some((s: any) => s.name === name)) return // the Skill tool and skill.prompt can both fire
666 cur.skills.push({ name, via, step: cur.steps.length, expansionChars: chars })
667 const s = (S.skills[name] ??= { n: 0, chars: 0 })
668 s.n += 1
669 s.chars += chars
670 pushLog('skill', 'SKILL', name, 0, true)
671}
672
673function addTool(e: any, result: any, ms: number, ok: boolean): { type: string; ms: number } | null {
674 const cur = mainRec()
675 const name = str(e?.tool) || 'unknown'
676 const resultChars = safeLen(result?.result ?? result)
677 const est = estTokens(resultChars)
678 const server = mcpServer(name)
679 const isAgentPlumbing = name === 'Agent' || name === 'SubagentHandback'
680 const args = summarizeArgs(e)
681 cur.tools.push({
682 i: cur.tools.length, step: Math.max(0, cur.steps.length - 1), name,
683 source: server ? 'mcp' : isAgentPlumbing ? 'agent' : 'builtin', mcpServer: server,
684 toolUseId: e?.tool_use_id ?? null, args,
685 wallMs: ms, // includes any permission-prompt wait
686 ok, resultChars, estResultTokens: est, // ESTIMATE, not an API number
687 })
688 const t = (S.tools[name] ??= { n: 0, ms: 0, fail: 0, estTokens: 0 })
689 t.n += 1; t.ms += ms; t.estTokens += est
690 if (!ok) t.fail += 1
691 S.totals.tools += 1; S.totals.toolMs += ms
692 if (!ok) S.totals.toolFail += 1
693 // Skill/Agent get their own log rows (SKILL, AGENT); don't log the plumbing twice.
694 if (name === 'SubagentHandback') pushLog('agent', 'AGENT', 'returned', ms, ok)
695 else if (name !== 'Skill' && name !== 'Agent') pushLog('tool', name, args || str(e?.description), ms, ok)
696 if (name === 'Skill') addSkill(cur, skillName(e), 'tool', 0)
697 // A handback means the oldest still-running subagent has returned (the event carries no agent id).
698 if (name === 'SubagentHandback') {
699 const run = S.agentRuns.find((r: any) => r.t1 == null)
700 if (run) { run.t1 = Date.now(); return { type: run.type, ms: run.t1 - run.t0 } }
701 }
702 return null
703}
704
705// ---------- text views ----------
706function cardLine(key: string, c: any): string {
707 return `${key.padEnd(14)} ${fmtUsd(c.usd).padStart(8)} · out ${fmtTokens(c.out)} · in ${fmtTokens(c.in)} · cache r ${fmtTokens(c.cacheRead)} w ${fmtTokens(c.cacheWrite)} · ${c.turns} turns · ${c.sessions} sessions · last ${str(c.lastAt).slice(0, 10)}`
708}
709
710function helpText(): string {
711 const c = '/' + S.cmd
712 return [
713 'CLAUDEGRID',
714 `${c} open the console (1 ledger, 2 GRID RUN, 3 SUMMARY, 4 SYSTEM, t theme; Esc closes)`,
715 `${c} text session summary in the transcript`,
716 `${c} tools|skills|agents|turn N`,
717 `${c} summary [card|day|week|month|model|skill|tool|agent|project|session] [ABC-123] [today|7d|30d|all]`,
718 ` the SUMMARY tab: metrics across ALL your sessions (default: last 30 days)`,
719 `${c} summary text [...] the same, printed in the transcript`,
720 `${c} card [KEY] one Jira card across every session, all time (default: this session's)`,
721 `${c} cards every card by cost`,
722 `${c} export [path] every turn as CSV (default ~/.claude/claudegrid/claudegrid.csv)`,
723 `${c} system the SYSTEM tab: CPU, memory, temperature, busiest processes (add text for the transcript)`,
724 `${c} today spend today, across sessions`,
725 `${c} budget N|off daily budget in USD; toasts at 80% and 100%`,
726 `${c} theme [soft|neon]`,
727 `${c} prompts on|off save prompt text in the ledger files`,
728 `${c} billing how this session is billed, and what COST means`,
729 `${c} probe what else this build exposes to a mod (shape only)`,
730 ].join('\n')
731}
732const topTools = (n = 8) => Object.entries(S.tools as Record<string, any>).sort((a, b) => b[1].n - a[1].n).slice(0, n)
733
734function view(arg: string): string {
735 const t = S.totals
736 const hit = Math.round(cacheHit(t.in, t.cacheRead, t.cacheWrite) * 100)
737 const parts = arg.trim().split(/\s+/)
738 const sub = parts[0] || ''
739 if (sub === 'tools') {
740 const rows = topTools(20).map(([k, v]: any) =>
741 `${k.padEnd(26)} ×${String(v.n).padStart(3)} ${fmtDur(v.ms)} ~${fmtTokens(v.estTokens)} tok result${v.fail ? ` ✗${v.fail}` : ''}`)
742 return ['TOOLS (time includes permission waits; tokens estimated)', ...(rows.length ? rows : ['none yet'])].join('\n')
743 }
744 if (sub === 'skills') {
745 const rows = Object.entries(S.skills as Record<string, any>).map(([k, v]: any) => `${k.padEnd(26)} ×${v.n}`)
746 return ['SKILLS', ...(rows.length ? rows : ['none yet'])].join('\n')
747 }
748 if (sub === 'agents') {
749 const rows = S.agentRuns.map((r: any) =>
750 `${r.t1 == null ? '▶ running' : '✓ done '} ${r.type.padEnd(14)} ${fmtDur((r.t1 ?? Date.now()) - r.t0)} ${trunc(r.desc, 50)}`)
751 return ['AGENTS (a subagent counts as done when its handback arrives)', ...(rows.length ? rows : ['none yet'])].join('\n')
752 }
753 if (sub === 'turn') {
754 const n = Number(parts[1])
755 return n ? `Turn ${n}: open ${S.dir}/turn-${pad(n)}.json` : `Usage: /${S.cmd} turn <number>`
756 }
757 const lines = [
758 `CLAUDEGRID session ${S.id} ${S.jira ?? 'no card'} ${S.branch || 'no branch'}`,
759 `elapsed ${fmtDur(Date.now() - S.startedAt)} · turns ${S.turns} · requests ${t.steps} · tool calls ${t.tools} (${t.toolFail} failed) · ${fmtUsd(S.costUsd)}`,
760 `tokens in ${fmtTokens(t.in)} · out ${fmtTokens(t.out)} · cache read ${fmtTokens(t.cacheRead)} · cache write ${fmtTokens(t.cacheWrite)} · hit ${hit}%`,
761 `models ${Object.entries(S.models).map(([m, c]) => `${m}×${c}`).join(' ') || 'none yet'}`,
762 `top tools ${topTools(5).map(([k, v]: any) => `${k}×${v.n}`).join(' ') || 'none yet'}`,
763 `skills ${Object.keys(S.skills).join(', ') || 'none'}`,
764 `agents ${Object.keys(S.agents).join(', ') || 'none'}`,
765 `files ${S.dir || '(no HOME; not writing)'}`,
766 ]
767 lines.push(`theme ${THEMES[T.name].label}`)
768 lines.push(`game ${S.game ? S.game.level + ' ' + S.game.state : 'none'} · tab ${S.tab} · moves ${S.game ? S.game.ticks : 0} · timer ticks ${S.timerTicks} · pane drawn ${S.paneSeen ? Math.round((Date.now() - S.paneSeen) / 1000) + 's ago' : 'never'}`)
769 if (S.lastError) lines.push(`last error: ${S.lastError}`)
770 lines.push(`try: /${S.cmd} help`)
771 return lines.join('\n')
772}
773
774// ---------- the mod ----------
775export function register(on: any, _options: any) {
776 on('session.start', async ($: any, e: any, next: any) => {
777 if (!S.inited) {
778 S.inited = true
779 try { const id = str(await $.session.id()); if (id) S.id = id } catch { /* keep fallback */ }
780 try { S.cwd = str(await $.session.cwd()) } catch { /* ignore */ }
781 const home = str(await $.env.get('HOME')) || str(await $.env.get('USERPROFILE'))
782 if (home) {
783 S.home = home
784 S.root = `${home}/.claude/claudegrid`
785 S.dir = `${S.root}/${S.id}`
786 await sh($, ['mkdir', '-p', S.dir])
787 await restoreOwnRows($)
788 }
789 S.branch = await sh($, ['git', 'branch', '--show-current'])
790 S.jira = jiraKey(S.branch, null)
791 }
792 try {
793 const saved = str(await $.store.get('theme2')) // new key: an older saved choice must not force the bold theme
794 if (saved && saved !== T.name) setTheme(saved)
795 } catch { /* first run: keep the default */ }
796 await refreshUsage($)
797 await detectEnvBilling($)
798 await probeApi($)
799 if (!S.acctLoaded) {
800 // Spend already in this session (a resumed one, or a mod installed mid-session) must not count as new.
801 S.acctLoaded = true
802 const saved = await storeGet($, 'acct:' + S.id)
803 S.acct.usd = typeof saved?.usd === 'number' ? saved.usd : S.costUsd
804 await storeSet($, 'acct:' + S.id, { usd: S.acct.usd })
805 S.cumBySeq[S.seq] = S.costUsd // this turn's cost is measured from here, not from zero
806 }
807 {
808 const b = await storeGet($, 'budget')
809 S.budget = typeof b === 'number' && b > 0 ? b : null
810 S.logPrompts = (await storeGet($, 'logPrompts')) !== false
811 const day = await storeGet($, dayKey())
812 S.today = day?.usd ?? 0
813 if (S.jira) S.cardLife = (await storeGet($, 'card:' + S.jira)) ?? null
814 }
815 // The first command name Claude Code accepts wins, so a clash with another mod can't stop the rest of start-up.
816 for (const name of COMMANDS) {
817 try {
818 await $.command.register({
819 name,
820 description: 'Open the ClaudeGrid console: ledger, agent radar, light cycles (/' + name + ' help)',
821 argumentHint: '[help | text | tools | skills | agents | turn N | card [KEY] | cards | today | budget N | theme | prompts on|off]',
822 immediate: true,
823 })
824 S.cmd = name
825 break
826 } catch (err) { S.lastError = `register /${name}: ${String(err)}` }
827 }
828 // One 50ms loop: moves the light cycles at the level's speed while the game runs; otherwise it only keeps
829 // the HUD fresh (every 100ms while working or while the console is open, once a second at rest).
830 if (!S.timer) {
831 try {
832 S.timer = $.clock.every(TICK_MS, () => {
833 S.tick += 1
834 S.timerTicks += 1
835 const visible = paneVisible()
836 if (visible && S.tick % 1200 === 0) refreshUsage($).catch(() => {})
837 if (visible && S.tab === 'system' && !S.sys.busy && Date.now() - S.sys.lastAt > SYS_EVERY_MS) pollSystem($).catch(() => {})
838 if (visible && S.tab === 'summary' && S.sum.loaded && !S.sum.loading && Date.now() - S.sum.loadedAt > 15_000) loadSummary($).catch(() => {})
839 if (S.game?.state === 'running' && !(visible && S.tab === 'game')) {
840 S.game.state = 'paused' // never run a game nobody can see
841 }
842 if (visible && S.tab === 'game' && S.game?.state === 'running') {
843 S.acc += TICK_MS
844 const need = LEVELS[S.game.level as keyof typeof LEVELS].stepMs
845 if (S.acc >= need) {
846 S.acc = S.acc >= need * 2 ? 0 : S.acc - need
847 stepGame(S.game)
848 redraw($)
849 }
850 } else {
851 S.acc = 0
852 if (isWorking() || visible) { if (S.tick % 2 === 0) redraw($) }
853 else if (S.tick % 20 === 0) redraw($)
854 }
855 })
856 } catch (err) { S.lastError = `timer: ${String(err)}` }
857 }
858 return next(e)
859 })
860
861 on('command.run', async ($: any, e: any, next: any) => {
862 if (e?.command !== S.cmd) return next(e)
863 const arg = str(e?.args).trim()
864 const [first, ...rest] = arg.split(/\s+/)
865 const word = (first || '').toLowerCase()
866 const tail = rest.join(' ').trim()
867
868 if (word === 'help') return { text: helpText() }
869
870 if (word === 'theme') {
871 const name = THEMES[tail.toLowerCase()] ? tail.toLowerCase() : nextThemeName()
872 setTheme(name)
873 await storeSet($, 'theme2', name)
874 redraw($)
875 return { text: `theme: ${THEMES[name].label} (soft | neon)` }
876 }
877
878 if (word === 'prompts') {
879 if (tail !== 'on' && tail !== 'off') return { text: `prompt text in ledger files: ${S.logPrompts ? 'on' : 'off'}. Use: /${S.cmd} prompts on|off` }
880 S.logPrompts = tail === 'on'
881 await storeSet($, 'logPrompts', S.logPrompts)
882 return { text: S.logPrompts ? 'Prompts are saved (trimmed to 200 characters) in the ledger files.' : 'Prompts are no longer saved. Tool arguments are still stored as one redacted line; turn those off by not sharing the files.' }
883 }
884
885 if (word === 'budget') {
886 if (tail === '') return { text: S.budget ? `daily budget ${fmtUsd(S.budget)} · today ${fmtUsd(S.today)}` : `No daily budget. Set one: /${S.cmd} budget 25 (USD per day)` }
887 if (tail === 'off') { S.budget = null; await storeSet($, 'budget', 0); return { text: 'Daily budget removed.' } }
888 const n = Number(tail.replace('$', ''))
889 if (!(n > 0)) return { text: `Usage: /${S.cmd} budget <usd> | off` }
890 S.budget = n
891 await storeSet($, 'budget', n)
892 redraw($)
893 return { text: `Daily budget set to ${fmtUsd(n)}. You will get a toast at 80% and at 100%.` }
894 }
895
896 if (word === 'billing') return { text: `billing: ${billingMode()}\n${billingNote()}` }
897
898 if (word === 'probe') return { text: ['What this Claude Code build exposes to a mod (shape only, no values):', JSON.stringify(S.probe, null, 1)].join('\n') }
899
900 if (word === 'today') {
901 return { text: `today ${fmtUsd(S.today)}${S.budget ? ` of ${fmtUsd(S.budget)} (${Math.round((100 * S.today) / S.budget)}%)` : ''} · this session ${fmtUsd(S.costUsd)}${billingMode() === 'subscription' ? ' (API-equivalent estimates, not what you pay)' : ''}` }
902 }
903
904 if (word === 'summary' || word === 'card' || word === 'cards') {
905 // summary [args] opens the SUMMARY tab with those filters. "summary text [args]" prints it in the transcript.
906 // card KEY = that card's detail (all time). cards = the by-card table.
907 let argText = word === 'summary' ? tail : word === 'cards' ? `card ${tail}` : tail || S.jira || ''
908 const asText = word === 'summary' && /^text(\s|$)/i.test(argText)
909 if (asText) argText = argText.replace(/^text\s*/i, '')
910 if (word === 'card' && !argText) return { text: 'No Jira card for this session yet. Name your branch with a key, like feature/ABC-123-thing, or pass one: /' + S.cmd + ' card ABC-123' }
911 if (!S.root) return { text: 'The ledger folder is unavailable (no home directory found), so there is nothing to summarise.' }
912 const args = parseSummaryArgs(word === 'card' ? argText + ' all' : argText)
913 if (args.error) return { text: args.error }
914 if (asText) {
915 const { rows, note } = await loadAllRows($)
916 return { text: renderSummary(rows, args, note) }
917 }
918 S.sum.days = args.days
919 S.sum.sel = 0
920 S.sum.detailCard = args.card
921 S.sum.view = args.card ? 'detail' : args.dim ? (args.dim as SView) : word === 'cards' ? 'card' : 'overview'
922 S.tab = 'summary'
923 await loadSummary($)
924 if (!(await openPane($))) return { text: renderSummary(allRows(), args, sumNote()) } // no pane available: fall back to text
925 redraw($)
926 return {}
927 }
928
929 if (word === 'export') return { text: await exportCsv($, tail) }
930
931 if (word === 'system' || word === 'sys') {
932 if (/^text(\s|$)/i.test(tail)) {
933 await pollSystem($)
934 return { text: systemText(sysState()) }
935 }
936 S.tab = 'system'
937 pollSystem($).catch(() => {})
938 if (!(await openPane($))) { await pollSystem($); return { text: systemText(sysState()) } } // no pane available: fall back to text
939 redraw($)
940 return {}
941 }
942
943 if (word === '' || word === 'console') {
944 if (await openPane($)) return {}
945 }
946 return { text: view(word === 'text' ? '' : arg) }
947 })
948
949 on('ui.close', async ($: any, e: any, next: any) => {
950 if (e?.id === PANE) {
951 S.paneOpen = false
952 S.paneSeen = 0
953 if (S.game?.state === 'running') S.game.state = 'paused'
954 }
955 return next(e)
956 })
957
958 on('prompt.submit', async ($: any, e: any, next: any) => {
959 seen('prompt.submit', e)
960 S.lastPrompt = S.logPrompts ? cleanPrompt(str(e?.text)) : '(prompt hidden)'
961 const o = e?.origin
962 S.lastOrigin = typeof o === 'string' ? o : str(o?.kind) || (o ? JSON.stringify(o).slice(0, 40) : '')
963 if (!S.jira && !str(e?.text).trim().startsWith('<')) S.jira = jiraKeyFromPrompt(str(e?.text))
964 return next(e)
965 })
966
967 on('turn.start', async ($: any, e: any, next: any) => {
968 seen('turn.start', e)
969 S.turns += 1
970 const branch = await sh($, ['git', 'branch', '--show-current'])
971 if (branch && branch !== S.branch) {
972 S.branch = branch
973 S.jira = jiraKey(branch, S.lastPrompt) ?? S.jira
974 }
975 const key = e?.turnId != null ? String(e.turnId) : 'main-' + S.turns
976 const rec = newRecord('turn', e?.turnId, key)
977 rec.prompt = S.lastPrompt
978 rec.origin = S.lastOrigin || null
979 S.mainKey = key
980 S.active.add(key)
981 S.doneFlash = false
982 touch()
983 pushLog('turn', 'TURN ' + S.turns, S.lastPrompt, 0, true)
984 redraw($)
985 return next(e)
986 })
987
988 on('turn.step', async function* ($: any, e: any, next: any) {
989 const t0 = Date.now()
990 const result = yield* next(e)
991 try {
992 seen('turn.step', e); seen('turn.step.result', result); seen('turn.step.result.usage', result?.usage)
993 addStep(e, result, Date.now() - t0)
994 touch()
995 } catch (err) { S.lastError = `turn.step: ${String(err)}` }
996 redraw($)
997 return result
998 })
999
1000 on('tool.call', async ($: any, e: any, next: any) => {
1001 seen('tool.call', e)
1002 seen('tool.call:' + str(e?.tool), e) // first call of each tool: shows that tool's own fields
1003 const t0 = Date.now()
1004 let result: any
1005 let ok = true
1006 try {
1007 result = await next(e)
1008 ok = !(result && typeof result === 'object' && 'deny' in result)
1009 return result
1010 } catch (err) {
1011 ok = false
1012 throw err
1013 } finally {
1014 touch()
1015 try {
1016 const done = addTool(e, result, Date.now() - t0, ok)
1017 if (done) await toast($, `Agent ${done.type} finished (${fmtMs(done.ms)})`)
1018 } catch (err) { S.lastError = `tool.call: ${String(err)}` }
1019 redraw($)
1020 }
1021 })
1022
1023 on('skill.prompt', async ($: any, e: any, next: any) => {
1024 seen('skill.prompt', e)
1025 const result = await next(e)
1026 try { addSkill(mainRec(), skillName(e), 'prompt', safeLen(result?.text)) } catch (err) { S.lastError = `skill.prompt: ${String(err)}` }
1027 redraw($)
1028 return result
1029 })
1030
1031 on('agent.spawn', async ($: any, e: any, next: any) => {
1032 seen('agent.spawn', e)
1033 const result = await next(e)
1034 try {
1035 const type = str(e?.subagentType) || str(e?.name) || 'unknown'
1036 const model = str(result?.model) || str(e?.model) || 'default'
1037 const cur = mainRec()
1038 cur.agents.push({
1039 type, name: str(e?.name) || null, description: trunc(str(e?.description), 120), model,
1040 parentModel: str(e?.parentModel) || null, background: Boolean(e?.background), fork: Boolean(e?.fork),
1041 toolUseId: e?.tool_use_id ?? null, step: cur.steps.length, spawnedAt: new Date().toISOString(),
1042 })
1043 const a = (S.agents[type] ??= { n: 0, models: {} })
1044 a.n += 1
1045 a.models[model] = (a.models[model] ?? 0) + 1
1046 pushLog('agent', 'AGENT', `${type} ${trunc(str(e?.description), 40)}`, 0, true)
1047 S.agentRuns.push({ type, desc: str(e?.description) || type, t0: Date.now(), t1: null })
1048 if (S.agentRuns.length > 30) S.agentRuns.splice(0, S.agentRuns.length - 30)
1049 } catch (err) { S.lastError = `agent.spawn: ${String(err)}` }
1050 redraw($)
1051 return result
1052 })
1053
1054 on('turn.complete', async ($: any, e: any, next: any) => {
1055 seen('turn.complete', e)
1056 const key = e?.turnId != null ? String(e.turnId) : S.mainKey
1057 let c = recFor(e?.turnId)
1058 if (c.done) {
1059 // The engine can report a turn complete twice. Keep the first record untouched and
1060 // only open a new one if the repeat actually carried work of its own.
1061 if (c.steps.length === c.stepsAtDone && c.tools.length === c.toolsAtDone) { S.dupCompletes += 1; return next(e) }
1062 c = newRecord('bg', null)
1063 }
1064 c.done = true
1065 c.stepsAtDone = c.steps.length
1066 c.toolsAtDone = c.tools.length
1067 c.endedAt = new Date().toISOString()
1068 c.durationMs = num(e?.durationMs) || Date.now() - c.t0
1069 c.end = e?.reason ?? null
1070 c.aborted = Boolean(e?.isAborted)
1071 c.reportedUsage = e?.usage ?? null // the engine's own total for this turn, to cross-check the step sum
1072 c.totals = c.steps.reduce(
1073 (a: any, s: any) => ({
1074 in: a.in + s.usage.in, out: a.out + s.usage.out,
1075 cacheRead: a.cacheRead + s.usage.cacheRead, cacheWrite: a.cacheWrite + s.usage.cacheWrite,
1076 }),
1077 { in: 0, out: 0, cacheRead: 0, cacheWrite: 0 },
1078 )
1079 S.active.delete(key)
1080 S.seq += 1
1081 c.seq = S.seq
1082 const record = { ...c }
1083 delete (record as any).t0
1084 delete (record as any).stepsAtDone
1085 delete (record as any).toolsAtDone
1086 S.lastRecord = record
1087 S.lastFile = c.kind === 'turn' ? `turn-${pad(c.turn)}.json` : `bg-${pad(c.bg)}.json`
1088 S.turnLog.push({ label: label(c), out: c.totals.out, ms: c.durationMs, seq: S.seq })
1089 await save($, S.lastFile, record)
1090 S.lastRow = pushRow(record)
1091 S.lastRowSeq = S.seq
1092 await save($, 'rows.json', S.rows, true)
1093 redraw($)
1094 if (c.kind === 'turn' && paneVisible() && S.tab === 'game') {
1095 // Like an arcade pause: when Claude is done, the game stops and says so, so a reply is never missed.
1096 S.doneFlash = true
1097 if (S.game?.state === 'running') S.game.state = 'paused'
1098 await toast($, 'Claude finished a turn')
1099 }
1100 return next(e)
1101 })
1102
1103 // After each turn (and when a plan limit changes): cost, context, limits.
1104 on('session.measure', async ($: any, e: any, next: any) => {
1105 try {
1106 await refreshUsage($)
1107 S.cumBySeq[S.seq] = S.costUsd
1108 if (S.lastRecord && S.lastRecord.seq === S.seq) {
1109 S.lastRecord.costUsd = costFor(S.seq)
1110 await save($, S.lastFile, S.lastRecord)
1111 }
1112 if (S.lastRow && S.lastRowSeq === S.seq) {
1113 S.lastRow.costUsd = costFor(S.seq)
1114 await save($, 'rows.json', S.rows, true)
1115 }
1116 await save($, 'session.json', sessionSummary())
1117 await account($)
1118 await alerts($)
1119 } catch (err) { S.lastError = `session.measure: ${String(err)}` }
1120 redraw($)
1121 return next(e)
1122 })
1123
1124 on('session.end', async ($: any, e: any, next: any) => {
1125 await save($, 'session.json', sessionSummary())
1126 return next(e)
1127 })
1128
1129 // ---------- list navigation in the SUMMARY tab ----------
1130 // The arrow keys and Tab move the focus ring between controls; the wheel and the page keys arrive as scroll events.
1131 // Both are turned into "move the selected row", so a long list of cards, tools or projects can be walked by keyboard or wheel.
1132 on('ui.focus', async ($: any, e: any, next: any) => {
1133 if (e?.requestId !== PANE || S.tab !== 'summary') return next(e)
1134 const el = str(e?.element)
1135 if (el === 'sum-prev' || el === 'sum-next') {
1136 if (S.sum.ringEl === 'sum-open') {
1137 // the ring was on OPEN and an arrow (or Tab, or a click) reached for a neighbour: step the list, keep the ring on OPEN
1138 moveSel(el === 'sum-next' ? 1 : -1)
1139 S.sum.lastFocusStep = Date.now()
1140 redraw($)
1141 return {}
1142 }
1143 return next({ ...e, element: 'sum-open' }) // the ring was somewhere else: bring it to the list control first
1144 }
1145 S.sum.ringEl = el
1146 return next(e)
1147 })
1148
1149 on('ui.scroll', async ($: any, e: any, next: any) => {
1150 if (e?.requestId !== PANE || S.tab !== 'summary' || !S.sum.loaded) return next(e)
1151 const by = num(e?.by)
1152 if (by !== 0) {
1153 // a page key asks for a whole body of rows: jump one page of the table. A wheel tick or a single row moves one row.
1154 const bodyRows = num(e?.bodyRows)
1155 moveSel(bodyRows > 0 && Math.abs(by) >= bodyRows ? Math.sign(by) * Math.max(1, S.sum.pageRows - 1) : by)
1156 redraw($)
1157 }
1158 return {} // the table scrolls itself; leave the window where it is
1159 })
1160
1161 // ---------- the grid ----------
1162 on('ui.render', { component: 'AbovePrompt' }, async ($: any, e: any, next: any) => {
1163 const { Box, Text, Raster } = $.ui.resolve(e)
1164 const theirs = await next(e)
1165 ensureUsage($)
1166 const snap = snapshot()
1167 let mine: any
1168 if (e.surface !== 'terminal') {
1169 mine = Text({ color: 'cyan', children: [plainLine(snap)] })
1170 } else {
1171 const cols = clamp(num(e?.props?.bodyColumns) || 80, 24, 400)
1172 const canvas = renderBand(snap, cols, num(e?.props?.maxRows) || 6)
1173 mine = Raster({ key: 'ledger-band', columns: canvas.cols, rows: canvas.rows, cells: canvas.pack() })
1174 }
1175 return Box({ flexDirection: 'column', children: [theirs, mine].filter(Boolean) })
1176 })
1177
1178 on('ui.render', { component: 'Pane' }, async ($: any, e: any, next: any) => {
1179 if (e.requestId !== PANE) return next(e)
1180 const { Box, Text, Button, Raster } = $.ui.resolve(e)
1181 if (e.surface !== 'terminal') return Text({ children: [view('')] })
1182 S.paneSeen = Date.now()
1183 S.paneOpen = true
1184 ensureUsage($)
1185
1186 const cols = clamp(num(e?.props?.bodyColumns) || 60, 24, 400)
1187 const dock = e?.props?.placement === 'dock'
1188 const total = dock ? clamp(num(e?.props?.scroll?.bodyRows) || 24, 8, 60) : 16
1189 const isGame = S.tab === 'game'
1190 const isSummary = S.tab === 'summary'
1191 const isSystem = S.tab === 'system'
1192 const rows = Math.max(8, total - 1 - (isGame || isSummary || isSystem ? 1 : 0)) // one row for the tabs, one for the keys
1193
1194 // The arena always matches the window. A resize starts a fresh run (scores are kept).
1195 const L = layout(cols, rows)
1196 if (isGame && (!S.game || S.game.w !== L.gw || S.game.h !== L.gh)) S.game = newGame(L.gw, L.gh, S.game)
1197
1198 if (isSummary && !S.sum.loaded && !S.sum.loading) loadSummary($).catch(() => {})
1199 if (isSystem && !S.sys.sample && !S.sys.busy) pollSystem($).catch(() => {})
1200 const canvas = isSystemhooks/lib.ts 145 lines1// Pure helpers for claudegrid. No Claude Code APIs in here, so they are easy to test.
2
3// ---------- Jira ----------
4const PROMPT_KEY = /\b([A-Z][A-Z0-9]{1,9}-\d{1,6})\b/
5const BRANCH_KEY = /(?:^|[\/_-])([a-z][a-z0-9]{1,9}-\d{1,6})(?=$|[\/_-])/i
6// Words that look like a Jira project key in a branch name but are not one (e.g. release-2024).
7const NOT_PROJECTS = new Set([
8 'feature', 'feat', 'fix', 'bugfix', 'hotfix', 'release', 'chore', 'task',
9 'spike', 'refactor', 'test', 'docs', 'v', 'build', 'ci',
10])
11
12export function jiraKeyFromPrompt(text?: string | null): string | null {
13 if (!text) return null
14 const m = PROMPT_KEY.exec(text)
15 return m ? m[1] : null
16}
17
18export function jiraKeyFromBranch(branch?: string | null): string | null {
19 if (!branch) return null
20 const m = BRANCH_KEY.exec(branch)
21 if (!m) return null
22 const key = m[1].toUpperCase()
23 const project = key.split('-')[0].toLowerCase()
24 return NOT_PROJECTS.has(project) ? null : key
25}
26
27// Branch wins (it is deliberate), the prompt is the fallback.
28export function jiraKey(branch?: string | null, prompt?: string | null): string | null {
29 return jiraKeyFromBranch(branch) ?? jiraKeyFromPrompt(prompt)
30}
31
32// ---------- Redaction / summaries ----------
33export function redact(s: string): string {
34 return s
35 .replace(/(bearer\s+)[\w.\-~+/=]+/gi, '$1[redacted]')
36 .replace(/\b(token|secret|password|passwd|api[_-]?key)(\s*[=:]\s*)\S+/gi, '$1$2[redacted]')
37 .replace(/\bsk-[A-Za-z0-9_-]{10,}/g, '[redacted]')
38}
39
40export function trunc(s: string, n = 80): string {
41 const one = s.replace(/\s+/g, ' ').trim()
42 return one.length > n ? one.slice(0, n - 1) + '…' : one
43}
44
45const ARG_KEYS = ['command', 'file_path', 'path', 'pattern', 'url', 'query', 'description', 'prompt']
46
47// One short, redacted line describing a tool call. Never the full input.
48export function summarizeArgs(e: any): string {
49 for (const src of [e, e?.input]) {
50 if (!src || typeof src !== 'object') continue
51 for (const k of ARG_KEYS) {
52 if (typeof src[k] === 'string' && src[k]) return trunc(redact(src[k]))
53 }
54 }
55 return ''
56}
57
58export function safeLen(v: unknown): number {
59 if (v == null) return 0
60 if (typeof v === 'string') return v.length
61 try { return JSON.stringify(v).length } catch { return 0 }
62}
63
64// Rough: ~3.5 chars per token for mixed code/text. Always label results as estimated.
65export function estTokens(chars: number): number {
66 return Math.ceil(chars / 3.5)
67}
68
69export function mcpServer(tool: string): string | null {
70 const m = /^mcp__([^_].*?)__/.exec(tool || '')
71 return m ? m[1] : null
72}
73
74// ---------- Formatting ----------
75export function fmtTokens(n: number): string {
76 if (n < 1000) return String(n)
77 if (n < 1_000_000) return (n / 1000).toFixed(n < 10_000 ? 2 : 1) + 'k'
78 return (n / 1_000_000).toFixed(2) + 'M'
79}
80
81export function fmtDur(ms: number): string {
82 const s = Math.max(0, Math.floor(ms / 1000))
83 const h = Math.floor(s / 3600)
84 const m = Math.floor((s % 3600) / 60)
85 const sec = s % 60
86 const mm = String(m).padStart(2, '0')
87 const ss = String(sec).padStart(2, '0')
88 return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`
89}
90
91export function meter(frac: number, width: number): string {
92 const f = Math.min(1, Math.max(0, frac || 0))
93 const filled = Math.round(f * width)
94 return '█'.repeat(filled) + '░'.repeat(width - filled)
95}
96
97export function cacheHit(inTok: number, cacheRead: number, cacheWrite: number): number {
98 const total = inTok + cacheRead + cacheWrite
99 return total > 0 ? cacheRead / total : 0
100}
101
102// The "laser": a bright head with a fading trail bouncing along a grid line.
103export function laser(width: number, now: number, active: boolean): string {
104 const w = Math.max(8, width)
105 const cells: string[] = Array(w).fill('─')
106 const period = (w - 1) * 2
107 const speed = active ? 45 : 140 // ms per cell
108 const p = Math.floor(now / speed) % period
109 const head = p < w ? p : period - p
110 const trailDir = p < w ? -1 : 1
111 cells[head] = '█'
112 const trail = ['▓', '▒', '░']
113 for (let k = 1; k <= 3; k++) {
114 const i = head + trailDir * k
115 if (i >= 0 && i < w) cells[i] = trail[k - 1]
116 }
117 return cells.join('')
118}
119
120// ---------- v0.2 additions ----------
121export function fmtUsd(n: number): string {
122 if (!isFinite(n)) return '$0.00'
123 return n < 10 ? '$' + n.toFixed(3) : '$' + n.toFixed(2)
124}
125
126export function clamp(n: number, lo: number, hi: number): number {
127 return Math.min(hi, Math.max(lo, n))
128}
129
130export function shortModel(m: string): string {
131 return (m || '—').replace(/^claude-/, '')
132}
133
134export function clock(t: number): string {
135 const d = new Date(t)
136 const p = (n: number) => String(n).padStart(2, '0')
137 return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
138}
139
140export function fmtMs(ms: number): string {
141 if (ms < 1000) return Math.round(ms) + 'ms'
142 if (ms < 60_000) return (ms / 1000).toFixed(1) + 's'
143 return fmtDur(ms)
144}
145hooks/hud.ts 453 lines1// Pure renderers: Snapshot in, Canvas out. No Claude Code APIs, so they run (and are
2// previewed and tested) outside a session.
3
4import { Canvas, C, T, DEFAULT, lerp, lerp3, LIGHT_H } from './canvas.ts'
5import { fmtTokens, fmtDur, fmtMs, cacheHit, fmtUsd, trunc, shortModel, clock, clamp } from './lib.ts'
6import { LEVELS } from './game.ts'
7import type { Game, Level } from './game.ts'
8
9export interface LogEntry {
10 t: number
11 kind: 'tool' | 'skill' | 'agent' | 'turn'
12 name: string
13 detail: string
14 ms: number
15 ok: boolean
16}
17
18export interface StepEntry {
19 n: number
20 label: string // which turn record it belongs to, e.g. T2 or B1
21 stop: string
22 out: number
23 in: number
24 cr: number
25 cw: number
26 ms: number
27 tools: number
28}
29
30export interface Snapshot {
31 id: string
32 jira: string | null
33 branch: string
34 working: boolean
35 now: number
36 startedAt: number
37 turns: number
38 steps: number
39 toolCalls: number
40 toolFail: number
41 tokens: { in: number; out: number; cacheRead: number; cacheWrite: number }
42 costUsd: number
43 turnCostUsd: number
44 models: Record<string, number>
45 toolStats: Record<string, { n: number; ms: number; fail: number }>
46 skills: Record<string, { n: number }>
47 agents: Record<string, { n: number }>
48 ctxPct: number
49 fiveHour: number | null
50 sevenDay: number | null
51 log: LogEntry[]
52 stepLog: StepEntry[]
53 turnLog: { label: string; out: number; ms: number; usd: number }[]
54 view?: 'ledger' | 'game'
55 game?: Game | null
56 // v1.0
57 fiveHourElapsed?: number | null // fraction of the 5-hour window already gone
58 sevenDayElapsed?: number | null
59 todayUsd?: number
60 budgetUsd?: number | null
61 cardLife?: { usd: number; out: number; turns: number; sessions: number } | null
62 agentRuns?: { type: string; desc: string; t0: number; t1: number | null }[]
63 claudeDone?: boolean
64 // 'subscription': the cost is an API-equivalent estimate (shown as COST~). 'api' / 'cloud': real charges.
65 costMode?: 'subscription' | 'api' | 'cloud' | 'unknown'
66}
67
68// Tools that are plumbing for skills/agents, which have their own rows.
69const PLUMBING = new Set(['Skill', 'Agent', 'SubagentHandback'])
70
71// ---------- segments: a line made of coloured pieces, optionally right-aligned ----------
72type Seg = { t: string; c: number } | { bar: number; w: number; c: number; mark?: number }
73
74const segLen = (s: Seg) => ('bar' in s ? s.w : [...s.t].length)
75
76function drawSegs(cv: Canvas, x: number, y: number, segs: Seg[], maxX: number): number {
77 for (const s of segs) {
78 if (x > maxX) break
79 if ('bar' in s) x = cv.bar(x, y, s.w, s.bar, s.c, C.deep, undefined, s.mark)
80 else x = cv.text(x, y, s.t, s.c, DEFAULT, maxX)
81 }
82 return x
83}
84
85const lab = (t: string): Seg => ({ t, c: C.dim })
86const val = (t: string, c: number = C.text): Seg => ({ t, c })
87const gap = (n = 2): Seg => ({ t: ' '.repeat(n), c: C.text })
88
89// The laser: a visible grid line. Sweeps only while a turn is running; at rest it sits still.
90function laser(cv: Canvas, x: number, y: number, w: number, now: number, active: boolean) {
91 if (w < 6) return
92 cv.hline(x, y, w, '─', C.deep)
93 if (!active) {
94 // resting beam: fixed at the left end, no motion
95 cv.set(x, y, '█'.codePointAt(0)!, C.hot)
96 cv.set(x + 1, y, '▓'.codePointAt(0)!, C.cyan)
97 cv.set(x + 2, y, '▒'.codePointAt(0)!, C.dim)
98 cv.set(x + 3, y, '░'.codePointAt(0)!, C.deep)
99 return
100 }
101 const period = (w - 1) * 2
102 const p = Math.floor(now / 28) % period
103 const head = p < w ? p : period - p
104 const dir = p < w ? -1 : 1
105 const TAIL = 24
106 for (let k = TAIL; k >= 1; k--) {
107 const i = head + dir * k
108 if (i < 0 || i >= w) continue
109 const glyph = k === 1 ? '▓' : k <= 3 ? '▒' : '░'
110 cv.set(x + i, y, glyph.codePointAt(0)!, lerp3(C.hot, C.cyan, C.magenta, k / TAIL))
111 }
112 cv.set(x + head, y, '█'.codePointAt(0)!, C.white)
113}
114
115// While a turn runs, a segment races around the top edge like a light-cycle trail.
116function cycleTrail(cv: Canvas, now: number) {
117 const span = cv.cols - 2
118 if (span < 12) return
119 const head = Math.floor(now / 35) % span
120 for (let k = 0; k < 16; k++) {
121 const i = head - k
122 if (i < 0) continue
123 const x = 1 + i
124 if (cv.charAt(x, 0) !== LIGHT_H) continue
125 cv.set(x, 0, '━'.codePointAt(0)!, lerp3(C.white, C.cyan, C.magenta, k / 14))
126 }
127}
128
129const pct = (n: number | null) => (n == null ? '--' : Math.round(n) + '%')
130// Neon traffic light for anything with a limit: primary neon, then yellow, then the alert colour.
131const heat = (frac: number): number => (frac >= 0.8 ? C.alert : frac >= 0.6 ? C.yellow : C.cyan)
132const hot = (n: number | null) => heat((n ?? 0) / 100)
133const visibleTools = (s: Snapshot) =>
134 Object.entries(s.toolStats).filter(([k]) => !PLUMBING.has(k)).sort((a, b) => b[1].n - a[1].n)
135
136// ---------- the band above the prompt ----------
137export function renderBand(s: Snapshot, cols: number, rows: number): Canvas {
138 const framed = rows >= 6 && cols >= 50
139 const H = framed ? 6 : 3
140 const cv = new Canvas(cols, H)
141 const x0 = framed ? 2 : 0
142 const x1 = framed ? cols - 3 : cols - 1
143 const w = x1 - x0 + 1
144 const y0 = framed ? 1 : 0
145 const state = s.working ? 'PROGRAM ACTIVE' : 'ON THE GRID'
146
147 if (framed) {
148 cv.frame(0, 0, cols, H, {
149 color: C.cyan, label: 'CLAUDEGRID', labelColor: C.hot,
150 tab: state, tabColor: s.working ? C.white : C.cyan,
151 })
152 if (s.working) cycleTrail(cv, s.now)
153 }
154
155 // line 1: identity + clock (+ plan limits on the right when there's room)
156 const id: Seg[] = [
157 ...(framed ? [] : [val('CLAUDEGRID ', C.hot), val(state + ' ', s.working ? C.white : C.cyan)]),
158 lab('CARD '), val(s.jira ?? 'NO-CARD', s.jira ? C.hot : C.dim), gap(),
159 lab('SESSION '), val(s.id.slice(0, 6)), gap(),
160 lab('TURN '), val(String(s.turns)), gap(),
161 lab('T+'), val(fmtDur(s.now - s.startedAt)),
162 ]
163 const idEnd = drawSegs(cv, x0, y0, id, x1)
164 const rate: Seg[] = [
165 lab('5H '), { bar: (s.fiveHour ?? 0) / 100, w: 8, c: hot(s.fiveHour), mark: s.fiveHourElapsed ?? undefined }, val(' ' + pct(s.fiveHour), hot(s.fiveHour)),
166 gap(),
167 lab('7D '), { bar: (s.sevenDay ?? 0) / 100, w: 8, c: hot(s.sevenDay), mark: s.sevenDayElapsed ?? undefined }, val(' ' + pct(s.sevenDay), hot(s.sevenDay)),
168 ]
169 const rateW = rate.reduce((a, g) => a + segLen(g), 0)
170 if (framed && x1 - rateW + 1 > idEnd + 2) drawSegs(cv, x1 - rateW + 1, y0, rate, x1)
171
172 // line 2: the laser
173 laser(cv, x0, y0 + 1, w, s.now, s.working)
174
175 // line 3: tokens + cost
176 const t = s.tokens
177 const hit = Math.round(cacheHit(t.in, t.cacheRead, t.cacheWrite) * 100)
178 drawSegs(cv, x0, y0 + 2, [
179 lab('OUT '), val(fmtTokens(t.out), C.hot), gap(),
180 lab('IN '), val(fmtTokens(t.in)), gap(),
181 lab('CACHE R '), val(fmtTokens(t.cacheRead)), lab(' W '), val(fmtTokens(t.cacheWrite)), lab(' HIT '), val(hit + '%'), gap(),
182 lab(s.costMode === 'subscription' ? 'COST~ ' : 'COST '), val(fmtUsd(s.costUsd), C.hot), ...(framed && s.turnCostUsd > 0 ? [lab(' LAST '), val(fmtUsd(s.turnCostUsd))] : []),
183 ...(framed && s.todayUsd ? [gap(), lab('TODAY '), val(fmtUsd(s.todayUsd), s.budgetUsd ? heat(s.todayUsd / s.budgetUsd) : C.text), ...(s.budgetUsd ? [lab('/'), val(fmtUsd(s.budgetUsd))] : [])] : []),
184 ], x1)
185
186 // line 4: context + model + programs
187 const ctx = s.ctxPct / 100
188 const line4: Seg[] = [
189 lab('CTX '), { bar: ctx, w: 10, c: heat(ctx) }, val(' ' + Math.round(s.ctxPct) + '%'), gap(),
190 lab('MODEL '), val(shortModel(Object.keys(s.models).slice(-1)[0] ?? '—')), gap(),
191 lab('TOOLS '), val(String(s.toolCalls)), ...(s.toolFail ? [val(' ✗' + s.toolFail, C.alert)] : []), gap(),
192 lab('SKILLS '), val(String(Object.keys(s.skills).length)), gap(),
193 lab('AGENTS '), val(String(Object.values(s.agents).reduce((a, g) => a + g.n, 0))),
194 ...((s.agentRuns ?? []).some((r) => r.t1 == null) ? [val(' ▶' + (s.agentRuns ?? []).filter((r) => r.t1 == null).length, C.white)] : []),
195 ]
196 if (framed) drawSegs(cv, x0, y0 + 3, line4, x1) // compact mode has no room for this row
197
198 if (s.turns === 0 && s.steps === 0) {
199 cv.fill(x0, y0 + 2, w, framed ? 2 : 1, ' ', C.text)
200 cv.text(x0, y0 + 2, 'AWAITING USER INPUT ▸ DISC READY', C.dim, DEFAULT, x1)
201 }
202 return cv
203}
204
205// ---------- the console (opened by /grid) ----------
206
207// One place for the console's geometry, so the game arena and the drawing never disagree.
208export function layout(cols: number, rows: number) {
209 const H = Math.max(8, rows)
210 const body = H - 1 // last row is the status bar
211 const wide = cols >= 84 && body >= 12
212 const leftW = wide ? clamp(Math.floor(cols * 0.4), 32, 54) : 0
213 const rw = cols - leftW
214 return { H, body, wide, leftW, rw, gw: Math.floor((rw - 4) / 2), gh: body - 3 }
215}
216
217const gameLabel = (g: Game): { text: string; color: number } => {
218 switch (g.state) {
219 case 'ready': return { text: 'W/A/S/D TO START', color: C.hot }
220 case 'running': return { text: 'RUNNING', color: C.white }
221 case 'paused': return { text: 'PAUSED (p)', color: C.dim }
222 case 'won': return { text: 'VICTORY (g)', color: C.hot }
223 case 'lost': return { text: 'DE-REZZED (g)', color: C.alert }
224 default: return { text: 'MUTUAL DE-REZ (g)', color: C.alert }
225 }
226}
227
228const levelColor = (l: Level): number => ({ easy: C.lime, normal: C.cyan, hard: C.orange, insane: C.wreck })[l]
229
230// Each game cell is two characters wide so cells look square.
231// Neon: a grid floor, trails that burn white-hot at the head and settle to the cycle's colour,
232// and a bloom of colour on the floor next to every trail.
233function drawArena(cv: Canvas, ox: number, oy: number, g: Game) {
234 const base = T.bg === DEFAULT ? 0x05090d : T.bg
235 const at = (gx: number, gy: number) => (gx < 0 || gy < 0 || gx >= g.w || gy >= g.h ? 0 : g.grid[gy * g.w + gx])
236 const put = (gx: number, gy: number, a: string, b: string, fg: number, bg: number) => {
237 const x = ox + gx * 2
238 const y = oy + gy
239 cv.set(x, y, a.codePointAt(0)!, fg, bg)
240 cv.set(x + 1, y, b.codePointAt(0)!, fg, bg)
241 }
242 const bloom = (gx: number, gy: number): number => {
243 if (T.glow <= 0) return DEFAULT // SOFT: the floor stays on your terminal background
244 let bg = base
245 for (const [dx, dy, w] of [[1, 0, 0.22], [-1, 0, 0.22], [0, 1, 0.22], [0, -1, 0.22], [1, 1, 0.09], [-1, 1, 0.09], [1, -1, 0.09], [-1, -1, 0.09]] as const) {
246 const v = at(gx + dx, gy + dy)
247 if (v) bg = lerp(bg, v === 1 ? C.you : C.clu, w * (T.glow > 0 ? 1 : 0))
248 }
249 return bg
250 }
251 for (let gy = 0; gy < g.h; gy++) {
252 for (let gx = 0; gx < g.w; gx++) {
253 const i = gy * g.w + gx
254 const v = g.grid[i]
255 if (v) {
256 const body = v === 1 ? C.you : C.clu
257 const hotHead = v === 1 ? C.youHead : C.cluHead
258 const age = g.ticks - g.stamp[i]
259 const color = age < 12 ? lerp(hotHead, body, age / 12) : body
260 put(gx, gy, ' ', ' ', color, color)
261 } else {
262 const onH = gy % 4 === 0
263 const onV = gx % 4 === 0
264 put(gx, gy, onH && onV ? '┼' : onH ? '─' : onV ? '│' : ' ', onH ? '─' : ' ', C.grid, bloom(gx, gy))
265 }
266 }
267 }
268 const head = (c: { x: number; y: number; alive: boolean }, color: number) => {
269 if (c.alive) put(c.x, c.y, ' ', ' ', color, color)
270 else {
271 put(c.x, c.y, '▒', '▒', C.white, C.wreck)
272 for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
273 if (at(c.x + dx, c.y + dy) === 0 && c.x + dx >= 0 && c.y + dy >= 0 && c.x + dx < g.w && c.y + dy < g.h) put(c.x + dx, c.y + dy, '░', '░', C.wreck, T.glow > 0 ? lerp(base, C.wreck, 0.3) : DEFAULT)
274 }
275 }
276 }
277 head(g.clu, C.cluHead)
278 head(g.you, C.youHead)
279}
280
281function drawGameView(cv: Canvas, s: Snapshot, rx: number, rw: number, body: number) {
282 const g = s.game
283 const ix = rx + 2
284 const imax = rx + rw - 3
285 if (!g || g.w < 8 || g.h < 6) {
286 cv.text(ix, 2, 'ARENA TOO SMALL - widen the terminal', C.dim, DEFAULT, imax)
287 return
288 }
289 const lab2 = gameLabel(g)
290 const hdr: Seg[] = [lab('YOU '), val(String(g.wins), C.you), lab(' CLU '), val(String(g.losses), C.clu), gap(), val(lab2.text, lab2.color)]
291 const hdrEnd = drawSegs(cv, ix, 1, hdr, imax)
292 const status = s.claudeDone ? 'CLAUDE DONE' : s.working ? `CLAUDE s${s.steps} t${s.toolCalls}` : 'CLAUDE IDLE'
293 const sx = imax - status.length + 1
294 if (sx > hdrEnd + 2) cv.text(sx, 1, status, s.claudeDone ? C.hot : s.working ? C.white : C.dim, DEFAULT, imax)
295 const ox = ix + Math.floor((rw - 4 - g.w * 2) / 2)
296 drawArena(cv, ox, 2, g)
297}
298
299function drawLedgerView(cv: Canvas, s: Snapshot, rx: number, rw: number, body: number) {
300 const ix = rx + 2
301 const imax = rx + rw - 3
302 const t = s.tokens
303 const hit = Math.round(cacheHit(t.in, t.cacheRead, t.cacheWrite) * 100)
304 const models = Object.entries(s.models).map(([m, n]) => `${shortModel(m)} ×${n}`).join(' ') || '—'
305 const vt = visibleTools(s)
306 const tools = vt.slice(0, 4).map(([k, v]) => `${k} ${v.n}`).join(' ') + (vt.length > 4 ? ` +${vt.length - 4}` : '') || '—'
307 const skills = Object.entries(s.skills).map(([k, v]) => (v.n > 1 ? `${k} ×${v.n}` : k)).join(', ') || '—'
308 const agents = Object.entries(s.agents).map(([k, v]) => `${k} ×${v.n}`).join(' ') || '—'
309
310 const lines: Seg[][] = [
311 [val('/grid # ', C.cyan), val('ledger --session', C.white)],
312 [lab('SESSION '), val(s.id)],
313 [lab('BRANCH '), val(s.branch || '—')],
314 [lab('CARD '), val(s.jira ?? 'NO-CARD', s.jira ? C.hot : C.dim), ...(s.cardLife ? [lab(' lifetime '), val(fmtUsd(s.cardLife.usd), C.hot), lab(` · ${s.cardLife.turns} turns · ${s.cardLife.sessions} sess`)] : [])],
315 [lab('TOKENS '), lab('out '), val(fmtTokens(t.out), C.hot), lab(' in '), val(fmtTokens(t.in))],
316 [lab('CACHE '), lab('read '), val(fmtTokens(t.cacheRead)), lab(' write '), val(fmtTokens(t.cacheWrite)), lab(' hit '), val(hit + '%')],
317 [lab(s.costMode === 'subscription' ? 'COST~ ' : 'COST '), val(fmtUsd(s.costUsd), C.hot), ...(s.costMode === 'subscription' ? [lab(' est.')] : []), lab(' 5h '), val(pct(s.fiveHour), hot(s.fiveHour)), lab(' 7d '), val(pct(s.sevenDay), hot(s.sevenDay))],
318 ...(s.todayUsd ? [[lab('TODAY '), val(fmtUsd(s.todayUsd), s.budgetUsd ? heat(s.todayUsd / s.budgetUsd) : C.hot), ...(s.budgetUsd ? [lab(' of '), val(fmtUsd(s.budgetUsd)), lab(` budget (${Math.round((100 * s.todayUsd) / s.budgetUsd)}%)`)] : [])]] : []),
319 [lab('CONTEXT '), { bar: s.ctxPct / 100, w: 12, c: heat(s.ctxPct / 100) }, val(' ' + Math.round(s.ctxPct) + '%')],
320 [lab('MODELS '), val(models)],
321 [lab('TOOLS '), val(tools)],
322 [lab('SKILLS '), val(skills)],
323 [lab('AGENTS '), val(agents)],
324 ]
325 const innerRows = body - 2
326 const hintY = body - 2 // last inner row: a pointer to the game
327 const stepRoom = innerRows - 1 - lines.length - 2 // minus hint row, blank + header
328 const showSteps = stepRoom >= 3
329 const shown = showSteps ? lines : lines.slice(0, Math.max(1, innerRows - 1))
330 shown.forEach((segs, i) => drawSegs(cv, ix, 1 + i, segs, imax))
331
332 if (showSteps) {
333 // one shared row format so the header and the rows always line up (48 columns)
334 const row = (n: string, tn: string, stop: string, out: string, inn: string, cr: string, cw: string, ms: string) =>
335 `${n.padStart(3)} ${tn.padEnd(3)} ${stop.slice(0, 9).padEnd(9)} ${out.padStart(5)} ${inn.padStart(4)} ${cr.padStart(6)} ${cw.padStart(6)} ${ms.padStart(6)}`
336 const hy = 1 + lines.length + 1
337 cv.text(ix, hy, row('STP', 'TRN', 'STOP', 'OUT', 'IN', 'C-READ', 'C-WRIT', 'TIME'), C.dim, DEFAULT, imax)
338 s.stepLog.slice(-stepRoom).forEach((st, i) => {
339 const y = hy + 1 + i
340 const stopColor = st.stop === 'tool_use' ? C.cyan : st.stop === 'end_turn' ? C.lime : C.text
341 const text = row(String(st.n), st.label, st.stop || '—', fmtTokens(st.out), fmtTokens(st.in), fmtTokens(st.cr), fmtTokens(st.cw), fmtMs(st.ms))
342 cv.text(ix, y, text, C.text, DEFAULT, imax)
343 cv.text(ix, y, text.slice(0, 3), C.dim, DEFAULT, imax) // step number
344 cv.text(ix + 8, y, text.slice(8, 17), stopColor, DEFAULT, imax) // stop reason
345 })
346 }
347 if (hintY > shown.length) cv.text(ix, hintY, '2 GRID RUN · 3 SUMMARY · 4 SYSTEM', C.dim, DEFAULT, imax)
348}
349
350export function renderConsole(s: Snapshot, cols: number, rows: number): Canvas {
351 const L = layout(cols, rows)
352 const { H, body, wide, leftW, rw } = L
353 const cv = new Canvas(cols, H)
354
355 // ---- left column: data log, per-turn bars, tool time ----
356 if (wide) {
357 const big = body >= 30
358 const runs = s.agentRuns ?? []
359 const radarH = big && runs.length > 0 ? clamp(runs.length + 2, 4, Math.floor(body * 0.2)) : 0
360 const logH = Math.floor(body * (big ? (radarH ? 0.3 : 0.4) : 0.6))
361 const turnH = big ? Math.floor(body * (radarH ? 0.2 : 0.27)) : body - logH
362 const toolH = body - logH - radarH - turnH
363
364 cv.frame(0, 0, leftW, logH, { color: C.cyan, label: 'DATA LOG CONSOLE', labelColor: C.hot })
365 const inner = leftW - 4
366 const tail = s.log.slice(-(logH - 2))
367 tail.forEach((e, i) => {
368 const y = 1 + i
369 const ms = e.ms > 0 ? fmtMs(e.ms) : ''
370 const nameColor = !e.ok ? C.alert : e.kind === 'agent' ? C.magenta : e.kind === 'skill' ? C.lime : e.kind === 'turn' ? C.blue : C.hot
371 const maxX = 2 + inner - 1
372 let x = cv.text(2, y, clock(e.t), C.dim, DEFAULT, maxX)
373 x = cv.text(x + 1, y, e.name.slice(0, 6).padEnd(6), nameColor, DEFAULT, maxX)
374 const room = maxX - (x + 1) - (ms ? ms.length + 1 : 0) + 1
375 if (room > 2) cv.text(x + 1, y, trunc(e.detail, room), C.text, DEFAULT, x + room)
376 if (ms) cv.text(maxX - ms.length + 1, y, ms, C.dim)
377 })
378 if (tail.length === 0) cv.text(2, 1, 'no activity yet', C.dim)
379
380 if (radarH > 0) {
381 // Agent radar: one live line per subagent, with a running clock and a tick when it returns.
382 cv.frame(0, logH, leftW, radarH, { color: C.cyan, label: 'AGENT RADAR', labelColor: C.hot })
383 const maxX = leftW - 3
384 runs.slice(-(radarH - 2)).forEach((r, i) => {
385 const y = logH + 1 + i
386 const running = r.t1 == null
387 const dur = fmtDur((r.t1 ?? s.now) - r.t0)
388 let x = cv.text(2, y, running ? '▶' : '✓', running ? C.white : C.lime)
389 x = cv.text(x + 1, y, r.type.slice(0, 8).padEnd(8), running ? C.magenta : C.hot, DEFAULT, maxX)
390 const room = maxX - (x + 1) - dur.length
391 if (room > 2) cv.text(x + 1, y, trunc(r.desc, room), C.text, DEFAULT, x + room)
392 cv.text(maxX - dur.length + 1, y, dur, running ? C.white : C.dim)
393 })
394 }
395
396 if (turnH >= 4) {
397 const ty = logH + radarH
398 cv.frame(0, ty, leftW, turnH, { color: C.cyan, label: 'PRESENTATION DATA', labelColor: C.hot })
399 const tl = s.turnLog.slice(-(turnH - 2))
400 const maxOut = Math.max(1, ...tl.map((t) => t.out))
401 tl.forEach((t, i) => {
402 const y = ty + 1 + i
403 let x = cv.text(2, y, t.label.padEnd(3), C.dim)
404 x = cv.bar(x, y, 8, t.out / maxOut, C.cyan, C.deep, C.magenta)
405 cv.text(x + 1, y, `${fmtTokens(t.out)} ${fmtUsd(t.usd)} ${fmtDur(t.ms)}`, C.text, DEFAULT, leftW - 3)
406 })
407 if (tl.length === 0) cv.text(2, ty + 1, 'no turns yet', C.dim)
408 }
409
410 if (toolH >= 5) {
411 const y0 = logH + radarH + turnH
412 cv.frame(0, y0, leftW, toolH, { color: C.cyan, label: 'TOOL TIME', labelColor: C.hot })
413 const rowsN = toolH - 2
414 const byTime = Object.entries(s.toolStats).filter(([k]) => !PLUMBING.has(k)).sort((a, b) => b[1].ms - a[1].ms).slice(0, rowsN)
415 const maxMs = Math.max(1, ...byTime.map(([, v]) => v.ms))
416 byTime.forEach(([k, v], i) => {
417 const y = y0 + 1 + i
418 let x = cv.text(2, y, k.slice(0, 8).padEnd(8), v.fail ? C.alert : C.hot)
419 x = cv.bar(x, y, 10, v.ms / maxMs, v.fail ? C.alert : C.cyan, C.deep, v.fail ? C.alert : C.blue)
420 cv.text(x + 1, y, `${fmtMs(v.ms)} ×${v.n}`, C.text, DEFAULT, leftW - 3)
421 })
422 if (byTime.length === 0) cv.text(2, y0 + 1, 'no tool calls yet', C.dim)
423 }
424 }
425
426 // ---- right: the window (ledger or game) ----
427 const game = s.view === 'game'
428 const gl = s.game ? gameLabel(s.game) : null
429 cv.frame(leftW, 0, rw, body, {
430 color: C.hot, label: game ? 'GRID RUN' : 'TERMINAL//LEDGER', labelColor: C.white,
431 tab: game && s.game ? `${LEVELS[s.game.level].label} ${s.game.state.toUpperCase()}` : s.working ? 'ACTIVE' : 'IDLE',
432 tabColor: game && s.game ? levelColor(s.game.level) : s.working ? C.white : C.cyan, tabInset: 6, // clear of the pane's own close mark
433 })
434 if (game) drawGameView(cv, s, leftW, rw, body)
435 else drawLedgerView(cv, s, leftW, rw, body)
436
437 // ---- status bar: lit strip like the one under the film's terminal ----
438 const vt = visibleTools(s)
439 const bar = ` ${game ? '2 [grid run]' : '1 [ledger]'} ${s.jira ?? 'NO-CARD'} turns ${s.turns} steps ${s.steps} tools ${s.toolCalls} ${fmtUsd(s.costUsd)}`
440 for (let x = 0; x < cols; x++) cv.set(x, H - 1, 32, C.ink, lerp3(C.cyan, C.blue, C.magenta, x / Math.max(1, cols - 1)))
441 cv.text(0, H - 1, bar, C.ink)
442 const top = vt[0] ? `${vt[0][0]}×${vt[0][1].n}` : '—'
443 const right = ` ${top} T+${fmtDur(s.now - s.startedAt)} `
444 cv.text(cols - right.length, H - 1, right, C.ink)
445 return cv
446}
447
448// A plain-text line for surfaces that can't draw a Raster (the Desktop app).
449export function plainLine(s: Snapshot): string {
450 const t = s.tokens
451 return `CLAUDEGRID ${s.jira ?? 'NO-CARD'} · out ${fmtTokens(t.out)} in ${fmtTokens(t.in)} cache ${fmtTokens(t.cacheRead)} · ${fmtUsd(s.costUsd)} · tools ${s.toolCalls}`
452}
453hooks/canvas.ts 227 lines1// A tiny character canvas for the terminal Raster element, plus the colour themes.
2// One cell = one character + foreground + background. Only single-width glyphs here
3// (box drawing, block elements), so cells never drift out of alignment.
4
5export const DEFAULT = 0x01000000 // "use the terminal's own color" (and, in set(), "leave the background as it is")
6
7// ---------- themes ----------
8// `cyan` is the theme's primary colour; `magenta` is its secondary (a light blue in SOFT, pink in NEON).
9export interface Palette {
10 white: number; hot: number; cyan: number; text: number; dim: number; deep: number; trail: number
11 magenta: number; lime: number; yellow: number; blue: number; orange: number; alert: number; ink: number
12 you: number; youHead: number; clu: number; cluHead: number; grid: number; wreck: number
13}
14export interface Theme { label: string; bg: number; glow: number; p: Palette }
15
16export const THEMES: Record<string, Theme> = {
17 // The default: the earlier cool-blue look with just a touch more electric. Same palette family,
18 // slightly brighter cyan, cyan-to-blue gradients on bars and the laser, no solid background, no glow.
19 soft: {
20 label: 'SOFT', bg: DEFAULT, glow: 0,
21 p: {
22 white: 0xf2ffff, hot: 0xb4f8ff, cyan: 0x2ee6ff, text: 0x9fe8f5, dim: 0x4d9fb0, deep: 0x1c5f70, trail: 0x0d3a46,
23 magenta: 0x5aa8ff, lime: 0xb4f8ff, yellow: 0xb4f8ff, blue: 0x3d8bff, orange: 0xff7a18, alert: 0xff7a18, ink: 0x03141c,
24 you: 0x2ee6ff, youHead: 0xf2ffff, clu: 0xd9600f, cluHead: 0xffd2a0, grid: 0x14404b, wreck: 0xff3b1f,
25 },
26 },
27 // The bold version: near-black panels, a glow inside every frame, pink and lime accents. Opt in with `t`.
28 neon: {
29 label: 'NEON', bg: 0x030812, glow: 0.11,
30 p: {
31 white: 0xeaffff, hot: 0x8cfbff, cyan: 0x00f0ff, text: 0xb4f4ff, dim: 0x3a9fb5, deep: 0x0a5568, trail: 0x06303c,
32 magenta: 0xff2bd6, lime: 0x39ff14, yellow: 0xffe81f, blue: 0x3d7bff, orange: 0xff8a00, alert: 0xff2a6d, ink: 0x02131c,
33 you: 0x00f0ff, youHead: 0xefffff, clu: 0xff7a00, cluHead: 0xffd27a, grid: 0x0b4558, wreck: 0xff2a6d,
34 },
35 },
36}
37export const THEME_ORDER = ['soft', 'neon']
38
39// Live palette: renderers read these at draw time, so switching theme restyles everything on the next frame.
40export const C: Palette = { ...THEMES.soft.p }
41export const T = { name: 'soft', bg: THEMES.soft.bg, glow: THEMES.soft.glow }
42
43export function setTheme(name: string): boolean {
44 const th = THEMES[name]
45 if (!th) return false
46 Object.assign(C, th.p)
47 T.name = name
48 T.bg = th.bg
49 T.glow = th.glow
50 return true
51}
52
53export function nextThemeName(): string {
54 return THEME_ORDER[(THEME_ORDER.indexOf(T.name) + 1) % THEME_ORDER.length]
55}
56
57export function lerp(a: number, b: number, t: number): number {
58 const k = Math.min(1, Math.max(0, t))
59 const ch = (shift: number) => Math.round(((a >> shift) & 255) * (1 - k) + ((b >> shift) & 255) * k)
60 return (ch(16) << 16) | (ch(8) << 8) | ch(0)
61}
62
63// Three-stop gradient: a at t=0, b at 0.5, c at 1.
64export function lerp3(a: number, b: number, c: number, t: number): number {
65 return t < 0.5 ? lerp(a, b, t * 2) : lerp(b, c, (t - 0.5) * 2)
66}
67
68const cp = (s: string) => s.codePointAt(0) as number
69const LIGHT_H = cp('─')
70
71export class Canvas {
72 readonly cols: number
73 readonly rows: number
74 readonly baseBg: number
75 ch: number[]
76 fg: number[]
77 bg: number[]
78
79 constructor(cols: number, rows: number) {
80 this.cols = Math.max(1, Math.floor(cols))
81 this.rows = Math.max(1, Math.floor(rows))
82 this.baseBg = T.bg
83 const n = this.cols * this.rows
84 this.ch = Array(n).fill(32)
85 this.fg = Array(n).fill(C.text)
86 this.bg = Array(n).fill(this.baseBg)
87 }
88
89 // bg === DEFAULT keeps whatever background the cell already has (so glow survives text drawn over it).
90 set(x: number, y: number, code: number, fg: number, bg: number = DEFAULT) {
91 if (x < 0 || y < 0 || x >= this.cols || y >= this.rows) return
92 const i = y * this.cols + x
93 this.ch[i] = code
94 this.fg[i] = fg
95 if (bg !== DEFAULT) this.bg[i] = bg
96 }
97
98 charAt(x: number, y: number): number {
99 return x < 0 || y < 0 || x >= this.cols || y >= this.rows ? 32 : this.ch[y * this.cols + x]
100 }
101
102 // Blend a cell's background toward a colour: the "bloom" next to something bright.
103 tint(x: number, y: number, color: number, amount: number) {
104 if (T.glow <= 0 || x < 0 || y < 0 || x >= this.cols || y >= this.rows) return
105 const i = y * this.cols + x
106 const base = this.bg[i] === DEFAULT ? 0x05090d : this.bg[i]
107 this.bg[i] = lerp(base, color, amount)
108 }
109
110 // Draw a string; stops at maxX (inclusive). Returns the x after the last drawn cell.
111 text(x: number, y: number, s: string, fg: number, bg: number = DEFAULT, maxX: number = this.cols - 1): number {
112 for (const c of s) {
113 if (x > maxX) break
114 this.set(x, y, cp(c), fg, bg)
115 x++
116 }
117 return x
118 }
119
120 fill(x: number, y: number, w: number, h: number, glyph: string, fg: number, bg: number = DEFAULT) {
121 for (let j = 0; j < h; j++) for (let i = 0; i < w; i++) this.set(x + i, y + j, cp(glyph), fg, bg)
122 }
123
124 hline(x: number, y: number, w: number, glyph: string, fg: number) {
125 for (let i = 0; i < w; i++) this.set(x + i, y, cp(glyph), fg)
126 }
127
128 // Heavy corners + light edges read like the notched frames in the film.
129 // `label` sits in a tab on the top edge, `tab` is a second tab at the right.
130 // The cells just inside the frame get a faint tint of the frame colour, which reads as a glow.
131 frame(x: number, y: number, w: number, h: number, o: { color: number; label?: string; labelColor?: number; tab?: string; tabColor?: number; tabInset?: number }) {
132 if (w < 4 || h < 2) return
133 const col = o.color
134 for (let j = 1; j < h - 1; j++) {
135 this.tint(x + 1, y + j, col, T.glow)
136 this.tint(x + w - 2, y + j, col, T.glow)
137 this.tint(x + 2, y + j, col, T.glow * 0.4)
138 this.tint(x + w - 3, y + j, col, T.glow * 0.4)
139 }
140 for (let i = 1; i < w - 1; i++) {
141 this.tint(x + i, y + 1, col, T.glow)
142 this.tint(x + i, y + h - 2, col, T.glow)
143 if (h > 4) { this.tint(x + i, y + 2, col, T.glow * 0.4); this.tint(x + i, y + h - 3, col, T.glow * 0.4) }
144 }
145 this.set(x, y, cp('┏'), col)
146 this.set(x + w - 1, y, cp('┓'), col)
147 this.set(x, y + h - 1, cp('┗'), col)
148 this.set(x + w - 1, y + h - 1, cp('┛'), col)
149 this.hline(x + 1, y, w - 2, '─', col)
150 this.hline(x + 1, y + h - 1, w - 2, '─', col)
151 for (let j = 1; j < h - 1; j++) {
152 this.set(x, y + j, cp('│'), col)
153 this.set(x + w - 1, y + j, cp('│'), col)
154 }
155 if (o.label) {
156 this.set(x + 1, y, cp('━'), col)
157 this.set(x + 2, y, cp('┫'), col)
158 const end = this.text(x + 3, y, ' ' + o.label + ' ', o.labelColor ?? C.hot, DEFAULT, x + w - 3)
159 this.set(end, y, cp('┣'), col)
160 }
161 if (o.tab) {
162 const t = ' ' + o.tab + ' '
163 const start = x + w - (o.tabInset ?? 3) - [...t].length
164 if (start > x + 6 + (o.label ? [...o.label].length : 0)) {
165 this.set(start - 1, y, cp('┫'), col)
166 this.text(start, y, t, o.tabColor ?? col)
167 this.set(start + [...t].length, y, cp('┣'), col)
168 this.set(start + [...t].length + 1, y, cp('━'), col)
169 }
170 }
171 }
172
173 // ▕████░░░░▏ ; width counts the caps. With `onEnd` the filled part is a gradient. Returns x after the bar.
174 bar(x: number, y: number, w: number, frac: number, on: number, off: number = C.deep, onEnd?: number, mark?: number): number {
175 const inner = Math.max(1, w - 2)
176 const filled = Math.round(Math.min(1, Math.max(0, frac || 0)) * inner)
177 this.set(x, y, cp('▕'), C.dim)
178 for (let i = 0; i < inner; i++) {
179 const c = i < filled ? (onEnd == null ? on : lerp(on, onEnd, inner > 1 ? i / (inner - 1) : 0)) : off
180 this.set(x + 1 + i, y, cp(i < filled ? '█' : '░'), c)
181 }
182 // optional marker: how far through its window we are (e.g. time elapsed in a 5-hour limit)
183 if (mark != null && mark > 0 && mark < 1) this.set(x + 1 + Math.min(inner - 1, Math.floor(mark * inner)), y, cp('│'), C.white)
184 this.set(x + 1 + inner, y, cp('▏'), C.dim)
185 return x + inner + 2
186 }
187
188 cells(): number[] {
189 const out: number[] = new Array(this.cols * this.rows * 3)
190 for (let i = 0; i < this.ch.length; i++) {
191 out[i * 3] = this.ch[i]
192 out[i * 3 + 1] = this.fg[i]
193 out[i * 3 + 2] = this.bg[i]
194 }
195 return out
196 }
197
198 // The packed string a Raster's `cells` prop takes.
199 pack(): string {
200 const bytes = new Uint8Array(Uint32Array.from(this.cells()).buffer)
201 const anyBytes = bytes as any
202 if (typeof anyBytes.toBase64 === 'function') return anyBytes.toBase64()
203 let bin = ''
204 for (let i = 0; i < bytes.length; i += 0x8000) bin += String.fromCharCode(...bytes.subarray(i, i + 0x8000))
205 return btoa(bin)
206 }
207
208 // For tests and previews outside Claude Code.
209 toAnsi(): string {
210 const rgb = (n: number) => (n === DEFAULT ? null : `${(n >> 16) & 255};${(n >> 8) & 255};${n & 255}`)
211 const lines: string[] = []
212 for (let y = 0; y < this.rows; y++) {
213 let line = ''
214 for (let x = 0; x < this.cols; x++) {
215 const i = y * this.cols + x
216 const f = rgb(this.fg[i])
217 const b = rgb(this.bg[i])
218 line += `\x1b[${f ? '38;2;' + f : '39'};${b ? '48;2;' + b : '49'}m${String.fromCodePoint(this.ch[i])}`
219 }
220 lines.push(line + '\x1b[0m')
221 }
222 return lines.join('\n')
223 }
224}
225
226export { LIGHT_H }
227hooks/game.ts 255 lines1// Light cycles. You (cyan) against Clu (orange) on a grid; hit a wall or any trail and you de-rez.
2// Pure logic: no Claude Code APIs, so it is easy to test.
3
4export type Dir = 'up' | 'down' | 'left' | 'right'
5export type GameState = 'ready' | 'running' | 'paused' | 'won' | 'lost' | 'draw'
6export type Level = 'easy' | 'normal' | 'hard' | 'insane'
7
8// stepMs: how long one move takes. Lower is faster.
9export const LEVELS: Record<Level, { label: string; stepMs: number }> = {
10 easy: { label: 'EASY', stepMs: 150 },
11 normal: { label: 'NORMAL', stepMs: 100 },
12 hard: { label: 'HARD', stepMs: 80 },
13 insane: { label: 'INSANE', stepMs: 60 },
14}
15export const LEVEL_ORDER: Level[] = ['easy', 'normal', 'hard', 'insane']
16
17export const DX: Record<Dir, number> = { up: 0, down: 0, left: -1, right: 1 }
18export const DY: Record<Dir, number> = { up: -1, down: 1, left: 0, right: 0 }
19const LEFT_OF: Record<Dir, Dir> = { up: 'left', left: 'down', down: 'right', right: 'up' }
20const RIGHT_OF: Record<Dir, Dir> = { up: 'right', right: 'down', down: 'left', left: 'up' }
21const OPPOSITE: Record<Dir, Dir> = { up: 'down', down: 'up', left: 'right', right: 'left' }
22
23export interface Cycle {
24 x: number
25 y: number
26 dir: Dir
27 alive: boolean
28}
29
30export interface Game {
31 w: number
32 h: number
33 grid: Uint8Array // 0 empty, 1 your trail, 2 Clu's trail
34 stamp: Uint32Array // the move on which each trail cell was laid, so the renderer can fade trails by age
35 you: Cycle
36 clu: Cycle
37 queued: Dir | null
38 state: GameState
39 level: Level
40 wins: number
41 losses: number
42 draws: number
43 ticks: number
44 seed: number
45}
46
47const idx = (g: Game, x: number, y: number) => y * g.w + x
48const inside = (g: Game, x: number, y: number) => x >= 0 && y >= 0 && x < g.w && y < g.h
49export const cellOpen = (g: Game, x: number, y: number) => inside(g, x, y) && g.grid[idx(g, x, y)] === 0
50const isOpen = (g: Game, x: number, y: number) => inside(g, x, y) && g.grid[idx(g, x, y)] === 0
51
52// mulberry32: small seeded generator so games can be replayed in tests
53function rnd(g: Game): number {
54 g.seed = (g.seed + 0x6d2b79f5) | 0
55 let t = Math.imul(g.seed ^ (g.seed >>> 15), 1 | g.seed)
56 t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
57 return ((t ^ (t >>> 14)) >>> 0) / 4294967296
58}
59
60export function newGame(w: number, h: number, prev?: Game | null, seed: number = Date.now()): Game {
61 const W = Math.max(8, Math.floor(w))
62 const H = Math.max(6, Math.floor(h))
63 const g: Game = {
64 w: W, h: H, grid: new Uint8Array(W * H), stamp: new Uint32Array(W * H),
65 you: { x: Math.floor(W * 0.2), y: Math.floor(H / 2), dir: 'right', alive: true },
66 clu: { x: Math.floor(W * 0.8), y: Math.floor(H / 2), dir: 'left', alive: true },
67 queued: null, state: 'ready', level: prev?.level ?? 'normal',
68 wins: prev?.wins ?? 0, losses: prev?.losses ?? 0, draws: prev?.draws ?? 0,
69 ticks: 0, seed,
70 }
71 g.grid[idx(g, g.you.x, g.you.y)] = 1
72 g.grid[idx(g, g.clu.x, g.clu.y)] = 2
73 return g
74}
75
76// Steer. The first press starts the run. A cycle can't reverse into its own trail.
77export function turn(g: Game, dir: Dir) {
78 if (g.state === 'won' || g.state === 'lost' || g.state === 'draw' || g.state === 'paused') return
79 if (dir === OPPOSITE[g.you.dir]) return // a reversal does nothing, and doesn't start the run either
80 if (g.state === 'ready') g.state = 'running'
81 g.queued = dir
82}
83
84export function togglePause(g: Game) {
85 if (g.state === 'running') g.state = 'paused'
86 else if (g.state === 'paused') g.state = 'running'
87}
88
89export function restart(g: Game): Game {
90 return newGame(g.w, g.h, g)
91}
92
93// Changing level starts a fresh run (scores are kept).
94export function setLevel(g: Game, level: Level): Game {
95 const n = newGame(g.w, g.h, g)
96 n.level = level
97 return n
98}
99
100export function nextLevel(g: Game): Game {
101 return setLevel(g, LEVEL_ORDER[(LEVEL_ORDER.indexOf(g.level) + 1) % LEVEL_ORDER.length])
102}
103
104// How much open floor is reachable from here (capped), so Clu can avoid boxing itself in.
105export function freeSpace(g: Game, x: number, y: number, cap = 500): number {
106 if (!isOpen(g, x, y)) return 0
107 const seen = new Uint8Array(g.w * g.h)
108 const queue: number[] = [idx(g, x, y)]
109 seen[queue[0]] = 1
110 let count = 0
111 for (let q = 0; q < queue.length && count < cap; q++) {
112 const cx = queue[q] % g.w
113 const cy = Math.floor(queue[q] / g.w)
114 count++
115 for (const d of ['up', 'down', 'left', 'right'] as Dir[]) {
116 const nx = cx + DX[d]
117 const ny = cy + DY[d]
118 if (isOpen(g, nx, ny) && !seen[idx(g, nx, ny)]) {
119 seen[idx(g, nx, ny)] = 1
120 queue.push(idx(g, nx, ny))
121 }
122 }
123 }
124 return count
125}
126
127// How often HARD plays the territory move (INSANE always does). Lower is easier.
128export const HARD_FOCUS = 0.6
129
130const FOUR: Dir[] = ['up', 'down', 'left', 'right']
131
132// Who gets to each empty cell first? Returns (cells Clu reaches first) - (cells you reach first).
133// Cells both reach on the same move are nobody's. This is the classic light-cycle evaluation:
134// the move that leaves Clu the bigger territory also cuts you off.
135function territory(g: Game, clu: number, you: number): number {
136 const n = g.w * g.h
137 const owner = new Int8Array(n) // 0 free, 1 Clu, 2 you, 3 closed
138 for (let i = 0; i < n; i++) if (g.grid[i] !== 0) owner[i] = 3
139 let fc: number[] = [clu]
140 let fy: number[] = [you]
141 owner[clu] = 1
142 if (owner[you] === 1) owner[you] = 3
143 else owner[you] = 2
144 let cc = 0
145 let yc = 0
146 const grab = (frontier: number[]): number[] => {
147 const out: number[] = []
148 for (const c of frontier) {
149 const cx = c % g.w
150 const cy = Math.floor(c / g.w)
151 for (const d of FOUR) {
152 const nx = cx + DX[d]
153 const ny = cy + DY[d]
154 if (inside(g, nx, ny) && owner[ny * g.w + nx] === 0) out.push(ny * g.w + nx)
155 }
156 }
157 return out
158 }
159 while (fc.length || fy.length) {
160 const a = grab(fc)
161 const b = grab(fy)
162 const setB = new Set(b)
163 const setA = new Set(a)
164 const nextC: number[] = []
165 const nextY: number[] = []
166 for (const c of setA) {
167 if (setB.has(c)) owner[c] = 3
168 else { owner[c] = 1; cc++; nextC.push(c) }
169 }
170 for (const c of setB) {
171 if (!setA.has(c)) { owner[c] = 2; yc++; nextY.push(c) }
172 }
173 fc = nextC
174 fy = nextY
175 }
176 return cc - yc
177}
178
179function aiPick(g: Game): Dir {
180 const d = g.clu.dir
181 const opts = ([d, LEFT_OF[d], RIGHT_OF[d]] as Dir[]).map((o) => {
182 const nx = g.clu.x + DX[o]
183 const ny = g.clu.y + DY[o]
184 return { o, nx, ny, free: isOpen(g, nx, ny) }
185 })
186 const safe = opts.filter((s) => s.free)
187 if (safe.length === 0) return d // boxed in: nothing to choose
188 if (safe.length === 1) return safe[0].o
189
190 // EASY: avoids the wall right in front of it and little else, and wanders.
191 if (g.level === 'easy') {
192 const straight = safe.find((s) => s.o === d)
193 if (straight && rnd(g) > 0.1) return d
194 const others = safe.filter((s) => s.o !== d)
195 return (others.length ? others : safe)[Math.floor(rnd(g) * (others.length || safe.length))].o
196 }
197
198 // NORMAL (and the moves HARD plays by feel): keeps to open floor, mostly straight, the odd unpredictable turn.
199 // HARD plays the territory move HARD_FOCUS of the time and falls back to this the rest, so it can be beaten.
200 if (g.level === 'normal' || (g.level === 'hard' && rnd(g) > HARD_FOCUS)) {
201 const scored = safe.map((s) => ({ ...s, room: freeSpace(g, s.nx, s.ny) }))
202 const best = Math.max(...scored.map((s) => s.room))
203 const straight = scored.find((s) => s.o === d)
204 if (straight && straight.room >= best * 0.85) {
205 const turns = scored.filter((s) => s.o !== d && s.room >= best * 0.6)
206 if (turns.length > 0 && rnd(g) < 0.05) return turns[Math.floor(rnd(g) * turns.length)].o
207 return d
208 }
209 return scored.reduce((a, b) => (b.room > a.room ? b : a)).o
210 }
211
212 // INSANE (and HARD when it is focused): pick the move that wins the most territory.
213 // look where you are about to be, not where you are
214 const yAhead = isOpen(g, g.you.x + DX[g.you.dir], g.you.y + DY[g.you.dir])
215 ? idx(g, g.you.x + DX[g.you.dir], g.you.y + DY[g.you.dir])
216 : idx(g, g.you.x, g.you.y)
217 let bestMove = safe[0]
218 let bestScore = -Infinity
219 for (const s of safe) {
220 const cell = idx(g, s.nx, s.ny)
221 g.grid[cell] = 2 // pretend we moved there
222 const score = territory(g, cell, yAhead) + (s.o === d ? 0.5 : 0)
223 g.grid[cell] = 0
224 if (score > bestScore) { bestScore = score; bestMove = s }
225 }
226 return bestMove.o
227}
228
229export function step(g: Game) {
230 if (g.state !== 'running') return
231 if (g.queued) {
232 g.you.dir = g.queued
233 g.queued = null
234 }
235 g.clu.dir = aiPick(g)
236
237 const yx = g.you.x + DX[g.you.dir]
238 const yy = g.you.y + DY[g.you.dir]
239 const cx = g.clu.x + DX[g.clu.dir]
240 const cy = g.clu.y + DY[g.clu.dir]
241 const headOn = yx === cx && yy === cy
242 const youDead = headOn || !isOpen(g, yx, yy)
243 const cluDead = headOn || !isOpen(g, cx, cy)
244
245 if (youDead) g.you.alive = false
246 else { g.you.x = yx; g.you.y = yy; g.grid[idx(g, yx, yy)] = 1; g.stamp[idx(g, yx, yy)] = g.ticks }
247 if (cluDead) g.clu.alive = false
248 else { g.clu.x = cx; g.clu.y = cy; g.grid[idx(g, cx, cy)] = 2; g.stamp[idx(g, cx, cy)] = g.ticks }
249
250 g.ticks++
251 if (youDead && cluDead) { g.state = 'draw'; g.draws++ }
252 else if (youDead) { g.state = 'lost'; g.losses++ }
253 else if (cluDead) { g.state = 'won'; g.wins++ }
254}
255hooks/summary.ts 376 lines1// The central ledger: one compact row per completed turn, kept per session in ~/.claude/claudegrid/<session>/rows.json.
2// Everything here is pure (rows in, text out) so it can be tested without Claude Code.
3
4import { fmtUsd, fmtTokens, fmtDur, fmtMs, cacheHit, trunc } from './lib.ts'
5
6export interface ToolStat { n: number; ms: number; fail: number; est: number } // calls, wall ms, failures, estimated result tokens
7export interface ModelStat { steps: number; in: number; out: number; cacheRead: number; cacheWrite: number }
8
9export interface Row {
10 v: number // 2 adds toolStats and modelStats; older rows are upgraded from their turn files when those exist
11 id: string // "<session>:<T3|B1>", unique, so a row is never counted twice
12 session: string
13 kind: 'turn' | 'bg'
14 n: number
15 at: string // when the turn ended (ISO)
16 card: string | null // Jira key
17 branch: string
18 project: string // the folder name of the working directory
19 models: Record<string, number> // model -> requests
20 in: number
21 out: number
22 cacheRead: number
23 cacheWrite: number
24 steps: number
25 toolCalls: number
26 toolMs: number
27 fails: number
28 tools: Record<string, number>
29 skills: string[]
30 agents: string[]
31 durationMs: number
32 costUsd: number
33 billing?: string
34 toolStats?: Record<string, ToolStat> // per tool: what makes a BY TOOL table real, instead of the turn's totals repeated
35 modelStats?: Record<string, ModelStat> // per model: exact tokens from each request's usage
36}
37
38const num = (v: any): number => (typeof v === 'number' && isFinite(v) ? v : 0)
39const str = (v: any): string => (typeof v === 'string' ? v : '')
40
41// Turn a saved turn record (what turn-0001.json holds) into a compact row.
42export function rowFromRecord(rec: any, ctx: { session: string; project?: string; billing?: string }): Row {
43 const models: Record<string, number> = {}
44 const modelStats: Record<string, ModelStat> = {}
45 for (const s of rec?.steps ?? []) {
46 const m = str(s?.model) || 'unknown'
47 models[m] = (models[m] ?? 0) + 1
48 const ms = (modelStats[m] ??= { steps: 0, in: 0, out: 0, cacheRead: 0, cacheWrite: 0 })
49 ms.steps += 1
50 ms.in += num(s?.usage?.in); ms.out += num(s?.usage?.out)
51 ms.cacheRead += num(s?.usage?.cacheRead); ms.cacheWrite += num(s?.usage?.cacheWrite)
52 }
53 const tools: Record<string, number> = {}
54 const toolStats: Record<string, ToolStat> = {}
55 let toolMs = 0
56 let fails = 0
57 for (const t of rec?.tools ?? []) {
58 const name = str(t?.name) || 'unknown'
59 tools[name] = (tools[name] ?? 0) + 1
60 toolMs += num(t?.wallMs)
61 if (t?.ok === false) fails++
62 const ts = (toolStats[name] ??= { n: 0, ms: 0, fail: 0, est: 0 })
63 ts.n += 1; ts.ms += num(t?.wallMs); ts.est += num(t?.estResultTokens)
64 if (t?.ok === false) ts.fail += 1
65 }
66 const kind: 'turn' | 'bg' = rec?.kind === 'bg' ? 'bg' : 'turn'
67 const n = kind === 'turn' ? num(rec?.turn) : num(rec?.bg)
68 const tot = rec?.totals ?? {}
69 return {
70 v: 2,
71 id: `${ctx.session}:${kind === 'turn' ? 'T' : 'B'}${n}`,
72 session: ctx.session, kind, n,
73 at: str(rec?.endedAt) || str(rec?.startedAt) || new Date(0).toISOString(),
74 card: str(rec?.jira) || null,
75 branch: str(rec?.branch),
76 project: ctx.project ?? '',
77 models,
78 in: num(tot.in), out: num(tot.out), cacheRead: num(tot.cacheRead), cacheWrite: num(tot.cacheWrite),
79 steps: (rec?.steps ?? []).length,
80 toolCalls: (rec?.tools ?? []).length, toolMs, fails, tools,
81 skills: (rec?.skills ?? []).map((s: any) => str(s?.name)).filter(Boolean),
82 agents: (rec?.agents ?? []).map((a: any) => str(a?.type)).filter(Boolean),
83 durationMs: num(rec?.durationMs),
84 costUsd: num(rec?.costUsd),
85 billing: ctx.billing,
86 toolStats, modelStats,
87 }
88}
89
90// ---------- arguments ----------
91export type Dim = 'card' | 'day' | 'week' | 'month' | 'model' | 'skill' | 'tool' | 'agent' | 'project' | 'session'
92const DIMS: Dim[] = ['card', 'day', 'week', 'month', 'model', 'skill', 'tool', 'agent', 'project', 'session']
93const JIRA = /^[A-Za-z][A-Za-z0-9]{1,9}-\d{1,6}$/
94
95export interface SummaryArgs {
96 dim: Dim | null
97 card: string | null
98 days: number | null // null = all time
99 label: string
100 error?: string
101}
102
103// /grid summary [card|day|week|month|model|skill|tool|agent|project|session] [ABC-123] [today|7d|2w|30d|all]
104export function parseSummaryArgs(text: string): SummaryArgs {
105 const a: SummaryArgs = { dim: null, card: null, days: 30, label: 'last 30 days' }
106 for (const raw of text.split(/\s+/).filter(Boolean)) {
107 const t = raw.toLowerCase()
108 if ((DIMS as string[]).includes(t)) a.dim = t as Dim
109 else if (t === 'all') { a.days = null; a.label = 'all time' }
110 else if (t === 'today') { a.days = 1; a.label = 'today' }
111 else if (/^\d+d$/.test(t)) { a.days = Number(t.slice(0, -1)); a.label = `last ${a.days} days` }
112 else if (/^\d+w$/.test(t)) { a.days = Number(t.slice(0, -1)) * 7; a.label = `last ${a.days} days` }
113 else if (JIRA.test(raw)) a.card = raw.toUpperCase()
114 else a.error = `Don't know "${raw}". Try: card day week month model skill tool agent project session, a key like ABC-123, today 7d 2w 30d all.`
115 }
116 if (a.days === 0) a.error = 'A range of 0 days is empty.'
117 return a
118}
119
120export function filterRows(rows: Row[], a: { days: number | null; card: string | null }, now: Date = new Date()): Row[] {
121 let cutoff = -Infinity
122 if (a.days != null) {
123 const d = new Date(now.getFullYear(), now.getMonth(), now.getDate() - (a.days - 1)) // local midnight, N days including today
124 cutoff = d.getTime()
125 }
126 return rows.filter((r) => Date.parse(r.at) >= cutoff && (!a.card || r.card === a.card))
127}
128
129// ---------- grouping ----------
130const pad2 = (n: number) => String(n).padStart(2, '0')
131const localDay = (iso: string): string => {
132 const d = new Date(iso)
133 return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`
134}
135const weekStart = (iso: string): string => {
136 const d = new Date(iso)
137 const back = (d.getDay() + 6) % 7 // Monday = 0
138 const m = new Date(d.getFullYear(), d.getMonth(), d.getDate() - back)
139 return `${m.getFullYear()}-${pad2(m.getMonth() + 1)}-${pad2(m.getDate())}`
140}
141
142export function keysFor(r: Row, dim: Dim): string[] {
143 switch (dim) {
144 case 'card': return [r.card ?? '(no card)']
145 case 'day': return [localDay(r.at)]
146 case 'week': return [weekStart(r.at)]
147 case 'month': return [localDay(r.at).slice(0, 7)]
148 case 'model': return Object.keys(r.models).length ? Object.keys(r.models) : ['unknown']
149 case 'skill': return [...new Set(r.skills)]
150 case 'tool': return Object.keys(r.tools)
151 case 'agent': return [...new Set(r.agents)]
152 case 'project': return [r.project || '(unknown)']
153 case 'session': return [r.session]
154 }
155}
156
157export interface Agg {
158 key: string
159 cost: number
160 turns: number
161 sessions: Set<string>
162 in: number
163 out: number
164 cr: number
165 cw: number
166 tools: number
167 ms: number
168 first: string
169 last: string
170 // per-dimension figures. For tool and model rows these are the tool's or model's own; the turn totals above are not used for them.
171 calls: number
172 toolMs: number
173 fail: number
174 est: number
175 reqs: number
176 spawns: number
177 msKnown: boolean // some turn in this group had per-tool timing (older rows only know call counts)
178 tokKnown: boolean // the model's own tokens are known
179}
180
181// Multi-valued dimensions (skill, tool, agent, model) put a turn under each value it used. What is added differs by dimension:
182// tool -> that tool's own calls, time, failures and result size (never the turn's totals)
183// model -> that model's own requests and tokens (a turn on one model attributes all of it; on two, from the per-request usage)
184// skill, agent -> the whole turn's cost and tokens ("the turns that used it"), plus the agent's own spawn count
185// Everything else (card, day, project, session...) adds the turn's totals.
186export function group(rows: Row[], dim: Dim): Agg[] {
187 const m = new Map<string, Agg>()
188 for (const r of rows) {
189 for (const key of keysFor(r, dim)) {
190 const a = m.get(key) ?? { key, cost: 0, turns: 0, sessions: new Set<string>(), in: 0, out: 0, cr: 0, cw: 0, tools: 0, ms: 0, first: r.at, last: r.at, calls: 0, toolMs: 0, fail: 0, est: 0, reqs: 0, spawns: 0, msKnown: false, tokKnown: false }
191 a.turns += 1; a.sessions.add(r.session); a.ms += r.durationMs
192 if (r.at < a.first) a.first = r.at
193 if (r.at > a.last) a.last = r.at
194 if (dim === 'tool') {
195 const ts = r.toolStats?.[key]
196 a.calls += ts?.n ?? r.tools[key] ?? 0
197 if (ts) { a.toolMs += ts.ms; a.fail += ts.fail; a.est += ts.est; a.msKnown = true }
198 } else if (dim === 'model') {
199 const st = r.modelStats?.[key]
200 const only = Object.keys(r.models).length <= 1
201 if (st) { a.reqs += st.steps; a.in += st.in; a.out += st.out; a.cr += st.cacheRead; a.cw += st.cacheWrite; a.tokKnown = true }
202 else if (only) { a.reqs += r.models[key] ?? 0; a.in += r.in; a.out += r.out; a.cr += r.cacheRead; a.cw += r.cacheWrite; a.tokKnown = true }
203 else a.reqs += r.models[key] ?? 0 // two models in one old row: the split is unknown, so no tokens
204 } else {
205 a.cost += r.costUsd
206 a.in += r.in; a.out += r.out; a.cr += r.cacheRead; a.cw += r.cacheWrite
207 a.tools += r.toolCalls
208 if (dim === 'agent') a.spawns += r.agents.filter((x) => x === key).length
209 }
210 m.set(key, a)
211 }
212 }
213 const list = [...m.values()]
214 const byTime = dim === 'day' || dim === 'week' || dim === 'month'
215 return list.sort((x, y) => {
216 if (byTime) return x.key < y.key ? 1 : -1
217 if (dim === 'tool') return y.calls - x.calls || y.turns - x.turns
218 if (dim === 'model') return y.out - x.out || y.reqs - x.reqs
219 if (dim === 'skill' || dim === 'agent') return y.turns - x.turns || y.cost - x.cost
220 return y.cost - x.cost || y.turns - x.turns
221 })
222}
223
224// ---------- columns: what each kind of table shows ----------
225// A table's columns depend on what it counts, so a BY TOOL table is about calls and time and a BY MODEL table about requests and tokens.
226// `pane` marks the columns the narrower console panes try first (in this order, as many as fit).
227export interface ColDef { id: string; head: string; w: number; get: (a: Agg) => string; pane?: boolean }
228
229const pct = (a: Agg) => Math.round(cacheHit(a.in, a.cr, a.cw) * 100) + '%'
230const MONEY: ColDef[] = [
231 { id: 'cost', head: 'COST', w: 8, get: (a) => fmtUsd(a.cost), pane: true },
232 { id: 'turns', head: 'TURNS', w: 5, get: (a) => String(a.turns), pane: true },
233 { id: 'sess', head: 'SESS', w: 4, get: (a) => String(a.sessions.size), pane: true },
234 { id: 'out', head: 'OUT', w: 7, get: (a) => fmtTokens(a.out), pane: true },
235 { id: 'in', head: 'IN', w: 7, get: (a) => fmtTokens(a.in) },
236 { id: 'cache', head: 'CACHE', w: 5, get: pct },
237 { id: 'tools', head: 'TOOLS', w: 5, get: (a) => String(a.tools) },
238 { id: 'time', head: 'TIME', w: 8, get: (a) => fmtDur(a.ms) },
239 { id: 'last', head: 'LAST', w: 10, get: (a) => a.last.slice(0, 10), pane: true },
240]
241const TOOL: ColDef[] = [
242 { id: 'calls', head: 'CALLS', w: 5, get: (a) => String(a.calls), pane: true },
243 { id: 'tms', head: 'TIME', w: 7, get: (a) => (a.msKnown ? fmtMs(a.toolMs) : '—'), pane: true },
244 { id: 'avg', head: 'AVG', w: 6, get: (a) => (a.msKnown && a.calls ? fmtMs(a.toolMs / a.calls) : '—'), pane: true },
245 { id: 'fail', head: 'FAIL', w: 4, get: (a) => (a.msKnown ? String(a.fail) : '—'), pane: true },
246 { id: 'turns', head: 'TURNS', w: 5, get: (a) => String(a.turns), pane: true },
247 { id: 'sess', head: 'SESS', w: 4, get: (a) => String(a.sessions.size) },
248 { id: 'est', head: 'RESULT~', w: 7, get: (a) => (a.msKnown ? fmtTokens(a.est) : '—') },
249 { id: 'last', head: 'LAST', w: 10, get: (a) => a.last.slice(0, 10) },
250]
251const MODEL: ColDef[] = [
252 { id: 'reqs', head: 'REQS', w: 5, get: (a) => String(a.reqs), pane: true },
253 { id: 'out', head: 'OUT', w: 7, get: (a) => (a.tokKnown ? fmtTokens(a.out) : '—'), pane: true },
254 { id: 'in', head: 'IN', w: 7, get: (a) => (a.tokKnown ? fmtTokens(a.in) : '—'), pane: true },
255 { id: 'turns', head: 'TURNS', w: 5, get: (a) => String(a.turns), pane: true },
256 { id: 'cr', head: 'CACHE-R', w: 8, get: (a) => (a.tokKnown ? fmtTokens(a.cr) : '—') },
257 { id: 'cw', head: 'CACHE-W', w: 8, get: (a) => (a.tokKnown ? fmtTokens(a.cw) : '—') },
258 { id: 'sess', head: 'SESS', w: 4, get: (a) => String(a.sessions.size) },
259 { id: 'last', head: 'LAST', w: 10, get: (a) => a.last.slice(0, 10) },
260]
261// Skills and agents: the figures are for the whole turns that used them (marked *), so two skills in one turn each carry that turn.
262const SKILL: ColDef[] = [
263 { id: 'turns', head: 'USES', w: 5, get: (a) => String(a.turns), pane: true },
264 { id: 'sess', head: 'SESS', w: 4, get: (a) => String(a.sessions.size), pane: true },
265 { id: 'cost', head: 'COST*', w: 8, get: (a) => fmtUsd(a.cost), pane: true },
266 { id: 'out', head: 'OUT*', w: 7, get: (a) => fmtTokens(a.out), pane: true },
267 { id: 'last', head: 'LAST', w: 10, get: (a) => a.last.slice(0, 10) },
268]
269const AGENT: ColDef[] = [
270 { id: 'spawns', head: 'SPAWNS', w: 6, get: (a) => String(a.spawns), pane: true },
271 { id: 'turns', head: 'TURNS', w: 5, get: (a) => String(a.turns), pane: true },
272 { id: 'sess', head: 'SESS', w: 4, get: (a) => String(a.sessions.size), pane: true },
273 { id: 'cost', head: 'COST*', w: 8, get: (a) => fmtUsd(a.cost), pane: true },
274 { id: 'last', head: 'LAST', w: 10, get: (a) => a.last.slice(0, 10) },
275]
276
277export function colsFor(dim: Dim): ColDef[] {
278 return dim === 'tool' ? TOOL : dim === 'model' ? MODEL : dim === 'skill' ? SKILL : dim === 'agent' ? AGENT : MONEY
279}
280
281// What the bar next to a row measures, and its scale: calls for tools, output tokens for models, uses for skills and agents, cost otherwise.
282export function barValue(a: Agg, dim: Dim): number {
283 return dim === 'tool' ? a.calls : dim === 'model' ? a.out : dim === 'skill' || dim === 'agent' ? a.turns : a.cost
284}
285
286export const WHOLE_TURN_NOTE = '* cost and tokens of the whole turns that used it; a turn that used two skills (or agents) counts under both, so these rows add up to more than the total'
287
288// ---------- text ----------
289const R = (s: string, w: number) => s.padStart(w)
290const L = (s: string, w: number) => (s.length > w ? s.slice(0, w - 1) + '…' : s.padEnd(w))
291
292function table(title: string, aggs: Agg[], keyLabel: string, limit: number, dim: Dim): string[] {
293 if (aggs.length === 0) return []
294 const cols = colsFor(dim)
295 const shown = aggs.slice(0, limit)
296 const kw = Math.min(28, Math.max(keyLabel.length, ...shown.map((a) => a.key.length)))
297 const head = `${L(keyLabel, kw)} ${cols.map((c) => R(c.head, c.w)).join(' ')}`
298 const rows = shown.map((a) => `${L(a.key, kw)} ${cols.map((c) => R(c.get(a), c.w)).join(' ')}`)
299 const out = [title, head, ...rows]
300 if (aggs.length > limit) out.push(`… and ${aggs.length - limit} more`)
301 return out
302}
303
304function headline(rows: Row[], a: SummaryArgs): string {
305 const cost = rows.reduce((s, r) => s + r.costUsd, 0)
306 const out = rows.reduce((s, r) => s + r.out, 0)
307 const inn = rows.reduce((s, r) => s + r.in, 0)
308 const cr = rows.reduce((s, r) => s + r.cacheRead, 0)
309 const cw = rows.reduce((s, r) => s + r.cacheWrite, 0)
310 const sessions = new Set(rows.map((r) => r.session)).size
311 const tools = rows.reduce((s, r) => s + r.toolCalls, 0)
312 const ms = rows.reduce((s, r) => s + r.durationMs, 0)
313 return `GRID SUMMARY · ${a.label}${a.card ? ' · ' + a.card : ''} · ${sessions} sessions · ${rows.length} turns · ${fmtUsd(cost)} · out ${fmtTokens(out)} · in ${fmtTokens(inn)} · cache hit ${Math.round(cacheHit(inn, cr, cw) * 100)}% · ${tools} tool calls · ${fmtDur(ms)} of Claude time`
314}
315
316export function renderSummary(all: Row[], a: SummaryArgs, note = '', now: Date = new Date()): string {
317 if (a.error) return a.error
318 const rows = filterRows(all, a, now)
319 if (rows.length === 0) {
320 return `No ledger data for ${a.card ? a.card + ' in ' : ''}${a.label}.${note ? '\n' + note : ''}`
321 }
322 const lines: string[] = [headline(rows, a)]
323 let wholeTurn = false // a skill or agent table was printed: say what its cost means
324 const add = (title: string, dim: Dim, keyLabel: string, limit: number, list: Agg[] = group(rows, dim)) => {
325 const t = table(title, list, keyLabel, limit, dim)
326 if (t.length) { lines.push('', ...t); if (dim === 'skill' || dim === 'agent') wholeTurn = true }
327 }
328
329 if (a.dim) {
330 const labels: Record<Dim, string> = { card: 'CARD', day: 'DAY', week: 'WEEK OF', month: 'MONTH', model: 'MODEL', skill: 'SKILL', tool: 'TOOL', agent: 'AGENT', project: 'PROJECT', session: 'SESSION' }
331 const hint: Partial<Record<Dim, string>> = {
332 tool: ' (each tool\'s own calls, time and failures; RESULT~ is an estimate of tokens its results added)',
333 model: ' (each model\'s own requests and tokens)',
334 }
335 add(`BY ${labels[a.dim]}${hint[a.dim] ?? ''}`, a.dim, labels[a.dim], 40)
336 } else if (a.card) {
337 // one card: where the money went
338 add('SESSIONS', 'session', 'SESSION', 15, group(rows, 'session').map((s) => ({ ...s, key: s.key.slice(0, 8) })))
339 add('BY DAY', 'day', 'DAY', 14)
340 add('BY MODEL', 'model', 'MODEL', 8)
341 add('SKILLS', 'skill', 'SKILL', 8)
342 add('AGENTS', 'agent', 'AGENT', 8)
343 add('TOOLS', 'tool', 'TOOL', 8)
344 } else {
345 add('BY CARD', 'card', 'CARD', 10)
346 add('BY DAY', 'day', 'DAY', 7)
347 add('BY MODEL', 'model', 'MODEL', 5)
348 add('TOP SKILLS', 'skill', 'SKILL', 5)
349 add('TOP AGENTS', 'agent', 'AGENT', 5)
350 add('TOP TOOLS', 'tool', 'TOOL', 6)
351 lines.push('', 'Drill down: /grid summary card ABC-123 · /grid summary skill 7d · /grid export')
352 }
353 if (wholeTurn) lines.push('', WHOLE_TURN_NOTE)
354 if (note) lines.push('', note)
355 return lines.join('\n')
356}
357
358// ---------- CSV ----------
359const cell = (v: unknown): string => {
360 const s = String(v ?? '')
361 return /[",\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s
362}
363
364export function toCsv(rows: Row[]): string {
365 const head = ['at', 'session', 'kind', 'turn', 'card', 'branch', 'project', 'models', 'cost_usd', 'in', 'out', 'cache_read', 'cache_write', 'steps', 'tool_calls', 'tool_ms', 'tool_failures', 'duration_ms', 'skills', 'agents', 'tools']
366 const body = [...rows].sort((a, b) => (a.at < b.at ? -1 : 1)).map((r) => [
367 r.at, r.session, r.kind, r.n, r.card ?? '', r.branch, r.project, Object.keys(r.models).join('|'), r.costUsd.toFixed(6),
368 r.in, r.out, r.cacheRead, r.cacheWrite, r.steps, r.toolCalls, r.toolMs, r.fails, r.durationMs,
369 [...new Set(r.skills)].join('|'), [...new Set(r.agents)].join('|'),
370 Object.entries(r.tools).map(([k, c]) => `${k}:${c}`).join('|'),
371 ].map(cell).join(','))
372 return [head.join(','), ...body].join('\n') + '\n'
373}
374
375export { trunc }
376hooks/summaryview.ts 205 lines1// The SUMMARY tab: the central ledger drawn in the same Raster style as the rest of the console.
2// Pure (state in, Canvas out) so it can be tested and previewed without Claude Code.
3
4import { Canvas, C, DEFAULT, lerp3 } from './canvas.ts'
5import { group, filterRows, colsFor, barValue } from './summary.ts'
6import type { Row, Agg, Dim, ColDef } from './summary.ts'
7import { fmtUsd, fmtTokens, fmtDur, cacheHit, clamp } from './lib.ts'
8
9export type SView = 'overview' | 'card' | 'day' | 'week' | 'month' | 'model' | 'skill' | 'tool' | 'agent' | 'project' | 'session' | 'detail'
10export const SVIEWS: SView[] = ['overview', 'card', 'day', 'model', 'skill', 'agent', 'tool', 'project']
11export const RANGE_PRESETS: (number | null)[] = [1, 7, 30, null]
12
13export interface SumState {
14 rows: Row[]
15 loaded: boolean
16 loading: boolean
17 loadedAt: number
18 note: string
19 view: SView
20 days: number | null
21 sel: number // selected row in the main table
22 detailCard: string | null
23 now: number
24 onLayout?: (info: { visibleRows: number }) => void // told how many rows the selectable table shows, so a page key can jump that far
25}
26
27export const rangeLabel = (days: number | null): string => (days == null ? 'all time' : days === 1 ? 'today' : `last ${days} days`)
28export const rangeShort = (days: number | null): string => (days == null ? 'ALL' : days === 1 ? 'TODAY' : `${days}D`)
29
30const VIEW_LABEL: Record<SView, string> = {
31 overview: 'OVERVIEW', card: 'BY CARD', day: 'BY DAY', week: 'BY WEEK', month: 'BY MONTH', model: 'BY MODEL', skill: 'BY SKILL',
32 tool: 'BY TOOL', agent: 'BY AGENT', project: 'BY PROJECT', session: 'BY SESSION', detail: 'CARD',
33}
34
35// The rows the main (selectable) table shows for this state.
36export function mainAggs(st: SumState): Agg[] {
37 const rows = filterRows(st.rows, { days: st.days, card: st.view === 'detail' ? st.detailCard : null }, new Date(st.now))
38 const dim: Dim = st.view === 'overview' || st.view === 'card' ? 'card' : st.view === 'detail' ? 'session' : (st.view as Dim)
39 return group(rows, dim)
40}
41
42// The dimension a view counts: its tables use that dimension's own columns.
43export const viewDim = (v: SView): Dim => (v === 'overview' || v === 'card' ? 'card' : v === 'detail' ? 'session' : (v as Dim))
44
45// ---------- drawing helpers ----------
46const clip = (s: string, w: number) => (s.length > w ? s.slice(0, Math.max(0, w - 1)) + '…' : s.padEnd(w))
47const right = (s: string, w: number) => s.padStart(w)
48
49function highlightRow(cv: Canvas, x0: number, x1: number, y: number) {
50 for (let x = x0; x <= x1; x++) {
51 const i = y * cv.cols + x
52 cv.bg[i] = C.cyan
53 const ch = cv.ch[i]
54 if (ch !== 0x2588 && ch !== 0x2591) cv.fg[i] = C.ink // keep the bar fill visible
55 }
56}
57
58// A framed table. `sel` highlights a row and the window follows it.
59function drawTable(cv: Canvas, x: number, y: number, w: number, h: number, title: string, aggs: Agg[], keyLabel: string, dim: Dim, sel: number | null, skip: string[] = [], onVisible?: (n: number) => void) {
60 if (w < 16 || h < 4) return
61 const visible = h - 3 // frame (2) + header (1)
62 if (onVisible) onVisible(visible)
63 const total = aggs.length
64 let start = 0
65 if (sel != null && total > visible) start = clamp(sel - Math.floor(visible / 2), 0, total - visible)
66 const end = Math.min(total, start + visible)
67 cv.frame(x, y, w, h, {
68 color: C.cyan, label: title, labelColor: C.hot,
69 tab: total > visible ? `${start + 1}-${end} of ${total}` : undefined, tabColor: C.dim,
70 })
71 const ix = x + 2
72 const innerW = w - 4
73 // this dimension's own columns, in priority order, while the key column still has 12+ characters; then the bar
74 const cols: ColDef[] = []
75 let used = 0
76 for (const c of colsFor(dim)) {
77 if (!c.pane || skip.includes(c.id)) continue
78 if (innerW - (used + c.w + 1) >= 12) { cols.push(c); used += c.w + 1 }
79 }
80 const BAR_W = 6
81 const hasBar = innerW - (used + BAR_W + 1) >= 12
82 if (hasBar) used += BAR_W + 1
83 const kw = innerW - used
84 let cx = ix + kw + 1
85 cv.text(ix, y + 1, clip(keyLabel, kw), C.dim)
86 for (const c of cols) { cv.text(cx, y + 1, right(c.head, c.w), C.dim); cx += c.w + 1 }
87
88 if (total === 0) { cv.text(ix, y + 2, 'nothing recorded for this range', C.dim, DEFAULT, ix + innerW - 1); return }
89 const maxBar = Math.max(1e-9, ...aggs.map((a) => barValue(a, dim)))
90 for (let i = start; i < end; i++) {
91 const a = aggs[i]
92 const yy = y + 2 + (i - start)
93 const noCard = a.key === '(no card)'
94 cv.text(ix, yy, clip(a.key, kw), noCard ? C.dim : C.hot)
95 let px = ix + kw + 1
96 for (const c of cols) {
97 const color = c.id === 'cost' ? C.hot : c.id === 'fail' && a.fail > 0 ? C.alert : c.id === 'last' ? C.dim : C.text
98 cv.text(px, yy, right(c.get(a), c.w), color)
99 px += c.w + 1
100 }
101 if (hasBar) cv.bar(px, yy, BAR_W, barValue(a, dim) / maxBar, C.cyan, C.deep, C.magenta)
102 if (sel === i) highlightRow(cv, ix - 1, ix + innerW, yy)
103 }
104}
105
106// ---------- the pane ----------
107export function renderSummaryPane(st: SumState, cols: number, rows: number): Canvas {
108 const H = Math.max(10, rows)
109 const cv = new Canvas(cols, H)
110 const body = H - 1
111 const detail = st.view === 'detail'
112
113 // headline panel
114 const filtered = filterRows(st.rows, { days: st.days, card: detail ? st.detailCard : null }, new Date(st.now))
115 const sum = (f: (r: Row) => number) => filtered.reduce((s, r) => s + f(r), 0)
116 const cost = sum((r) => r.costUsd)
117 const out = sum((r) => r.out)
118 const inn = sum((r) => r.in)
119 const cr = sum((r) => r.cacheRead)
120 const cw = sum((r) => r.cacheWrite)
121 const sessions = new Set(filtered.map((r) => r.session)).size
122 const headTitle = detail ? `CARD ${st.detailCard}` : 'GRID SUMMARY'
123 cv.frame(0, 0, cols, 4, { color: C.hot, label: headTitle, labelColor: C.white, tab: `${rangeShort(st.days)} · ${VIEW_LABEL[st.view]}`, tabColor: C.cyan, tabInset: 6 })
124 const stat = (x: number, y: number, label: string, value: string, color: number = C.hot) => {
125 const e = cv.text(x, y, label + ' ', C.dim)
126 return cv.text(e, y, value, color) + 2
127 }
128 if (!st.loaded) {
129 cv.text(2, 1, st.loading ? 'reading every session in ~/.claude/claudegrid …' : 'press u to load', C.dim)
130 } else if (filtered.length === 0) {
131 cv.text(2, 1, `no turns recorded for ${detail ? st.detailCard + ' in ' : ''}${rangeLabel(st.days)}`, C.dim)
132 cv.text(2, 2, st.note.includes('loose') ? st.note : 'press r for a wider range, or b to go back', st.note.includes('loose') ? C.alert : C.dim, DEFAULT, cols - 3)
133 } else {
134 let x = 2
135 x = stat(x, 1, 'SESSIONS', String(sessions))
136 x = stat(x, 1, 'TURNS', String(filtered.length))
137 x = stat(x, 1, 'COST', fmtUsd(cost))
138 x = stat(x, 1, 'CLAUDE TIME', fmtDur(sum((r) => r.durationMs)))
139 x = 2
140 x = stat(x, 2, 'OUT', fmtTokens(out))
141 x = stat(x, 2, 'IN', fmtTokens(inn))
142 x = stat(x, 2, 'CACHE R', fmtTokens(cr), C.text)
143 x = stat(x, 2, 'W', fmtTokens(cw), C.text)
144 x = stat(x, 2, 'HIT', Math.round(cacheHit(inn, cr, cw) * 100) + '%')
145 x = stat(x, 2, 'TOOLS', String(sum((r) => r.toolCalls)))
146 }
147
148 // main area
149 const top = 4
150 const areaH = body - top
151 const aggs = st.loaded ? mainAggs(st) : []
152 const sel = aggs.length ? clamp(st.sel, 0, aggs.length - 1) : null
153 const dim = viewDim(st.view)
154 const keyLabel = detail ? 'SESSION' : st.view === 'overview' || st.view === 'card' ? 'CARD' : VIEW_LABEL[st.view].replace('BY ', '')
155 const mainTitle = detail ? 'SESSIONS' : st.view === 'overview' ? 'BY CARD (↑↓ select, Enter opens)' : st.view === 'skill' || st.view === 'agent' ? `${VIEW_LABEL[st.view]} (* whole turns that used it)` : VIEW_LABEL[st.view]
156 const multi = (st.view === 'overview' || detail) && st.loaded && filtered.length > 0 && areaH >= 14
157 const skip: string[] = detail ? ['sess'] : [] // every row in a list of sessions is one session
158 const vis = (n: number) => st.onLayout?.({ visibleRows: n })
159
160 // Panels stacked in a column; each gets the rows it wants, and later ones are dropped if there is no room.
161 const stack = (x: number, y0: number, w: number, h: number, defs: [string, Dim, string, number][]) => {
162 const groups = defs.map(([title, dim, label, n]) => ({ title, dim, label, list: group(filtered, dim).slice(0, n) })).filter((g) => g.list.length > 0)
163 let remaining = h
164 let y = y0
165 groups.forEach((g, i) => {
166 const left = groups.length - i - 1
167 const ph = Math.min(g.list.length + 3, remaining - left * 4)
168 if (ph >= 4) { drawTable(cv, x, y, w, ph, g.title, g.list, g.label, g.dim, null); y += ph; remaining -= ph }
169 })
170 }
171
172 if (multi) {
173 const mainRows = detail ? aggs.map((a) => ({ ...a, key: a.key.slice(0, 8) })) : aggs
174 const side: [string, Dim, string, number][] = [
175 ['BY MODEL', 'model', 'MODEL', 4], [detail ? 'SKILLS' : 'TOP SKILLS', 'skill', 'SKILL', 4],
176 [detail ? 'AGENTS' : 'TOP AGENTS', 'agent', 'AGENT', 3], [detail ? 'TOOLS' : 'TOP TOOLS', 'tool', 'TOOL', 5],
177 ]
178 if (cols >= 76) {
179 // two columns: the main table with the days under it, and models, skills, agents and tools beside
180 const leftW = Math.floor(cols * 0.55)
181 const mainH = clamp(aggs.length + 3, 6, Math.floor(areaH * 0.55))
182 drawTable(cv, 0, top, leftW, mainH, mainTitle, mainRows, keyLabel, dim, sel, skip, vis)
183 const days = group(filtered, 'day').slice(0, 14)
184 const dayH = Math.min(days.length + 3, areaH - mainH)
185 if (dayH >= 4) drawTable(cv, 0, top + mainH, leftW, dayH, 'BY DAY', days, 'DAY', 'day', null)
186 stack(leftW, top, cols - leftW, areaH, side)
187 } else {
188 // a narrow pane: one column, everything stacked under the main table
189 const mainH = clamp(aggs.length + 3, 6, Math.floor(areaH * 0.35))
190 drawTable(cv, 0, top, cols, mainH, mainTitle, mainRows, keyLabel, dim, sel, skip, vis)
191 stack(0, top + mainH, cols, areaH - mainH, [['BY DAY', 'day', 'DAY', 7], ...side])
192 }
193 } else {
194 drawTable(cv, 0, top, cols, areaH, mainTitle, aggs, keyLabel, dim, sel, skip, vis)
195 }
196
197 // status strip, like the other tabs
198 const bar = ` 3 [summary] ${rangeLabel(st.days)} ${VIEW_LABEL[st.view].toLowerCase()} ${st.loaded ? st.rows.length + ' turns on file' : 'loading'}`
199 const note = st.loaded ? ` ${st.note} ` : ''
200 for (let x = 0; x < cols; x++) cv.set(x, H - 1, 32, C.ink, lerp3(C.cyan, C.blue, C.magenta, x / Math.max(1, cols - 1)))
201 cv.text(0, H - 1, bar, C.ink)
202 if (note.length < cols - bar.length - 2) cv.text(cols - note.length, H - 1, note, C.ink)
203 return cv
204}
205hooks/sysmon.ts 188 lines1// Parsers for the SYSTEM tab. Text in, numbers out, no Claude Code APIs: the mod collects the raw text (from `vm_stat`, `top`,
2// `sysctl`, `pmset`, `ps` on macOS; /proc and /sys on Linux) and these functions turn it into a Sample.
3
4export type Platform = 'darwin' | 'linux' | 'other'
5
6export interface Mem {
7 total: number // bytes
8 used: number
9 cached: number
10 free: number
11 app?: number // macOS: app memory, wired and compressed are what Activity Monitor adds up to "Memory Used"
12 wired?: number
13 compressed?: number
14}
15export interface CpuUse { total: number; user: number; sys: number } // percent, 0 to 100
16export interface Proc { pid: number; cpu: number; rssKb: number; name: string }
17export interface Thermal { state: 'nominal' | 'throttled' | 'unknown'; limit?: number } // macOS thermal pressure, not a temperature
18export type Pressure = 'normal' | 'warn' | 'critical'
19
20export interface Sample {
21 at: number
22 platform: Platform
23 cores: number | null
24 cpu: CpuUse | null
25 perCore: number[] | null
26 load: [number, number, number] | null
27 mem: Mem | null
28 swap: { total: number; used: number } | null
29 pressure: Pressure | null
30 thermal: Thermal | null
31 procs: Proc[]
32 notes: string[] // what could not be read, and why
33}
34
35const UNIT: Record<string, number> = { K: 1024, M: 1024 ** 2, G: 1024 ** 3, T: 1024 ** 4 }
36
37// ---------- macOS ----------
38
39// vm_stat, with the machine's total memory. Follows Activity Monitor: Memory Used = App Memory + Wired + Compressed,
40// where App Memory is anonymous pages less purgeable ones, and "cached files" are file-backed plus purgeable pages.
41export function parseVmStat(text: string, totalBytes: number): Mem | null {
42 const pageSize = Number(/page size of (\d+) bytes/.exec(text)?.[1] ?? 4096)
43 const get = (label: string): number | null => {
44 const m = new RegExp(`^"?${label}"?:\\s+(\\d+)`, 'mi').exec(text)
45 return m ? Number(m[1]) : null
46 }
47 const wired = get('Pages wired down')
48 if (wired == null) return null
49 const free = get('Pages free') ?? 0
50 const spec = get('Pages speculative') ?? 0
51 const active = get('Pages active') ?? 0
52 const inactive = get('Pages inactive') ?? 0
53 const purgeable = get('Pages purgeable') ?? 0
54 const anon = get('Anonymous pages')
55 const file = get('File-backed pages')
56 const comp = get('Pages occupied by compressor') ?? 0
57 const P = (n: number) => n * pageSize
58 const app = anon != null ? Math.max(0, P(anon - purgeable)) : P(active)
59 const total = totalBytes > 0 ? totalBytes : P(free + spec + active + inactive + wired + comp)
60 const used = Math.min(total, app + P(wired) + P(comp))
61 const cached = Math.min(total - used, file != null ? P(file + purgeable) : P(inactive + purgeable))
62 return { total, used, cached, free: Math.max(0, total - used - cached), app, wired: P(wired), compressed: P(comp) }
63}
64
65// The last "CPU usage:" line of `top -l 2` (the first sample is an average since boot, the second is live).
66export function parseTopCpu(text: string): CpuUse | null {
67 const all = [...text.matchAll(/CPU usage:\s*([\d.]+)%\s*user,\s*([\d.]+)%\s*sys,\s*([\d.]+)%\s*idle/g)]
68 const m = all[all.length - 1]
69 if (!m) return null
70 const user = Number(m[1])
71 const sys = Number(m[2])
72 const idle = Number(m[3])
73 return { total: clamp(100 - idle, 0, 100), user, sys }
74}
75
76// "total = 3072.00M used = 1450.25M free = 1621.75M (encrypted)"
77export function parseSwapUsage(text: string): { total: number; used: number } | null {
78 const m = /total\s*=\s*([\d.]+)([KMGT])\s+used\s*=\s*([\d.]+)([KMGT])/.exec(text)
79 return m ? { total: Number(m[1]) * UNIT[m[2]], used: Number(m[3]) * UNIT[m[4]] } : null
80}
81
82// kern.memorystatus_vm_pressure_level: 1 normal, 2 warning, 4 critical
83export function parsePressure(text: string): Pressure | null {
84 const n = Number(text.trim())
85 return n === 1 ? 'normal' : n === 2 ? 'warn' : n === 4 ? 'critical' : null
86}
87
88// `pmset -g therm` lists "CPU_Speed_Limit = 70" only when the system has been slowed to stay cool.
89export function parsePmsetTherm(text: string): Thermal {
90 if (!text.trim()) return { state: 'unknown' }
91 const m = /CPU_Speed_Limit\s*=\s*(\d+)/.exec(text)
92 if (m && Number(m[1]) < 100) return { state: 'throttled', limit: Number(m[1]) }
93 return { state: 'nominal' }
94}
95
96// ---------- Linux ----------
97
98export function parseMeminfo(text: string): { mem: Mem; swap: { total: number; used: number } } | null {
99 const kb = (key: string): number | null => {
100 const m = new RegExp(`^${key}:\\s+(\\d+)`, 'mi').exec(text)
101 return m ? Number(m[1]) * 1024 : null
102 }
103 const total = kb('MemTotal')
104 if (total == null) return null
105 const free = kb('MemFree') ?? 0
106 const buffers = kb('Buffers') ?? 0
107 const cachedFiles = kb('Cached') ?? 0
108 const available = kb('MemAvailable') ?? free + buffers + cachedFiles
109 const used = Math.max(0, total - available)
110 const swapTotal = kb('SwapTotal') ?? 0
111 const swapFree = kb('SwapFree') ?? 0
112 return {
113 mem: { total, used: Math.min(used, total), cached: Math.min(buffers + cachedFiles, Math.max(0, total - used)), free },
114 swap: { total: swapTotal, used: Math.max(0, swapTotal - swapFree) },
115 }
116}
117
118export interface CpuTimes { name: string; busyUser: number; busySys: number; idle: number; total: number }
119
120// /proc/stat: one "cpu" line for the whole machine and one per core.
121export function parseProcStat(text: string): CpuTimes[] {
122 const out: CpuTimes[] = []
123 for (const line of text.split('\n')) {
124 const m = /^(cpu\d*)\s+(.+)$/.exec(line)
125 if (!m) continue
126 const [user, nice, sys, idle, iowait, irq, softirq, steal] = m[2].trim().split(/\s+/).map(Number)
127 if (![user, nice, sys, idle].every(Number.isFinite)) continue
128 const idleAll = idle + (iowait || 0)
129 const sysAll = sys + (irq || 0) + (softirq || 0)
130 out.push({ name: m[1], busyUser: user + nice, busySys: sysAll, idle: idleAll, total: user + nice + sysAll + idleAll + (steal || 0) })
131 }
132 return out
133}
134
135// Utilisation between two reads of the same counter.
136export function cpuBetween(prev: CpuTimes, cur: CpuTimes): CpuUse | null {
137 const dt = cur.total - prev.total
138 if (dt <= 0) return null
139 const idle = cur.idle - prev.idle
140 return {
141 total: clamp(100 * (1 - idle / dt), 0, 100),
142 user: clamp((100 * (cur.busyUser - prev.busyUser)) / dt, 0, 100),
143 sys: clamp((100 * (cur.busySys - prev.busySys)) / dt, 0, 100),
144 }
145}
146
147// "0.12 0.20 0.18 1/234 5678" (Linux) or "{ 1.78 1.92 2.01 }" (macOS)
148export function parseLoad(text: string): [number, number, number] | null {
149 const n = [...text.matchAll(/\d+\.\d+/g)].map((m) => Number(m[0]))
150 return n.length >= 3 ? [n[0], n[1], n[2]] : null
151}
152
153// ---------- both ----------
154
155// `ps` with pid, %cpu, rss (KiB) and command, one process a line.
156export function parsePs(text: string): Proc[] {
157 const out: Proc[] = []
158 for (const line of text.split('\n')) {
159 const m = /^\s*(\d+)\s+([\d.]+)\s+(\d+)\s+(.*\S)\s*$/.exec(line)
160 if (!m) continue
161 const name = m[4].split('/').filter(Boolean).pop() ?? m[4]
162 out.push({ pid: Number(m[1]), cpu: Number(m[2]), rssKb: Number(m[3]), name })
163 }
164 return out
165}
166
167export const topByCpu = (p: Proc[], n: number): Proc[] => [...p].sort((a, b) => b.cpu - a.cpu).slice(0, n)
168export const topByMem = (p: Proc[], n: number): Proc[] => [...p].sort((a, b) => b.rssKb - a.rssKb).slice(0, n)
169
170export function fmtBytes(n: number): string {
171 if (n < 1024 ** 2) return `${Math.round(n / 1024)}K`
172 if (n < 1024 ** 3) return `${Math.round(n / 1024 ** 2)}M`
173 const g = n / 1024 ** 3
174 return `${g < 10 ? g.toFixed(1) : Math.round(g)}G`
175}
176
177const SPARK = '▁▂▃▄▅▆▇█'
178// A line chart in one row of block glyphs, newest on the right.
179export function spark(values: number[], width: number, max = 100): string {
180 const v = values.slice(-width)
181 const s = v.map((x) => SPARK[clamp(Math.round((x / max) * 7), 0, 7)]).join('')
182 return s.padStart(width, ' ')
183}
184
185function clamp(n: number, lo: number, hi: number): number {
186 return Math.min(hi, Math.max(lo, n))
187}
188hooks/sysview.ts 291 lines1// The SYSTEM tab: CPU, memory and the busiest processes, drawn like the other tabs.
2// Pure (state in, Canvas out) so it can be tested and previewed without Claude Code.
3
4import { Canvas, C, DEFAULT, lerp3 } from './canvas.ts'
5import type { Sample } from './sysmon.ts'
6import { fmtBytes, spark, topByCpu, topByMem } from './sysmon.ts'
7import { clamp } from './lib.ts'
8
9export interface SysState {
10 sample: Sample | null
11 cpuHist: number[]
12 memHist: number[]
13 loading: boolean
14 error: string
15 now: number
16 everyMs: number
17}
18
19// Primary colour while there is room, then yellow, then the alert colour.
20const heat = (frac: number): number => (frac >= 0.85 ? C.alert : frac >= 0.65 ? C.yellow : C.cyan)
21const pct = (n: number) => `${Math.round(n)}%`
22const clip = (s: string, w: number) => (s.length > w ? s.slice(0, Math.max(0, w - 1)) + '…' : s)
23
24// Break text into lines of at most `w` characters, on spaces.
25function wrap(text: string, w: number): string[] {
26 const out: string[] = []
27 let line = ''
28 for (const word of text.split(/\s+/).filter(Boolean)) {
29 if (line && (line + ' ' + word).length > w) { out.push(line); line = word }
30 else line = line ? line + ' ' + word : word
31 }
32 if (line) out.push(line)
33 return out
34}
35
36// ▕█████░░░░▏ with a fill colour that follows the load.
37function meter(cv: Canvas, x: number, y: number, w: number, frac: number): void {
38 cv.bar(x, y, w, frac, heat(frac), C.deep, frac < 0.65 ? C.magenta : undefined)
39}
40
41// A stacked bar: each part gets its share of the width; what is left over is the empty track.
42function stacked(cv: Canvas, x: number, y: number, w: number, parts: { frac: number; color: number }[]): void {
43 const inner = Math.max(1, w - 2)
44 cv.set(x, y, '▕'.codePointAt(0)!, C.dim)
45 let at = 0
46 for (const p of parts) {
47 const n = Math.round(clamp(p.frac, 0, 1) * inner)
48 for (let i = 0; i < n && at < inner; i++, at++) cv.set(x + 1 + at, y, '█'.codePointAt(0)!, p.color)
49 }
50 for (; at < inner; at++) cv.set(x + 1 + at, y, '░'.codePointAt(0)!, C.deep)
51 cv.set(x + 1 + inner, y, '▏'.codePointAt(0)!, C.dim)
52}
53
54// A sparkline coloured by value.
55function sparkline(cv: Canvas, x: number, y: number, w: number, values: number[], max: number): void {
56 const s = spark(values, w, max)
57 const tail = values.slice(-w)
58 for (let i = 0; i < w; i++) {
59 const v = tail[i - (w - tail.length)]
60 if (v == null) continue
61 cv.set(x + i, y, s.codePointAt(i)!, heat(v / max))
62 }
63}
64
65// A history chart `h` rows tall: each column is one sample, filled from the bottom with block glyphs (eight levels a row).
66// With one row it is a sparkline.
67function areaChart(cv: Canvas, x: number, y: number, w: number, h: number, values: number[], max: number): void {
68 if (h <= 1) { sparkline(cv, x, y, w, values, max); return }
69 const LEVELS = ' ▁▂▃▄▅▆▇█'
70 const tail = values.slice(-w)
71 for (let i = 0; i < tail.length; i++) {
72 const col = x + w - tail.length + i
73 const eighths = clamp(Math.round((tail[i] / max) * h * 8), 0, h * 8)
74 const color = heat(tail[i] / max)
75 for (let r = 0; r < h; r++) {
76 const fromBottom = h - 1 - r
77 const level = clamp(eighths - fromBottom * 8, 0, 8)
78 if (level > 0) cv.set(col, y + r, LEVELS.codePointAt(level)!, color)
79 }
80 }
81}
82
83function label(cv: Canvas, x: number, y: number, text: string, color: number = C.dim): number {
84 return cv.text(x, y, text, color)
85}
86
87// ---------- panels ----------
88
89function cpuPanel(cv: Canvas, s: SysState, x: number, y: number, w: number, h: number): void {
90 if (w < 24 || h < 4) return
91 const smp = s.sample
92 cv.frame(x, y, w, h, { color: C.cyan, label: 'CPU', labelColor: C.hot })
93 const ix = x + 2
94 const iw = w - 4
95 if (!smp?.cpu) { label(cv, ix, y + 1, smp ? 'CPU use is not available here' : 'reading …'); return }
96 const c = smp.cpu
97 let e = label(cv, ix, y + 1, 'USED ')
98 const barW = Math.max(8, iw - 28) // leaves room for "39%" and "user 31 sys 8" after the bar
99 meter(cv, e, y + 1, barW, c.total / 100)
100 e = cv.text(e + barW + 1, y + 1, pct(c.total).padStart(4), heat(c.total / 100))
101 cv.text(e + 1, y + 1, `user ${Math.round(c.user)} sys ${Math.round(c.sys)}`, C.text, DEFAULT, ix + iw - 1)
102 // below the bar: a history chart on whatever rows are left, then the per-core bars (or the load average)
103 const inner = h - 2
104 const cell = 18 // "C00 ▕████▏ 88%" and a gap
105 const perRow = Math.max(1, Math.floor(iw / cell))
106 const coreRows = smp.perCore && smp.perCore.length > 0 ? Math.ceil(smp.perCore.length / perRow) : smp.load ? 1 : 0
107 const chartH = clamp(inner - 1 - coreRows, 0, 6)
108 if (chartH >= 1) {
109 label(cv, ix, y + 2, 'TREND')
110 areaChart(cv, ix + 6, y + 2, Math.max(4, iw - 6), chartH, s.cpuHist, 100)
111 }
112 const row = y + 2 + chartH
113 if (smp.perCore && smp.perCore.length > 0) {
114 smp.perCore.forEach((v, i) => {
115 const r = row + Math.floor(i / perRow)
116 if (r > y + h - 2) return
117 const cx = ix + (i % perRow) * cell
118 const l = cv.text(cx, r, `C${String(i).padStart(2, '0')} `, C.dim)
119 meter(cv, l, r, 6, v / 100)
120 cv.text(l + 7, r, pct(v).padStart(4), heat(v / 100))
121 })
122 } else if (smp.load && row <= y + h - 2) {
123 e = label(cv, ix, row, 'LOAD ')
124 e = cv.text(e, row, smp.load.map((n) => n.toFixed(2)).join(' '), C.text)
125 if (smp.cores) cv.text(e + 2, row, `${smp.cores} cores`, C.dim)
126 }
127}
128
129function memPanel(cv: Canvas, s: SysState, x: number, y: number, w: number, h: number): void {
130 if (w < 24 || h < 4) return
131 const smp = s.sample
132 cv.frame(x, y, w, h, { color: C.cyan, label: 'MEMORY', labelColor: C.hot })
133 const ix = x + 2
134 const iw = w - 4
135 if (!smp?.mem) { label(cv, ix, y + 1, smp ? 'memory is not available here' : 'reading …'); return }
136 const m = smp.mem
137 const frac = m.used / m.total
138 const barW = Math.max(8, iw - 14)
139 // segments: what is in use (split on macOS), cached files, then free
140 const parts = m.app != null && m.wired != null && m.compressed != null
141 ? [{ frac: m.app / m.total, color: C.cyan }, { frac: m.wired / m.total, color: C.blue }, { frac: m.compressed / m.total, color: C.magenta }, { frac: m.cached / m.total, color: C.dim }]
142 : [{ frac: m.used / m.total, color: heat(frac) }, { frac: m.cached / m.total, color: C.dim }]
143 stacked(cv, ix, y + 1, barW, parts)
144 cv.text(ix + barW + 1, y + 1, pct(frac * 100).padStart(4), heat(frac))
145 let row = y + 2
146 if (row <= y + h - 2) {
147 let e = label(cv, ix, row, 'USED ')
148 e = cv.text(e, row, `${fmtBytes(m.used)} of ${fmtBytes(m.total)}`, C.hot)
149 e = label(cv, e + 2, row, 'FREE ')
150 cv.text(e, row, fmtBytes(m.free), C.text, DEFAULT, ix + iw - 1)
151 row++
152 }
153 if (row <= y + h - 2 && m.app != null) {
154 let e = cv.text(ix, row, '█', C.cyan)
155 e = label(cv, e, row, ` APP ${fmtBytes(m.app)} `, C.text)
156 e = cv.text(e, row, '█', C.blue)
157 e = label(cv, e, row, ` WIRED ${fmtBytes(m.wired ?? 0)} `, C.text)
158 e = cv.text(e, row, '█', C.magenta)
159 cv.text(e, row, ` COMPRESSED ${fmtBytes(m.compressed ?? 0)}`, C.text, DEFAULT, ix + iw - 1)
160 row++
161 }
162 if (row <= y + h - 2) {
163 let e = label(cv, ix, row, 'SWAP ')
164 e = cv.text(e, row, smp.swap && smp.swap.total > 0 ? `${fmtBytes(smp.swap.used)} of ${fmtBytes(smp.swap.total)}` : 'none', C.text)
165 if (smp.pressure) {
166 e = label(cv, e + 2, row, 'PRESSURE ')
167 cv.text(e, row, smp.pressure, smp.pressure === 'normal' ? C.hot : smp.pressure === 'warn' ? C.yellow : C.alert)
168 }
169 row++
170 }
171 const chartH = clamp(y + h - 1 - row, 0, 6)
172 if (chartH >= 1) {
173 label(cv, ix, row, 'TREND')
174 areaChart(cv, ix + 6, row, Math.max(4, iw - 6), chartH, s.memHist, 100)
175 }
176}
177
178function procPanel(cv: Canvas, s: SysState, x: number, y: number, w: number, h: number): void {
179 if (w < 30 || h < 4) return
180 cv.frame(x, y, w, h, { color: C.cyan, label: 'PROCESSES', labelColor: C.hot })
181 const smp = s.sample
182 const ix = x + 2
183 const iw = w - 4
184 if (!smp || smp.procs.length === 0) { label(cv, ix, y + 1, smp ? 'process list is not available here' : 'reading …'); return }
185 const rows = h - 3
186 const side = iw >= 62 // two lists side by side, else one list after the other
187 const draw = (x0: number, width: number, title: string, list: typeof smp.procs, by: 'cpu' | 'mem', top: number, count: number) => {
188 cv.text(x0, top, title, C.dim, DEFAULT, x0 + width - 1)
189 const nameW = Math.max(6, width - 19)
190 list.slice(0, count).forEach((p, i) => {
191 const r = top + 1 + i
192 cv.text(x0, r, String(p.pid).padStart(6), C.dim)
193 cv.text(x0 + 7, r, `${p.cpu.toFixed(1)}%`.padStart(6), by === 'cpu' ? heat(p.cpu / 100) : C.text)
194 cv.text(x0 + 14, r, fmtBytes(p.rssKb * 1024).padStart(5), by === 'mem' ? C.hot : C.text)
195 cv.text(x0 + 20, r, clip(p.name, nameW), C.hot, DEFAULT, x0 + width - 1)
196 })
197 }
198 if (side) {
199 const half = Math.floor(iw / 2)
200 draw(ix, half - 1, ' PID CPU MEM NAME (top by CPU)', topByCpu(smp.procs, rows - 1), 'cpu', y + 1, rows - 1)
201 draw(ix + half, iw - half, ' PID CPU MEM NAME (top by memory)', topByMem(smp.procs, rows - 1).filter((p) => p.rssKb > 0), 'mem', y + 1, rows - 1)
202 } else {
203 const each = Math.max(1, Math.floor((rows - 2) / 2))
204 draw(ix, iw, ' PID CPU MEM NAME (top by CPU)', topByCpu(smp.procs, each), 'cpu', y + 1, each)
205 draw(ix, iw, ' PID CPU MEM NAME (top by memory)', topByMem(smp.procs, each).filter((p) => p.rssKb > 0), 'mem', y + 2 + each, each)
206 }
207}
208
209// ---------- the pane ----------
210export function renderSystemPane(s: SysState, cols: number, rows: number): Canvas {
211 const H = Math.max(12, rows)
212 const cv = new Canvas(cols, H)
213 const body = H - 1
214 const smp = s.sample
215 const platformName = smp ? (smp.platform === 'darwin' ? 'macOS' : smp.platform === 'linux' ? 'Linux' : 'this system') : 'system'
216
217 // headline
218 cv.frame(0, 0, cols, 4, { color: C.hot, label: 'SYSTEM', labelColor: C.white, tab: `${platformName} · every ${Math.round(s.everyMs / 1000)}s`, tabColor: C.cyan, tabInset: 6 })
219 if (!smp) {
220 cv.text(2, 1, s.error ? `could not read the system: ${s.error}` : 'reading the system …', s.error ? C.alert : C.dim, DEFAULT, cols - 3)
221 } else {
222 let x = 2
223 const stat = (name: string, value: string, color: number = C.hot) => {
224 const e = cv.text(x, 1, name + ' ', C.dim)
225 x = cv.text(e, 1, value, color, DEFAULT, cols - 3) + 2
226 }
227 if (smp.cpu) stat('CPU', pct(smp.cpu.total), heat(smp.cpu.total / 100))
228 if (smp.mem) stat('MEM', `${pct((smp.mem.used / smp.mem.total) * 100)} (${fmtBytes(smp.mem.used)} of ${fmtBytes(smp.mem.total)})`, heat(smp.mem.used / smp.mem.total))
229 if (smp.swap && smp.swap.total > 0) stat('SWAP', fmtBytes(smp.swap.used), C.text)
230 if (smp.load) stat('LOAD', smp.load.map((n) => n.toFixed(2)).join(' '), C.text)
231 x = 2
232 const cv2 = cv
233 const put = (name: string, value: string, color: number = C.hot) => { const e = cv2.text(x, 2, name + ' ', C.dim); x = cv2.text(e, 2, value, color, DEFAULT, cols - 3) + 2 }
234 if (smp.thermal) put('THERMAL', smp.thermal.state === 'throttled' ? `slowed to ${smp.thermal.limit}%` : smp.thermal.state, smp.thermal.state === 'throttled' ? C.alert : C.hot)
235 if (smp.cores) put('CORES', String(smp.cores), C.text)
236 if (smp.pressure) put('PRESSURE', smp.pressure, smp.pressure === 'normal' ? C.hot : smp.pressure === 'warn' ? C.yellow : C.alert)
237 }
238
239 // panels
240 const top = 4
241 const areaH = body - top
242 const wide = cols >= 100 && areaH >= 14
243 const perCoreRows = smp?.perCore ? Math.ceil(smp.perCore.length / Math.max(1, Math.floor((Math.floor(cols * 0.55) - 4) / 16))) : 0
244 if (wide) {
245 const leftW = Math.floor(cols * 0.55)
246 const cpuH = clamp(4 + Math.max(1, perCoreRows), 6, Math.floor(areaH * 0.5))
247 cpuPanel(cv, s, 0, top, leftW, cpuH)
248 memPanel(cv, s, 0, top + cpuH, leftW, Math.max(4, areaH - cpuH))
249 procPanel(cv, s, leftW, top, cols - leftW, areaH)
250 } else {
251 // one column: CPU, memory, processes, each as tall as it needs and the rest dropped when short
252 let y = top
253 let left = areaH
254 const place = (h: number, f: (y: number, h: number) => void) => { const hh = Math.min(h, left); if (hh >= 4) { f(y, hh); y += hh; left -= hh } }
255 place(smp?.perCore ? 5 : 5, (yy, hh) => cpuPanel(cv, s, 0, yy, cols, hh))
256 place(7, (yy, hh) => memPanel(cv, s, 0, yy, cols, hh))
257 place(Math.max(6, left), (yy, hh) => procPanel(cv, s, 0, yy, cols, hh))
258 }
259
260 // status strip, like the other tabs
261 const age = smp ? Math.max(0, Math.round((s.now - smp.at) / 1000)) : 0
262 const bar = ` 4 [system] ${platformName} ${smp ? `sampled ${age}s ago` : 'sampling'}${s.loading ? ' …' : ''}`
263 for (let x = 0; x < cols; x++) cv.set(x, H - 1, 32, C.ink, lerp3(C.cyan, C.blue, C.magenta, x / Math.max(1, cols - 1)))
264 cv.text(0, H - 1, bar, C.ink)
265 const right = 'read-only: nothing is saved'
266 if (cols > bar.length + right.length + 2) cv.text(cols - right.length - 1, H - 1, right, C.ink)
267 return cv
268}
269
270// A plain-text snapshot for the transcript (/grid system text).
271export function systemText(s: SysState): string {
272 const smp = s.sample
273 if (!smp) return 'No reading yet: open the SYSTEM tab (/grid system) and try again.'
274 const lines: string[] = [`SYSTEM · ${smp.platform}`]
275 if (smp.cpu) lines.push(`cpu ${pct(smp.cpu.total)} (user ${Math.round(smp.cpu.user)}, sys ${Math.round(smp.cpu.sys)})${smp.cores ? ` · ${smp.cores} cores` : ''}`)
276 if (smp.load) lines.push(`load ${smp.load.map((n) => n.toFixed(2)).join(' ')}`)
277 if (smp.mem) {
278 const m = smp.mem
279 lines.push(`memory ${fmtBytes(m.used)} of ${fmtBytes(m.total)} (${pct((m.used / m.total) * 100)}) · cached ${fmtBytes(m.cached)} · free ${fmtBytes(m.free)}`)
280 if (m.app != null) lines.push(` app ${fmtBytes(m.app)} · wired ${fmtBytes(m.wired ?? 0)} · compressed ${fmtBytes(m.compressed ?? 0)}`)
281 }
282 if (smp.swap) lines.push(`swap ${smp.swap.total > 0 ? `${fmtBytes(smp.swap.used)} of ${fmtBytes(smp.swap.total)}` : 'none'}${smp.pressure ? ` · pressure ${smp.pressure}` : ''}`)
283 if (smp.thermal) lines.push(`thermal ${smp.thermal.state === 'throttled' ? `slowed to ${smp.thermal.limit}%` : smp.thermal.state}`)
284 const cpuTop = topByCpu(smp.procs, 5)
285 if (cpuTop.length) lines.push('top cpu ' + cpuTop.map((p) => `${p.name} ${p.cpu.toFixed(0)}%`).join(' · '))
286 const memTop = topByMem(smp.procs, 5)
287 if (memTop.length) lines.push('top mem ' + memTop.map((p) => `${p.name} ${fmtBytes(p.rssKb * 1024)}`).join(' · '))
288 for (const n of smp.notes) lines.push('note ' + n)
289 return lines.join('\n')
290}
291