SLOPSHOPPER

Terminal Desk

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…

newpaneguardcommandtoastprompt
v0.2.2MITupdated 2026-10-08Uzasch/skills/mods/terminal-desk
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · terminal-desk
│ ┃ This session ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ ┌──────────────────────────────────────────┐ │ terminal-desk │ │ ┃ │ Plan limits │ ⏺ Read(src/auth.ts) │ Left undone: Wrote "// TODO: implement │ │ ┃ │ Session (5h) 31%, resets NaNh NaNm │ ⎿ Read 6 lines │ eviction later" into cache.ts │ │ ┃ │ ████████████████░░░░░░░░░░░░░░░░░░░░░░░░ │ ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ │ ┃ │ ░░░░░░░░░░░░ │ ⎿ Added 2 lines, removed 1 line ╭─────────────────╮ │ ┃ └──────────────────────────────────────────┘ ⏺ Bash(bun test) │ terminal-desk │ │ ┃ ┌──────────────────────────────────────────┐ ⎿ 3 pass, 1 fail │ Left undone: OK │ │ ┃ │ This session │ ╰─────────────────╯ │ ┃ │ fix the failing auth test │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ No task list yet │ │ ┃ └──────────────────────────────────────────┘ ✻ Worked for 42s · done 4:20 PM │ ┃ ┌──────────────────────────────────────────┐ │ ┃ │ What Claude is assuming │ › /desk │ ┃ │ None reported yet. They appear here as │ ⎿ terminal-desk: Session dashboard opened. │ ┃ │ Claude makes them. │ │ ┃ └──────────────────────────────────────────┘ │ ┃ ┌──────────────────────────────────────────┐ │ ┃ │ Left undone │ │ ┃ │ U3 · 00:00 · second model[ a Do it now ] │ │ ┃ │ OK │ │ ┃ │ │ │ ┃ │ U2 · 00:00 · in a file [ b Do it now ] │ │ ┃ │ Wrote "// TODO: implement eviction │ │ ┃ │ later" into cache.ts │ │ ┃ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · This session
┌──────────────────────────────────────────────────────────┐ │ Plan limits │ │ Session (5h) 31%, resets NaNh NaNm │ │ ████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │ └──────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────┐ │ This session │ │ fix the failing auth test │ │ No task list yet │ └──────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────┐ │ What Claude is assuming │ │ None reported yet. They appear here as Claude makes │ │ them. │ └──────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────┐ │ Left undone │ │ U3 · 00:00 · second model [ a Do it now ] │ │ OK │ │ │ │ U2 · 00:00 · in a file [ b Do it now ] │ │ Wrote "// TODO: implement eviction later" into cache.ts │ │ │ │ U1 · 00:00 · in a file [ c Do it now ] │ │ Wrote "// TODO: implement persistence" into audit.ts │ │ [ x Clear all ] │ └──────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────┐ │ Where your context went │ │ Used 49% of 200.0K │ │ Measured after the next turn │ └──────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────┐ │ Prompt cache │ │ Right now hot, 60:00 left │ │ Next message while hot $0.02 │ │ Next message once cold $0.78 │ │ Estimate: resending the conversation at API list price, │ │ 1h cache │ └──────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────┐ │ Subagents │ │ None started yet │ └──────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────┐ │ Cost and tokens │ │ Cost, whole session $0.42 │ │ Tokens read 97.4K │ │ served from cache 93% │ │ Tokens written 1.5K │ │ Turns 1 │ │ Tokens and turns count from when this loaded │ └──────────────────────────────────────────────────────────┘
README

Terminal Desk

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.

Install

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

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.

The pane

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

Commands

| 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) |

What it adds to your sessions

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.

What to know about the numbers

  • The session cost comes from Claude Code and is an estimate at API list prices. The cache resend estimates use a price table inside the mod, which goes stale when prices change. Your plan may bill differently.
  • Claude Code does not tell a mod how long the prompt cache lasts, so the mod assumes one hour unless you run /desk cache 5m.
  • Tokens and turns count from when the mod loaded, which is the start of the session unless you installed it partway through.
  • The one-hour cache applies to a Claude subscription within its included usage. On an API key or a cloud provider the default is five minutes, so run /desk cache 5m there.
  • On a Team or Enterprise plan, or a machine with managed settings, Claude Code stops a mod you install yourself from changing the system prompt, so the assumptions panel may stay empty.
  • Left undone is a prompt to look, not a verdict. The phrase scan flags innocent sentences sometimes, and the second model reads Claude's report and not its tool calls, so it finds what the report admits to.
  • Context and cache figures describe the main conversation. Subagents get their own status but not their own context breakdown.

Before you install

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.

License

MIT

Source 2 files
hooks/register.tsx 903 lines
1import { 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}
903
types/index.d.ts 79 lines
1export 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