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…

newpanebandguardcommandtoast
v0.2.1MITupdated 2026-10-07ClariSortAi/claude-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 │ │ ┃ │ What Claude is assuming │ ⏺ Read(src/auth.ts) │ Left undone: Wrote "// TODO: implement │ │ ┃ │ None reported yet. They appear here as │ ⎿ Read 6 lines │ eviction later" into cache.ts │ │ ┃ │ Claude makes them. │ ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ │ ┃ └──────────────────────────────────────────┘ ⎿ Added 2 lines, removed 1 line ╭─────────────────╮ │ ┃ ┌──────────────────────────────────────────┐ ⏺ Bash(bun test) │ terminal-desk │ │ ┃ │ Left undone │ ⎿ 3 pass, 1 fail │ Left undone: OK │ │ ┃ │ U3 · 00:00 · second model[ a Do it now ] │ ╰─────────────────╯ │ ┃ │ OK │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ │ │ ┃ │ U2 · 00:00 · in a file [ b Do it now ] │ ✻ Worked for 42s · done 4:20 PM │ ┃ │ Wrote "// TODO: implement eviction │ │ ┃ │ later" into cache.ts │ › /desk │ ┃ │ │ ⎿ terminal-desk: Session dashboard opened. │ ┃ │ 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 │ │ ┃ └──────────────────────────────────────────┘ │ ┃ ┌──────────────────────────────────────────┐ Claude Code ○ ready Tokens 98.9K Cost $0.42 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Claude Code ○ ready Tokens 98.9K Cost $0.42
Pane · This session
┌──────────────────────────────────────────────────────────┐ │ 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

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

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.

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 950 lines
1import { 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}
950
types/index.d.ts 73 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'
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