SLOPSHOPPER

claudegrid

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…

panebandguardcommandtoastprompt
v1.3.0MITupdated 2026-10-16jatinmishra/claudegrid
A shopper browsing a rack in a slop shop
Preview could not run: harness produced no result (20 | pUsage, parsePressure, parsePmsetTherm, parseTempText, parseMeminfo, parseProcStat, cpuBetween, parseLoad, pickLinuxTemp
README

claudegrid

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.

claudegrid in a live session

Needs Claude Code 2.1.287 or later. Terminal only for the drawings (it uses the Raster element); other surfaces get a text line.

Install

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.

What you get

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.

LEDGER: see exactly what Claude did

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.

The LEDGER console

SUMMARY: cost and usage across all sessions

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

The SUMMARY tab

SYSTEM: what your machine is doing

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.

The SYSTEM tab

GRID RUN: a light-cycle game for while Claude works

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.

GRID RUN

Alerts (toasts, once per window): limits at 80% and 90%, daily budget at 80% and 100%, each subagent finishing.

Commands

/grid help lists these. If another mod already owns /grid, this one registers /claudegrid, then /cgrid.

commanddoes
/gridopen the console
/grid textsession 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 cardsone Jira card across every session, or every card by cost
/grid systemthe 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 probehow 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.

Data

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.

Privacy

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.

Troubleshooting

/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.

Tuning

hooks/game.ts: LEVELS, HARD_FOCUS. hooks/canvas.ts: themes.

MIT licensed. Copyright (c) 2026 Jatin Mishra.

Disclaimer

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.

Source 9 files
hooks/register.ts 1292 lines
1// 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 = isSystem
hooks/lib.ts 145 lines
1// 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}
145
hooks/hud.ts 453 lines
1// 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}
453
hooks/canvas.ts 227 lines
1// 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 }
227
hooks/game.ts 255 lines
1// 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}
255
hooks/summary.ts 376 lines
1// 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 }
376
hooks/summaryview.ts 205 lines
1// 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}
205
hooks/sysmon.ts 188 lines
1// 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}
188
hooks/sysview.ts 291 lines
1// 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