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