Session dashboard: a live stats bar above the prompt and a /desk pane for context, cache, subagents, cost, the assumptions Claude is making and the work it…

A session dashboard for Claude Code. It puts a one-line stats bar above the prompt and adds a /desk pane with more detail.
Needs Claude Code v2.1.287 or later. It was built and tested on v2.1.289 in the terminal.
Download terminal-desk-mod.zip from https://www.graniteai.co/tools/terminal-desk and unzip it into your Claude Code skills folder, so this file exists:
~/.claude/skills/terminal-desk/.claude-plugin/plugin.json
On Windows that folder is C:\Users\<you>\.claude\skills\terminal-desk. Claude Code loads a plugin it finds there in every session.
Start a new session. The bar appears above the prompt, and /desk opens the pane.
To try it for one session without installing, run claude --plugin-dir followed by the path to the unzipped folder.
To remove it, delete the folder.
The bar shows, left to right: whether Claude is working, tokens used, cost, context used on a 0 to 100 scale with a marker at the current position, tool calls per minute, running subagents, turns, and errors. On the right it shows whether the prompt cache is hot or cold and what the next message would cost to send in each state.
On a narrow terminal the bar drops the less important figures instead of wrapping.
When you switch to a subagent's transcript, the bar switches to that subagent's status, run time, tool calls, tokens and model.
/desk opens a pane with six panels.
What Claude is assuming lists the last five judgment calls Claude reported, newest first, each with its reason and what it affects. Press the number next to an entry, or click its Reject button, to put a correction in your prompt box. Nothing is sent until you finish the sentence and press Enter. If Claude later overturns an assumption itself, the old entry is struck through. z clears the list.
Left undone lists work Claude put off. It is filled three ways: sentences in Claude's answer that defer something ("for now", "I did not run", "placeholder"), lines it writes into a file that mark unfinished work (TODO, a skipped test, "not implemented"), and a second, small model that reads your request against Claude's final report after any turn with five or more tool calls. Press the letter next to an entry to draft "You left this undone: ... Do it now." in your prompt box. x clears the list.
Where your context went is a stacked bar of what fills the context window (messages, system tools, skills, memory files and so on), with a legend and the free space left.
Prompt cache shows whether the cache is hot, how long until it goes cold, and the estimated cost of your next message in each state.
Subagents lists every subagent in the session with its status, type, model, run time and tool calls.
Cost and tokens shows session cost, tokens read and written, and the share served from cache.
| Command | What it does | | :- | :- | | /desk | Open the pane | | /desk hide | Hide the bar | | /desk show | Show the bar again | | /desk check off | Stop sending finished turns to the second model | | /desk check on | Turn that check back on (the default) | | /desk cache 5m | Estimate cache figures for a 5-minute cache | | /desk cache 1h | Estimate cache figures for a 1-hour cache (the default) |
The bar and the pane are drawn locally from numbers Claude Code already keeps. Drawing them makes no model calls and no network calls.
The assumptions panel is the one part that costs tokens. It adds a short instruction to the system prompt (roughly 200 tokens) and registers one tool, note_assumption, that Claude calls when it makes a judgment call you did not state. Each logged assumption is a small tool call, and when Claude logs one as a separate step, that is one extra request at the cached rate. On a large conversation that can be several cents each.
The Left undone panel's scanning is local and free. Its second-model check is one small request to Claude Haiku 4.5 after each turn that made five or more tool calls, sent with your request and the last part of Claude's answer. /desk check off stops it.
/desk cache 5m./desk cache 5m there.Mods are not sandboxed. This one reads session usage, tool call names and subagent activity, adds text to the system prompt, and registers a tool. It reads the text Claude writes into files to look for unfinished-work markers, and it sends your request and Claude's final answer to a second Claude model for the check described above. It does not write files or contact any other service. The whole mod is one file, hooks/register.tsx, so you can check that for yourself.
MIT
hooks/register.tsx 903 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AgentRow, Assumption, Slice, Stats, Todo, Undone } from '../types'
5
6const PANE = 'desk'
7const AMBER = '#ffb000'
8const GREEN = '#00d67a'
9const RED = '#ff4d4d'
10const CYAN = '#4dd2ff'
11const GREY = '#555555'
12const SLICE_COLORS = ['#4dd2ff', '#ffb000', '#c77dff', '#00d67a', '#ff7eb6', '#b59f00', '#ff9f1c', '#3a86ff']
13const NOTE = 'mcp__terminal-desk__note_assumption'
14const SHOWN_NOTES = 5
15
16// What the model is told, as a section of the system prompt.
17const GUIDE = [
18 '# Surfacing your assumptions',
19 'The user watches a live panel of the assumptions you make about what they want.',
20 `Whenever you make a judgment call the user did not state, and it changes what you do next, call the ${NOTE} tool once, before you act on it. If that tool is not loaded yet, load it by its exact name first.`,
21 'Log: the scope you settled on, which files or systems you took to be in or out of play, what you chose to keep or delete, a naming or structure choice in a refactor, which of two readings of the request you took, what you took "done" to mean.',
22 'Do not log: facts you verified, routine steps, or anything the user said outright.',
23 'One plain sentence per assumption. When a later finding overturns an earlier one, call the tool again with `replaces` set to that assumption\'s id.',
24 'A small task may have none; a large refactor has many.',
25].join('\n')
26const MINUTE = 60_000
27const HOUR = 60 * MINUTE
28const MAX_AGENTS = 12
29
30// Anthropic API list prices in dollars per million input tokens, and what a
31// cache read costs as a share of that (cached 2026-09-25). First match wins.
32const PRICES: { match: string; input: number; read: number }[] = [
33 { match: 'fable-5-1', input: 10, read: 0.025 },
34 { match: 'mythos-5-1', input: 10, read: 0.025 },
35 { match: 'fable', input: 10, read: 0.1 },
36 { match: 'mythos', input: 10, read: 0.1 },
37 { match: 'opus-5-5', input: 4, read: 0.05 },
38 { match: 'opus', input: 5, read: 0.1 },
39 { match: 'sonnet-5', input: 2, read: 0.1 },
40 { match: 'sonnet', input: 3, read: 0.1 },
41 { match: 'haiku', input: 1, read: 0.1 },
42]
43
44const EMPTY: Stats = {
45 openedAt: 0,
46 now: 0,
47 turns: 0,
48 tools: 0,
49 fails: 0,
50 freshTokens: 0,
51 cacheReadTokens: 0,
52 cacheWriteTokens: 0,
53 outTokens: 0,
54 costUsd: null,
55 ctxPercent: null,
56 ctxTokens: null,
57 ctxWindow: null,
58 slices: [],
59 stamps: [],
60 assumptions: [],
61 nextNote: 1,
62 agents: [],
63 lastMainAt: 0,
64 model: null,
65 ttlMs: HOUR,
66 needsMeasure: false,
67 undone: [],
68 nextUndone: 1,
69 lastPrompt: '',
70 turnTools: 0,
71 isChecking: true,
72 tickError: '',
73 limits: [],
74 todos: [],
75 goal: '',
76}
77
78const SHOWN_UNDONE = 5
79const LIMIT_NAMES: Record<string, string> = { five_hour: 'Session (5h)', seven_day: 'Weekly' }
80const resetsIn = (iso: string | undefined, now: number): string => {
81 if (iso === undefined) return ''
82 const mins = Math.max(0, Math.round((Date.parse(iso) - now) / MINUTE))
83 return mins >= 1440 ? `, resets ${Math.floor(mins / 1440)}d ${Math.floor((mins % 1440) / 60)}h` : `, resets ${Math.floor(mins / 60)}h ${mins % 60}m`
84}
85const UNDONE_KEYS = ['a', 'b', 'c', 'd', 'e']
86// A turn with fewer tool calls than this did too little work to be checked.
87const CHECK_MIN_TOOLS = 5
88const CHECKER = 'claude-haiku-4-5-20251001'
89const CHECK_SYSTEM = [
90 'You compare what a user asked a coding assistant to do with the assistant\'s final report.',
91 'List each thing the user clearly asked for that the report shows was not done, was put off, or was only partly done.',
92 'One per line, at most three, each under 20 words, each starting with a verb. No numbering, no commentary.',
93 'If nothing was left undone, reply with the single word NONE.',
94].join(' ')
95// How an answer words work it is putting off.
96const SAID = /\b(for now|follow[- ]up|out of scope|not yet|I (?:didn't|did not|haven't|have not|skipped|left)\b|(?:do|handle|add|fix|revisit|address|tackle) (?:that|this|it|them|those) later|in a later (?:pass|step|turn|change|PR)|still needs?|remains? to be|placeholder|stubbed|untested|not (?:verified|tested|implemented|wired up))/i
97// What put-off work looks like once it is written into a file.
98const WROTE = /\b(?:TODO|FIXME|XXX)\b|not implemented|NotImplemented|\bplaceholder\b|\.skip\(|\bx(?:it|describe)\(|@pytest\.mark\.skip/
99const WRITERS = ['Edit', 'Write', 'MultiEdit', 'NotebookEdit']
100
101// The sentences of an answer that put work off, code blocks left out.
102const deferrals = (answer: string): string[] =>
103 answer
104 .replace(/```[\s\S]*?```/g, ' ')
105 .split(/(?<=[.!?])\s+|\n+/)
106 .map(part => part.replace(/^[\s>*#-]+/, '').replace(/\*\*/g, '').trim())
107 // Words Claude is quoting or showing as code are not Claude putting work off.
108 .filter(part => part.length > 12 && SAID.test(part.replace(/"[^"]*"|`[^`]*`/g, ' ')))
109 .map(part => part.slice(0, 200))
110 .slice(0, 3)
111
112const recordUndone = async ($: EngineInterface, found: string[], source: Undone['source']): Promise<void> => {
113 if (found.length === 0) {
114 return
115 }
116
117 const at = await $.clock.now()
118 let added = 0
119
120 await update($, stats, raw => {
121 const s = whole(raw)
122 const fresh = [...new Set(found)].filter(text => !s.undone.some(one => one.text === text))
123 added = fresh.length
124
125 return {
126 ...s,
127 nextUndone: s.nextUndone + fresh.length,
128 undone: [
129 ...s.undone,
130 ...fresh.map((text, i): Undone => ({ id: s.nextUndone + i, text, source, at, status: 'open' })),
131 ].slice(-40),
132 }
133 })
134
135 if (added > 0) {
136 $.ui.toast(`Left undone: ${(found[0] ?? '').slice(0, 80)}`)
137 }
138}
139
140// A second, small model reads the request against the final report. It sees
141// the report and not the tool calls, so it finds what the report admits to.
142const check = async ($: EngineInterface, asked: string, answer: string): Promise<void> => {
143 const reply = await $.model.complete({
144 model: CHECKER,
145 system: CHECK_SYSTEM,
146 prompt: `REQUEST:\n${asked}\n\nFINAL REPORT:\n${answer.slice(-6000)}`,
147 maxTokens: 300,
148 timeoutMs: 30_000,
149 })
150
151 if (!reply.isAnswered) {
152 $.ui.log('terminal-desk checker: the model did not answer', { to: 'debug' })
153
154 return
155 }
156
157 const found = reply.text
158 .split('\n')
159 .map(row => row.replace(/^[\s\d.*-]+/, '').trim().slice(0, 200))
160 .filter(row => row !== '' && !/^none\.?$/i.test(row))
161 .slice(0, 3)
162
163 await recordUndone($, found, 'checker')
164}
165
166const BLANK_ROW: AgentRow = {
167 id: '',
168 label: '',
169 type: 'subagent',
170 model: null,
171 startedAt: 0,
172 endedAt: null,
173 lastSeenAt: 0,
174 isSpawned: false,
175 tools: 0,
176 tokensRead: 0,
177 tokensOut: 0,
178 hasFailed: false,
179}
180
181const stats = atom({ plugin: 'terminal-desk', key: 'stats' } as const, EMPTY)
182const isHidden = atom({ plugin: 'terminal-desk', key: 'isHidden' } as const, false)
183
184// The session's stored value may predate a field added since: fill the gaps.
185const whole = (s: Stats): Stats => ({
186 ...EMPTY,
187 ...s,
188 agents: (s.agents ?? []).map(row => ({ ...BLANK_ROW, ...row })),
189})
190
191const compact = (n: number): string => {
192 if (n >= 1_000_000) {
193 return `${(n / 1_000_000).toFixed(2)}M`
194 }
195
196 return n >= 1000 ? `${(n / 1000).toFixed(1)}K` : `${n}`
197}
198
199const elapsed = (ms: number): string => {
200 const seconds = Math.max(0, Math.floor(ms / 1000))
201 const mm = `${Math.floor(seconds / 60)}`.padStart(2, '0')
202
203 return `${mm}:${`${seconds % 60}`.padStart(2, '0')}`
204}
205
206const money = (usd: number | null): string => {
207 if (usd === null) {
208 return '--'
209 }
210
211 return usd > 0 && usd < 0.005 ? '<$0.01' : `$${usd.toFixed(2)}`
212}
213
214// Minutes and seconds left, so the countdown is seen to move.
215const minutes = (ms: number): string => {
216 const seconds = Math.ceil(ms / 1000)
217
218 return `${Math.floor(seconds / 60)}:${`${seconds % 60}`.padStart(2, '0')}`
219}
220
221// Tool calls in the minute before `now`: counted when drawn, so the rate
222// falls back to 0 once the session goes quiet.
223const perMinute = (s: Stats): number => s.stamps.filter(t => s.now - t <= MINUTE).length
224
225const tokensRead = (s: Stats): number => s.freshTokens + s.cacheReadTokens + s.cacheWriteTokens
226
227// A row nobody spawned in view of this mod (a fork of the engine's, an agent
228// older than this load) may never report an end, so silence ends it.
229const isRunning = (row: AgentRow, now: number): boolean =>
230 row.endedAt === null && (row.isSpawned || now - row.lastSeenAt < 2 * MINUTE)
231
232// The prompt cache: hot while the main loop's last request began inside the
233// cache's lifetime, when resending the conversation is a cheap cache read;
234// cold after it, when the whole window is written to the cache again.
235const cache = (s: Stats): { isWarm: boolean; leftMs: number; warmUsd: number | null; coldUsd: number | null } => {
236 const leftMs = s.lastMainAt === 0 ? 0 : Math.max(0, s.lastMainAt + s.ttlMs - s.now)
237 const price = PRICES.find(one => (s.model ?? '').includes(one.match))
238 const millions = (s.ctxTokens ?? 0) / 1_000_000
239 const write = s.ttlMs > 5 * MINUTE ? 2 : 1.25
240
241 return {
242 isWarm: leftMs > 0,
243 leftMs,
244 warmUsd: price === undefined || s.ctxTokens === null ? null : millions * price.input * price.read,
245 coldUsd: price === undefined || s.ctxTokens === null ? null : millions * price.input * write,
246 }
247}
248
249// Cells per slice by largest remainder, so the bar is exactly `width` wide.
250const share = (slices: Slice[], width: number): number[] => {
251 const total = slices.reduce((sum, one) => sum + one.tokens, 0)
252
253 if (total === 0) {
254 return slices.map(() => 0)
255 }
256
257 const exact = slices.map(one => (one.tokens / total) * width)
258 const cells = exact.map(Math.floor)
259 let spare = width - cells.reduce((sum, n) => sum + n, 0)
260 const order = exact.map((x, i) => ({ i, rest: x - Math.floor(x) })).sort((a, b) => b.rest - a.rest)
261
262 for (const { i } of order) {
263 if (spare <= 0) {
264 break
265 }
266
267 cells[i] = (cells[i] ?? 0) + 1
268 spare -= 1
269 }
270
271 return cells
272}
273
274const withAgent = (agents: AgentRow[], id: string, at: number, change: (row: AgentRow) => AgentRow): AgentRow[] => {
275 const known = agents.find(row => row.id === id) ?? {
276 ...BLANK_ROW,
277 id,
278 label: `agent ${id.slice(0, 6)}`,
279 startedAt: at,
280 }
281 const rows = [...agents.filter(row => row.id !== id), { ...change(known), lastSeenAt: at }]
282 // Over the cap, the oldest finished rows go; a running one never does.
283 const surplus = rows.filter(row => row.endedAt !== null).slice(0, Math.max(0, rows.length - MAX_AGENTS))
284
285 return rows.filter(row => !surplus.includes(row))
286}
287
288const measure = async ($: EngineInterface): Promise<void> => {
289 const { context, cost, rateLimits } = await $.session.usage({ breakdown: 'summary' })
290 // The session's goal: the first thing the person asked.
291 let goal = whole(await read($, stats)).goal
292 if (goal === '') {
293 const first = (await $.session.messages()).find(m => m.role === 'user' && `${m.text ?? ''}`.trim() !== '')
294 goal = first === undefined ? '' : `${first.text}`.replace(/\s+/g, ' ').trim().slice(0, 300)
295 }
296 const breakdown = context.breakdown
297 const slices: Slice[] = (breakdown?.categories ?? [])
298 .filter(row => row.tokens > 0)
299 .map(row => ({ name: row.name, tokens: row.tokens, kind: row.kind }))
300
301 // The breakdown measures against the window compaction works to, which is
302 // what its rows add up to; a window just compacted has no reading of its
303 // own until its next response, so nothing older is carried over it.
304 await update($, stats, raw => ({
305 ...whole(raw),
306 costUsd: cost?.usd ?? raw.costUsd,
307 ctxPercent: breakdown?.percentage ?? context.percent ?? null,
308 ctxTokens: context.tokens ?? breakdown?.totalTokens ?? null,
309 ctxWindow: breakdown?.rawMaxTokens ?? context.window,
310 slices,
311 limits: rateLimits.length > 0 ? rateLimits : raw.limits ?? [],
312 goal,
313 needsMeasure: false,
314 }))
315}
316
317// The model's own report of an assumption: stored, shown at once, and
318// answered with the id a later note names to replace it.
319const recordAssumption = async ($: EngineInterface, input: Record<string, unknown>, agentId: string | undefined): Promise<string | null> => {
320 const say = (key: string): string => `${input[key] ?? ''}`.replace(/\s+/g, ' ').trim().slice(0, 240)
321 const text = say('assumption')
322
323 if (text === '') {
324 return null
325 }
326
327 const at = await $.clock.now()
328 const replaces = Number(say('replaces').replace(/\D/g, '')) || null
329 let id = 0
330
331 await update($, stats, raw => {
332 const s = whole(raw)
333 id = s.nextNote
334 const added: Assumption = {
335 id,
336 text,
337 basis: say('basis'),
338 affects: say('affects'),
339 at,
340 agent: agentId ?? null,
341 status: 'open',
342 replacedBy: null,
343 }
344
345 return {
346 ...s,
347 openedAt: s.openedAt || at,
348 now: at,
349 nextNote: id + 1,
350 assumptions: [
351 ...s.assumptions.map(one =>
352 one.id === replaces && one.status === 'open' ? { ...one, status: 'replaced' as const, replacedBy: id } : one,
353 ),
354 added,
355 ].slice(-30),
356 }
357 })
358
359 $.ui.toast(`Assumed: ${text.slice(0, 90)}`)
360
361 return `Noted as A${id}. The user can see it.`
362}
363
364export const register: Register = on => {
365 on('session.start', async ($, e, next) => {
366 await $.command.register({
367 name: 'desk',
368 description: 'Open the session dashboard pane, or /desk hide and /desk show for the stats bar',
369 })
370
371 // Moves `now` on while something is still counting: a running subagent's
372 // clock, or tool calls that have yet to age out of the last minute.
373 const tick = async (): Promise<void> => {
374 const s = whole(await read($, stats))
375 const at = await $.clock.now()
376
377 // A compaction left the context reading to be taken again.
378 if (s.needsMeasure) {
379 await measure($)
380 }
381
382 const isCounting = s.agents.some(row => isRunning(row, s.now)) || perMinute(s) > 0
383 // The cache countdown shows seconds, so it moves on every tick until
384 // one tick past cold. A cold, quiet session writes nothing.
385 const isCooling = cache(s).isWarm
386
387 if (isCounting || isCooling || s.tickError !== '') {
388 await update($, stats, one => ({ ...whole(one), tickError: '', now: at }))
389 }
390 }
391 const failed = (where: string) => async (err: unknown): Promise<void> => {
392 $.ui.log(`terminal-desk ${where}: ${String(err)}`, { to: 'debug' })
393 await update($, stats, one => ({ ...whole(one), tickError: `${where}: ${String(err)}`.slice(0, 120) }))
394 }
395
396 // Started before anything that could fail, so the clock always runs.
397 $.clock.every(1000, () => {
398 tick().catch(failed('timer'))
399 })
400
401 await $.tool.register({
402 name: 'note_assumption',
403 description:
404 'Record one assumption you are making about what the user wants, before acting on it. The user sees it at once and can reject it. Use it for judgment calls the user did not state; not for verified facts or routine steps.',
405 inputSchema: {
406 type: 'object',
407 properties: {
408 assumption: { type: 'string', description: 'The assumption, as one plain sentence.' },
409 basis: { type: 'string', description: 'What led you to it, in a few words.' },
410 affects: { type: 'string', description: 'What it changes: the files, behaviour or scope that would differ if it is wrong.' },
411 replaces: { type: 'string', description: 'The id of an earlier assumption this one overturns, such as A3. Leave out otherwise.' },
412 },
413 required: ['assumption', 'basis', 'affects'],
414 additionalProperties: false,
415 },
416 })
417
418 return next(e)
419 })
420
421 on('command.run', { command: 'desk' }, async ($, e) => {
422 const word = `${e.args ?? ''}`.trim().toLowerCase()
423
424 if (word === 'close' || word === 'hide') {
425 await $.ui.close({ id: PANE })
426
427 return { text: 'Session dashboard closed. /desk opens it again.' }
428 }
429
430 if (word === 'cache 5m' || word === 'cache 1h') {
431 await update($, stats, s => ({ ...whole(s), ttlMs: word === 'cache 5m' ? 5 * MINUTE : HOUR }))
432
433 return { text: `Cache lifetime set to ${word.slice(6)}.` }
434 }
435
436 if (word === 'check on' || word === 'check off') {
437 await update($, stats, s => ({ ...whole(s), isChecking: word === 'check on' }))
438
439 return { text: `Second-model check of finished turns is ${word.slice(6)}.` }
440 }
441
442 await $.ui.open({ id: PANE, title: 'This session' })
443 await measure($)
444
445 return { text: 'Session dashboard opened.' }
446 })
447
448 on('agent.spawn', async ($, e, next) => {
449 const spawned = await next(e)
450
451 if (spawned.agentId !== undefined) {
452 const id = spawned.agentId
453 const at = await $.clock.now()
454 await update($, stats, raw => ({
455 ...whole(raw),
456 now: at,
457 agents: withAgent(whole(raw).agents, id, at, row => ({
458 ...row,
459 label: e.description,
460 type: e.subagentType,
461 model: spawned.model,
462 isSpawned: true,
463 })),
464 }))
465 }
466
467 return spawned
468 })
469
470 // What the person asked for, kept for the check at the end of the turn.
471 on('prompt.submit', async ($, e, next) => {
472 const kind: string = e.origin.kind
473
474 if (kind === 'composer' || kind === 'bridge' || kind === 'sdk') {
475 await update($, stats, raw => ({ ...whole(raw), lastPrompt: e.text.slice(0, 4000) }))
476 }
477
478 return next(e)
479 })
480
481 on('prompt.compose', async ($, e, next) => {
482 const composed = await next(e)
483
484 return {
485 sections: [...composed.sections, { id: 'terminal-desk:assumptions', text: GUIDE, scope: 'session' as const }],
486 }
487 })
488
489 // Each request of a loop. The cache's lifetime runs from the start of the
490 // main loop's last request; a subagent's request means it is at work, even
491 // one that had finished an earlier run.
492 on('turn.step', async function* ($, e, next) {
493 const at = await $.clock.now()
494 const agentId = e.agentId
495
496 await update($, stats, raw => {
497 const s = whole(raw)
498
499 return agentId === undefined
500 ? { ...s, now: at, lastMainAt: at }
501 : { ...s, now: at, agents: withAgent(s.agents, agentId, at, row => ({ ...row, endedAt: null })) }
502 })
503
504 return yield* next(e)
505 })
506
507 on('tool.call', async ($, e, next) => {
508 // The mod's own tool is answered here and is not counted as a tool call.
509 const called: string = e.tool
510 const calledBy = e.agentId
511
512 if (called === NOTE) {
513 const noted = await recordAssumption($, e as unknown as Record<string, unknown>, calledBy)
514
515 return (noted === null
516 ? { deny: 'note_assumption needs an `assumption`: one plain sentence.' }
517 : { result: noted }) as never
518 }
519
520 const ran = await next(e)
521
522 const at = await $.clock.now()
523 const agentId = e.agentId
524 const hasFailed = ran.deny !== undefined || ran.isError === true
525
526 await update($, stats, raw => {
527 const s = whole(raw)
528 const stamps = [...s.stamps, at].filter(t => at - t <= MINUTE)
529
530 return {
531 ...s,
532 openedAt: s.openedAt || at,
533 now: at,
534 tools: s.tools + 1,
535 fails: s.fails + (hasFailed ? 1 : 0),
536 stamps,
537 turnTools: s.turnTools + (agentId === undefined ? 1 : 0),
538 agents: agentId === undefined
539 ? s.agents
540 : withAgent(s.agents, agentId, at, row => ({ ...row, endedAt: null, tools: row.tools + 1 })),
541 }
542 })
543
544 // The session's task list, as the model keeps it.
545 if (!hasFailed && called === 'TodoWrite') {
546 const todos = ((ran.result as { newTodos?: { content: string; status: Todo['status'] }[] } | undefined)?.newTodos ?? [])
547 .map(one => ({ subject: one.content, status: one.status }))
548 await update($, stats, raw => ({ ...whole(raw), todos }))
549 } else if (!hasFailed && (called === 'TaskCreate' || called === 'TaskUpdate')) {
550 const list = await $.tool.call({ tool: 'TaskList' } as never).catch(() => null) as { result?: { tasks?: Todo[] } } | null
551 const tasks = list?.result?.tasks
552 if (tasks !== undefined) {
553 await update($, stats, raw => ({ ...whole(raw), todos: tasks.map(one => ({ subject: one.subject, status: one.status })) }))
554 }
555 }
556
557 // Put-off work written into a file: the line that says so, and where.
558 if (!hasFailed && WRITERS.includes(called)) {
559 const input = e as unknown as Record<string, unknown>
560 const wrote = `${input.new_string ?? input.content ?? input.new_source ?? ''}`
561 const row = wrote.split('\n').find(one => WROTE.test(one))
562
563 if (row !== undefined) {
564 const file = `${input.file_path ?? input.notebook_path ?? 'a file'}`.split(/[\\/]/).pop() ?? 'a file'
565 await recordUndone($, [`Wrote "${row.trim().slice(0, 90)}" into ${file}`], 'code')
566 }
567 }
568
569 return ran
570 })
571
572 on('turn.complete', async ($, e, next) => {
573 const at = await $.clock.now()
574 const agentId = e.agentId
575 const out = e.usage?.output_tokens ?? 0
576 // `input_tokens` is the uncached part alone; a cached prompt reports
577 // nearly all of its input under the two cache fields.
578 const fresh = e.usage?.input_tokens ?? 0
579 const cacheRead = e.usage?.cache_read_input_tokens ?? 0
580 const cacheWrite = e.usage?.cache_creation_input_tokens ?? 0
581
582 await update($, stats, raw => {
583 const s = whole(raw)
584
585 return {
586 ...s,
587 openedAt: s.openedAt || at,
588 now: at,
589 // Only where no request of the main loop was seen to start.
590 lastMainAt: agentId === undefined && s.lastMainAt === 0 ? at : s.lastMainAt,
591 model: agentId === undefined ? (e.usage?.model ?? s.model) : s.model,
592 turns: s.turns + (agentId === undefined ? 1 : 0),
593 freshTokens: s.freshTokens + fresh,
594 cacheReadTokens: s.cacheReadTokens + cacheRead,
595 cacheWriteTokens: s.cacheWriteTokens + cacheWrite,
596 outTokens: s.outTokens + out,
597 agents: agentId === undefined
598 ? s.agents
599 : withAgent(s.agents, agentId, at, row => ({
600 ...row,
601 endedAt: at,
602 model: e.usage?.model ?? row.model,
603 tokensRead: row.tokensRead + fresh + cacheRead + cacheWrite,
604 tokensOut: row.tokensOut + out,
605 hasFailed: e.reason === 'error' || e.reason === 'aborted',
606 })),
607 }
608 })
609
610 if (agentId === undefined) {
611 await measure($)
612
613 const turn = whole(await read($, stats))
614 await update($, stats, raw => ({ ...whole(raw), turnTools: 0 }))
615
616 if (e.reason === 'answer') {
617 await recordUndone($, deferrals(e.answer), 'said')
618
619 if (turn.isChecking && turn.turnTools >= CHECK_MIN_TOOLS && turn.lastPrompt !== '') {
620 // Not awaited: the turn ends now and the finding arrives when it does.
621 check($, turn.lastPrompt, e.answer).catch((err: unknown) =>
622 $.ui.log(`terminal-desk checker: ${String(err)}`, { to: 'debug' }),
623 )
624 }
625 }
626 }
627
628 return next(e)
629 })
630
631 // A compacted main conversation is a different window: measure it again
632 // rather than leave the figures of the one it replaced.
633 on('session.compact', async ($, e, next) => {
634 const done = await next(e)
635
636 if (e.agentId !== undefined || e.trigger === 'precompute' || done.messages === undefined) {
637 return done
638 }
639
640 // The engine swaps the conversation in after this hook returns, so a
641 // reading taken here is still the old one: show the compaction's own
642 // count now and measure again on the next tick.
643 const { tokensBefore, tokensAfter } = done
644
645 await update($, stats, raw => {
646 const s = whole(raw)
647 const sized = tokensAfter !== undefined && s.ctxWindow !== null && s.ctxWindow > 0
648
649 return {
650 ...s,
651 needsMeasure: true,
652 ctxTokens: tokensAfter ?? s.ctxTokens,
653 ctxPercent: sized ? (tokensAfter / (s.ctxWindow ?? 1)) * 100 : s.ctxPercent,
654 slices: sized ? [] : s.slices,
655 }
656 })
657
658 if (tokensBefore !== undefined && tokensAfter !== undefined) {
659 $.ui.toast(`Compacted: ${compact(tokensBefore)} to ${compact(tokensAfter)} tokens`)
660 }
661
662 return done
663 })
664
665 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
666 const { Box, Button, Text } = $.ui.resolve(e)
667 const s = whole(await read($, stats))
668 const kept = cache(s)
669 const inner = Math.max(16, e.props.bodyColumns - 4)
670 const viewedId = e.props.view?.agentId
671
672 const line = (label: string, value: string, color: string) => (
673 <Box flexDirection="row" justifyContent="space-between">
674 <Text color={AMBER} dimColor>{label}</Text>
675 <Text color={color} bold>{value}</Text>
676 </Box>
677 )
678 const title = (text: string, color: string = AMBER) => (
679 <Text backgroundColor={color} color="#000000" bold>{` ${text} `}</Text>
680 )
681 // "Reject" puts the correction in the prompt box for the person to finish
682 // and send; nothing reaches the model until they do.
683 const flag = async (id: number, text: string): Promise<void> => {
684 await $.prompt.fill({ text: `Assumption A${id} is wrong ("${text}"). Instead: `, mode: 'append' })
685 await update($, stats, raw => ({
686 ...whole(raw),
687 assumptions: whole(raw).assumptions.map(one => (one.id === id ? { ...one, status: 'flagged' as const } : one)),
688 }))
689 }
690 // "Do it now" drafts the instruction; the person sends it.
691 const push = async (id: number, text: string): Promise<void> => {
692 await $.prompt.fill({ text: `You left this undone: "${text}". Do it now.`, mode: 'append' })
693 await update($, stats, raw => ({
694 ...whole(raw),
695 undone: whole(raw).undone.map(one => (one.id === id ? { ...one, status: 'sent' as const } : one)),
696 }))
697 }
698 const clear = async (): Promise<void> => {
699 await update($, stats, raw => ({
700 ...whole(raw),
701 undone: whole(raw).undone.map(one => (one.status === 'open' ? { ...one, status: 'cleared' as const } : one)),
702 }))
703 }
704 const clearNotes = async (): Promise<void> => {
705 await update($, stats, raw => ({
706 ...whole(raw),
707 assumptions: whole(raw).assumptions.map(one => ({ ...one, status: 'cleared' as const })),
708 }))
709 }
710 const todo = s.undone.filter(one => one.status === 'open').slice(-SHOWN_UNDONE).reverse()
711 const sources = { said: 'Claude said', code: 'in a file', checker: 'second model' }
712 const notes = (list: Assumption[]) => list.filter(note => note.status !== 'cleared').slice(-SHOWN_NOTES).reverse().map((note, i) => {
713 const isOpen = note.status === 'open'
714 const who = note.agent === null ? '' : ` · ${s.agents.find(row => row.id === note.agent)?.label ?? 'subagent'}`
715
716 return (
717 <Box key={`note-${note.id}`} flexDirection="column" marginTop={i === 0 ? 0 : 1}>
718 <Box flexDirection="row" justifyContent="space-between">
719 <Text color={isOpen ? CYAN : GREY} bold>{`A${note.id} · ${elapsed(note.at - s.openedAt)}${who}`.slice(0, Math.max(8, inner - 12))}</Text>
720 {isOpen && <Button key={`wrong-${note.id}`} label={`${i + 1} Reject`} hotkey={`${i + 1}`} onPress={() => flag(note.id, note.text)} />}
721 {!isOpen && <Text color={note.status === 'flagged' ? RED : GREY}>{note.status === 'flagged' ? 'you flagged it' : `replaced by A${note.replacedBy ?? '?'}`}</Text>}
722 </Box>
723 <Text color={isOpen ? undefined : GREY} strikethrough={note.status === 'replaced'} wrap="wrap">{note.text}</Text>
724 {isOpen && note.basis !== '' && <Text dimColor wrap="wrap">{`because ${note.basis}`}</Text>}
725 {isOpen && note.affects !== '' && <Text dimColor wrap="wrap">{`affects ${note.affects}`}</Text>}
726 </Box>
727 )
728 })
729 const agentRows = (limit: number) => [...s.agents].sort((a, b) => b.startedAt - a.startedAt).slice(0, limit).map(row => {
730 const runs = isRunning(row, s.now)
731 const color = runs ? GREEN : row.hasFailed ? RED : CYAN
732 const pointer = row.id === viewedId ? '▶ ' : ''
733
734 return (
735 <Box flexDirection="column">
736 <Box flexDirection="row" justifyContent="space-between">
737 <Text color={color} bold>{`${pointer}${runs ? '●' : row.hasFailed ? '✗' : '✓'} ${row.label}`.slice(0, Math.max(8, inner - 7))}</Text>
738 <Text color={color}>{elapsed((runs ? s.now : (row.endedAt ?? row.lastSeenAt)) - row.startedAt)}</Text>
739 </Box>
740 <Text dimColor>
741 {` ${runs ? 'running' : row.hasFailed ? 'stopped' : 'done'} · ${row.type} · ${row.model ?? 'model unknown'} · ${row.tools} tool calls`.slice(0, inner)}
742 </Text>
743 </Box>
744 )
745 })
746
747 // A subagent's transcript is on screen: the pane is that agent's.
748 if (viewedId !== undefined) {
749 const row = s.agents.find(one => one.id === viewedId)
750 const runs = row !== undefined && isRunning(row, s.now)
751 const color = row === undefined ? AMBER : runs ? GREEN : row.hasFailed ? RED : CYAN
752 const own = s.assumptions.filter(note => note.agent === viewedId)
753
754 return (
755 <Box flexDirection="column">
756 <Box key="p-agent" flexDirection="column" borderStyle="single" borderColor={CYAN} paddingX={1}>
757 {title('Subagent in view', CYAN)}
758 <Text color={color} bold>{(row?.label ?? `agent ${viewedId.slice(0, 6)}`).slice(0, inner)}</Text>
759 {row === undefined && <Text dimColor>No activity seen yet</Text>}
760 {row !== undefined && line('Status', runs ? 'running' : row.hasFailed ? 'stopped' : 'done', color)}
761 {row !== undefined && line('Time', elapsed((runs ? s.now : (row.endedAt ?? row.lastSeenAt)) - row.startedAt), color)}
762 {row !== undefined && line('Type', row.type, CYAN)}
763 {row !== undefined && line('Model', row.model ?? 'unknown', CYAN)}
764 {row !== undefined && line('Tool calls', `${row.tools}`, CYAN)}
765 {row !== undefined && line('Tokens read', compact(row.tokensRead), CYAN)}
766 {row !== undefined && line('Tokens written', compact(row.tokensOut), GREEN)}
767 <Text dimColor>Tokens update each time it finishes a run</Text>
768 </Box>
769 <Box key="p-assumptions" flexDirection="column" borderStyle="single" borderColor={CYAN} paddingX={1}>
770 {title('What this subagent is assuming', CYAN)}
771 {own.length === 0 && <Text dimColor>None reported yet</Text>}
772 {notes(own)}
773 </Box>
774 <Box key="p-agents" flexDirection="column" borderStyle="single" borderColor={AMBER} paddingX={1}>
775 {title('All subagents')}
776 {agentRows(6)}
777 </Box>
778 <Text dimColor>Context and cache belong to the main session. Switch back to see them.</Text>
779 </Box>
780 )
781 }
782
783 const used = (s.ctxPercent ?? 0) / 100
784 const ctxColor = used > 0.8 ? RED : GREEN
785 const read_ = tokensRead(s)
786 const cached = read_ === 0 ? 0 : Math.round((s.cacheReadTokens / read_) * 100)
787
788 // What the window holds, biggest first, with the free space last; schemas
789 // loaded on demand sit outside the window and are listed, not drawn.
790 const held = s.slices.filter(one => one.kind === 'used').sort((a, b) => b.tokens - a.tokens)
791 const spare = s.slices.filter(one => one.kind === 'free' || one.kind === 'buffer')
792 const deferred = s.slices.filter(one => one.kind === 'deferred').reduce((sum, one) => sum + one.tokens, 0)
793 const drawn = [...held, ...spare]
794 const cells = share(drawn, inner)
795 const windowTokens = drawn.reduce((sum, one) => sum + one.tokens, 0)
796 const tint = (one: Slice): string =>
797 one.kind === 'used' ? (SLICE_COLORS[held.indexOf(one) % SLICE_COLORS.length] ?? CYAN) : GREY
798 const cell = (one: Slice): string => (one.kind === 'used' ? '█' : one.kind === 'buffer' ? '▒' : '░')
799 // Seven colours' worth of rows, then everything smaller as one, and the
800 // free space and compaction reserve always.
801 const rest = held.slice(7).reduce((sum, one) => sum + one.tokens, 0)
802 const legend = [...held.slice(0, 7), ...spare]
803
804 return (
805 <Box flexDirection="column">
806 <Box key="p-limits" flexDirection="column" borderStyle="single" borderColor={AMBER} paddingX={1}>
807 {title('Plan limits')}
808 {s.limits.length === 0 && <Text dimColor>Read after the next reply (subscription only)</Text>}
809 {s.limits.map(one => {
810 const name = LIMIT_NAMES[one.kind] ?? one.kind
811 const color = one.percentUsed >= 80 ? RED : one.percentUsed >= 60 ? AMBER : GREEN
812 const filled = Math.max(0, Math.min(inner, Math.round((one.percentUsed / 100) * inner)))
813 return (
814 <Box flexDirection="column">
815 {line(name, `${Math.round(one.percentUsed)}%${resetsIn(one.resetsAt, s.now)}`, color)}
816 <Text color={color}>{'█'.repeat(filled)}<Text color={GREY}>{'░'.repeat(inner - filled)}</Text></Text>
817 </Box>
818 )
819 })}
820 </Box>
821 <Box key="p-goal" flexDirection="column" borderStyle="single" borderColor={GREEN} paddingX={1}>
822 {title('This session', GREEN)}
823 <Text wrap="wrap">{s.goal === '' ? '—' : s.goal}</Text>
824 {s.todos.length > 0 && line('Tasks done', `${s.todos.filter(one => one.status === 'completed').length} of ${s.todos.length}`, GREEN)}
825 {s.todos.length === 0 && <Text dimColor>No task list yet</Text>}
826 {s.todos.map(one => (
827 <Text color={one.status === 'in_progress' ? AMBER : one.status === 'completed' ? GREY : undefined} strikethrough={one.status === 'completed'} wrap="wrap">
828 {`${one.status === 'completed' ? '✔' : one.status === 'in_progress' ? '▶' : '○'} ${one.subject}`}
829 </Text>
830 ))}
831 </Box>
832 <Box key="p-assumptions" flexDirection="column" borderStyle="single" borderColor={CYAN} paddingX={1}>
833 {title('What Claude is assuming', CYAN)}
834 {s.assumptions.every(note => note.status === 'cleared') && <Text dimColor>None reported yet. They appear here as Claude makes them.</Text>}
835 {notes(s.assumptions)}
836 {s.assumptions.some(note => note.status !== 'cleared') && <Button key="notes-clear" label="z Clear all" hotkey="z" onPress={clearNotes} />}
837 </Box>
838 <Box key="p-undone" flexDirection="column" borderStyle="single" borderColor={RED} paddingX={1}>
839 {title('Left undone', RED)}
840 {todo.length === 0 && <Text dimColor>Nothing flagged. Work Claude puts off shows up here.</Text>}
841 {todo.map((one, i) => (
842 <Box key={`undone-${one.id}`} flexDirection="column" marginTop={i === 0 ? 0 : 1}>
843 <Box flexDirection="row" justifyContent="space-between">
844 <Text color={RED} bold>{`U${one.id} · ${elapsed(one.at - s.openedAt)} · ${sources[one.source]}`.slice(0, Math.max(8, inner - 14))}</Text>
845 <Button key={`do-${one.id}`} label={`${UNDONE_KEYS[i] ?? ''} Do it now`} hotkey={UNDONE_KEYS[i] ?? 'a'} onPress={() => push(one.id, one.text)} />
846 </Box>
847 <Text wrap="wrap">{one.text}</Text>
848 </Box>
849 ))}
850 {todo.length > 0 && <Button key="undone-clear" label="x Clear all" hotkey="x" onPress={clear} />}
851 </Box>
852 <Box key="p-context" flexDirection="column" borderStyle="single" borderColor={AMBER} paddingX={1}>
853 {title('Where your context went')}
854 {line('Used', s.ctxPercent === null ? '--' : `${Math.round(s.ctxPercent)}% of ${compact(s.ctxWindow ?? 0)}`, ctxColor)}
855 {drawn.length === 0 && <Text dimColor>Measured after the next turn</Text>}
856 {drawn.length > 0 && (
857 <Box flexDirection="row">
858 {drawn.map((one, i) => (
859 <Text color={held.indexOf(one) >= 7 ? GREY : tint(one)}>{cell(one).repeat(cells[i] ?? 0)}</Text>
860 ))}
861 </Box>
862 )}
863 {legend.map(one => (
864 <Box flexDirection="row" justifyContent="space-between">
865 <Text color={tint(one)}>{`${cell(one) === '█' ? '■' : cell(one)} ${one.name}`.slice(0, Math.max(8, inner - 14))}</Text>
866 <Text color={tint(one)} bold>
867 {`${compact(one.tokens)} ${`${Math.round((one.tokens / Math.max(1, windowTokens)) * 100)}%`.padStart(4)}`}
868 </Text>
869 </Box>
870 ))}
871 {rest > 0 && <Text dimColor>{`■ ${held.length - 7} smaller items, ${compact(rest)}`}</Text>}
872 {deferred > 0 && <Text dimColor>{`+ ${compact(deferred)} of tools loaded only when needed`}</Text>}
873 </Box>
874 <Box key="p-cache" flexDirection="column" borderStyle="single" borderColor={AMBER} paddingX={1}>
875 {title('Prompt cache')}
876 {s.lastMainAt === 0 && <Text dimColor>Measured after the next turn</Text>}
877 {s.lastMainAt !== 0 && line('Right now', kept.isWarm ? `hot, ${minutes(kept.leftMs)} left` : 'cold', kept.isWarm ? RED : CYAN)}
878 {s.lastMainAt !== 0 && line('Next message while hot', money(kept.warmUsd), GREEN)}
879 {s.lastMainAt !== 0 && line('Next message once cold', money(kept.coldUsd), kept.isWarm ? AMBER : RED)}
880 <Text dimColor>
881 {`Estimate: resending the conversation at API list price, ${s.ttlMs > 5 * MINUTE ? '1h' : '5m'} cache`}
882 </Text>
883 </Box>
884 <Box key="p-agents" flexDirection="column" borderStyle="single" borderColor={AMBER} paddingX={1}>
885 {title('Subagents')}
886 {s.agents.length === 0 && <Text dimColor>None started yet</Text>}
887 {agentRows(6)}
888 </Box>
889 <Box key="p-cost" flexDirection="column" borderStyle="single" borderColor={AMBER} paddingX={1}>
890 {title('Cost and tokens')}
891 {line('Cost, whole session', s.costUsd === null ? '--' : `$${s.costUsd.toFixed(2)}`, AMBER)}
892 {line('Tokens read', compact(read_), CYAN)}
893 {line(' served from cache', `${cached}%`, CYAN)}
894 {line('Tokens written', compact(s.outTokens), GREEN)}
895 {line('Turns', `${s.turns}`, CYAN)}
896 <Text dimColor>Tokens and turns count from when this loaded</Text>
897 {s.tickError !== '' && <Text key="p-timer" color={RED}>{`Timer error, ${s.tickError}`}</Text>}
898 </Box>
899 </Box>
900 )
901 })
902}
903types/index.d.ts 79 lines1export type Assumption = {
2 id: number
3 text: string
4 basis: string
5 affects: string
6 at: number
7 agent: string | null
8 status: 'open' | 'replaced' | 'flagged' | 'cleared'
9 replacedBy: number | null
10}
11
12export type Undone = {
13 id: number
14 text: string
15 source: 'said' | 'code' | 'checker'
16 at: number
17 status: 'open' | 'sent' | 'cleared'
18}
19
20export type Slice ={ name: string; tokens: number; kind: 'used' | 'free' | 'buffer' | 'deferred' }
21
22export type AgentRow = {
23 id: string
24 label: string
25 type: string
26 model: string | null
27 startedAt: number
28 endedAt: number | null
29 lastSeenAt: number
30 isSpawned: boolean
31 tools: number
32 tokensRead: number
33 tokensOut: number
34 hasFailed: boolean
35}
36
37export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
38export type Todo = { subject: string; status: 'pending' | 'in_progress' | 'completed' }
39
40export type Stats = {
41 openedAt: number
42 now: number
43 turns: number
44 tools: number
45 fails: number
46 freshTokens: number
47 cacheReadTokens: number
48 cacheWriteTokens: number
49 outTokens: number
50 costUsd: number | null
51 ctxPercent: number | null
52 ctxTokens: number | null
53 ctxWindow: number | null
54 slices: Slice[]
55 stamps: number[]
56 assumptions: Assumption[]
57 nextNote: number
58 agents: AgentRow[]
59 lastMainAt: number
60 model: string | null
61 ttlMs: number
62 needsMeasure: boolean
63 undone: Undone[]
64 nextUndone: number
65 lastPrompt: string
66 turnTools: number
67 isChecking: boolean
68 tickError: string
69 limits: Limit[]
70 todos: Todo[]
71 goal: string
72}
73
74declare module 'claude-code' {
75 interface PluginState {
76 'terminal-desk': { stats: Stats; isHidden: boolean }
77 }
78}
79