A side panel for Claude Code: things Claude left undone, where your context went, cost, and whether the prompt cache is still warm.

A side panel for Claude Code that shows what Claude left unfinished, where your context went, and whether your prompt cache is still warm, and tells you when it's time to /clear.

The screenshot and demo here are placeholder mock-ups drawn with
scripts/make-media.py, not captures of a real session. They will be replaced with real recordings. Full-size video: assets/demo.mp4
Long Claude Code sessions hide three things you usually only notice when it's too late:
// TODO in a file, or ends a turn with tasks still open on its own to-do list. You read the summary, move on, and the gap ships.terminal-desk is a Claude Code mod: a plugin that runs inside Claude Code and draws its own pane next to the transcript. It keeps all three in view while you work.

After every turn, the desk collects unfinished work from three sources:
| Source | What it catches | Label in the pane |
|---|---|---|
| What Claude said | Admissions in any text Claude wrote that turn: "I didn't run…", "not yet implemented", "skipped", "I won't implement until you say so", Thai phrases such as "ยังไม่ได้รัน", and the bullets under headings like OPEN, FLAGGED, BLOCKED, Remaining:, Next steps. Code blocks are ignored. | Claude said |
| Files Claude edited | Lines that are new in a Write / Edit / MultiEdit / NotebookEdit and contain TODO, FIXME, XXX, HACK or NotImplementedError. Markers that were already in the file don't count. | in a file |
| Claude's task list | Items from TaskCreate / TaskUpdate / TodoWrite that aren't completed: ◐ in progress, ◻ pending. They disappear once Claude completes them. | Task list |
Keys while the pane has focus:
| Key | Action |
|---|---|
✓ | Dismiss one item |
x | Clear the whole list (or run /desk-clear) |
f | Ask Claude to finish: types a request listing every open item into your prompt. Nothing is sent until you press Enter. |
h | Show how the list is filled, inside the pane |
The list survives /clear, open tasks included. Clear the context first, then press f and Claude finishes the leftovers with a clean context.
Detection is pattern-based, so it will sometimes miss an item or catch one too many. Dismiss false hits with ✓, and tune the patterns in hooks/detect.ts.
A colored bar and a breakdown by category, the same categories /context uses (Messages, System tools, Skills, Memory files, MCP server instructions, System prompt, Custom agents, Free space, Autocompact buffer), with the percentage and token count of the window in use.
/clear| Context used | What you see |
|---|---|
| below 60% | Nothing extra |
| 60% or more | 🟡 A one-time toast and a yellow band above the prompt: finish the current task, then /clear (or /compact to keep a summary) |
| 80% or more | 🔴 A second toast, a red banner in the pane, and the band turns red: time to /clear |
The band above the prompt shows even when the pane is closed. Its buttons are:
Both thresholds can be changed (see Settings).
/clear starts a new conversation with an empty context. /compact replaces the conversation with a summary, keeping the gist at a fraction of the tokens.
Shows whether the cache is warm (with a countdown to when it goes cold) or cold (and how long ago it expired). Also shows how many tokens the next message will read from the cache or have to re-cache, and the session's cache hit ratio. The cache lifetime (5 minutes or 1 hour) is read from the session transcript, or you can set it yourself.
Under the prompt, even with the pane closed:
desk · ctx 64% · $2.55 · cache warm 3:12 · ⚠ 4 left undone (/desk)
The pane's footer shows the session cost (Claude Code's own estimate, as in /cost) and how long the session has been running.
Every message you send to Claude carries the whole conversation so far: the system prompt, tool definitions, CLAUDE.md, and every earlier message and tool result. On a long session that can be hundreds of thousands of tokens per message.
Prompt caching lets the API skip re-reading that unchanged beginning. After a request, the prefix of the prompt is kept in a cache for a limited time (its TTL: 5 minutes by default, or 1 hour). Each time a request reads the cache, the timer starts over.
The bigger the context, the bigger the gap. At 150K tokens of context, a cold message costs roughly 12 times what the same message costs warm. That's why the desk shows a countdown, and why /clear before a long break is often cheaper than coming back to a huge, cold conversation.
Source: Anthropic prompt caching docs
Requires Claude Code 2.1.287 or newer (mods are a newer plugin feature). Check with claude --version.
Option 1: from GitHub (recommended). At the prompt of a Claude Code session in your terminal:
/plugin install terminal-desk --marketplace Doonminus2/claude-code-TerminalDesk-Plugin
Answer y to add the marketplace and pick the user scope.
Option 2: from a clone (macOS / Linux):
git clone https://github.com/Doonminus2/claude-code-TerminalDesk-Plugin.git
bash claude-code-TerminalDesk-Plugin/install.sh
Then restart Claude Code, or run /reload-plugins in an open session.
| Command | What it does |
|---|---|
/desk | Show or hide the pane |
/desk-clear | Empty the Left undone list |
/desk and it opens above the prompt./plugin → terminal-desk → configure:
| Setting | Default | Meaning |
|---|---|---|
autoOpen | true | Open the pane when a session starts |
cacheTtl | auto | Cache lifetime: auto (read from the transcript), 5m or 1h |
warnPercent | 60 | Context % for the yellow warning |
urgentPercent | 80 | Context % for the red warning |
claude plugin marketplace update terminal-desk
claude plugin update terminal-desk@terminal-desk
Then /reload-plugins in Claude Code. If you installed with install.sh, git pull and run it again.
claude plugin uninstall terminal-desk@terminal-desk
If you installed with install.sh, use bash ~/.claude/mods/terminal-desk/install.sh --remove.
claude --plugin-dir . # run Claude Code with the mod loaded from this folder; saving a file reloads it
claude plugin validate . # check the manifest and hooks module
claude plugin test . # run the tests in tests/
python3 scripts/make-media.py assets # regenerate the placeholder screenshot, video and GIF
| Path | What's there |
|---|---|
hooks/register.tsx | The pane, the warning band, the status line, /desk and every event hook |
hooks/detect.ts | The "Left undone" patterns and small formatting helpers |
types/index.d.ts | The mod's session state |
tests/desk.test.ts | Detection, pane, warning and task-list tests on the terminal and desktop surfaces |
.claude-plugin/ | Plugin manifest, settings and the marketplace entry |
/cost shows, and can differ from your bill.hooks/. claude plugin validate . lists every event it hooks and every API it calls. It reads your transcript's tail to learn the cache lifetime, and it never sends anything over the network.MIT: free to use, modify and share, including in commercial projects, as long as the copyright notice stays with the code.
hooks/register.tsx 660 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { CacheTtl, CategoryRow, TaskItem, UndoneItem } from '../types'
5import {
6 clock,
7 contextLevel,
8 duration,
9 findMarkers,
10 findSaid,
11 hhmm,
12 kTokens,
13 ttlFromTranscriptTail,
14 ttlMs,
15} from './detect'
16
17const PANE = 'terminal-desk'
18const MAX_ITEMS = 50
19
20const undone = atom({ plugin: 'terminal-desk', key: 'undone' } as const, [])
21const tasks = atom({ plugin: 'terminal-desk', key: 'tasks' } as const, [])
22const warnLevel = atom({ plugin: 'terminal-desk', key: 'warnLevel' } as const, 0)
23const isBandHidden = atom({ plugin: 'terminal-desk', key: 'isBandHidden' } as const, false)
24const nextId = atom({ plugin: 'terminal-desk', key: 'nextId' } as const, 1)
25const usage = atom({ plugin: 'terminal-desk', key: 'usage' } as const, {
26 window: 200_000,
27 categories: [],
28})
29const cache = atom({ plugin: 'terminal-desk', key: 'cache' } as const, {
30 lastAt: null,
31 ttl: null,
32 isObserved: false,
33 readTokens: 0,
34 inputTokens: 0,
35 lastContext: 0,
36})
37
38type Options = { autoOpen?: boolean; cacheTtl?: string; warnPercent?: number; urgentPercent?: number }
39type Limits = { warnAt: number; urgentAt: number }
40type Api = EngineInterface
41
42// ---------- helpers that touch $ ----------
43
44
45async function addItems($: Api, items: { kind: 'said' | 'file' | 'task'; text: string; file?: string }[]) {
46 if (items.length === 0) return 0
47 const now = await $.clock.now()
48 let added = 0
49 let first = 0
50 await update($, nextId, id => {
51 first = id
52 return id
53 })
54 await update($, undone, list => {
55 const out = [...list]
56 let id = first
57 for (const it of items) {
58 const dup = out.some(o => o.text === it.text && o.file === it.file)
59 if (dup) continue
60 out.push({ id: id++, kind: it.kind, text: it.text, file: it.file, at: now })
61 added++
62 }
63 return out.slice(-MAX_ITEMS)
64 })
65 if (added > 0) await update($, nextId, id => id + added)
66 return added
67}
68
69/** Follow Claude's own task list (TaskCreate / TaskUpdate / TodoWrite). */
70async function trackTasks($: Api, tool: string, input: Record<string, unknown>, result: unknown) {
71 if (tool === 'TodoWrite' && Array.isArray(input.todos)) {
72 const todos = input.todos as { content?: unknown; status?: unknown }[]
73 await update($, tasks, () =>
74 todos.map((t, i) => ({
75 id: `todo-${i + 1}`,
76 subject: String(t.content ?? ''),
77 status: t.status === 'completed' || t.status === 'in_progress' ? t.status : 'pending',
78 })),
79 )
80 return
81 }
82 if (tool === 'TaskCreate') {
83 const made = (result as { task?: { id?: unknown; subject?: unknown } } | undefined)?.task
84 const id = made?.id !== undefined ? String(made.id) : `t${Date.now()}`
85 const subject = String(made?.subject ?? input.subject ?? '')
86 await update($, tasks, list => [...list.filter(t => t.id !== id), { id, subject, status: 'pending' as const }])
87 return
88 }
89 if (tool === 'TaskUpdate') {
90 const id = String(input.taskId ?? '')
91 const status = input.status
92 await update($, tasks, list => {
93 if (status === 'deleted') return list.filter(t => t.id !== id)
94 const known = list.find(t => t.id === id)
95 const next: TaskItem = {
96 id,
97 subject: typeof input.subject === 'string' ? input.subject : (known?.subject ?? `task #${id}`),
98 status:
99 status === 'completed' || status === 'in_progress' || status === 'pending'
100 ? status
101 : (known?.status ?? 'pending'),
102 }
103 return known ? list.map(t => (t.id === id ? next : t)) : [...list, next]
104 })
105 }
106}
107
108/** Raise the context warning once per level as the context fills; reset when it empties. */
109async function checkContext($: Api, limits: Limits) {
110 const u = await read($, usage)
111 const level = contextLevel(u.percent, limits.warnAt, limits.urgentAt)
112 const prev = await read($, warnLevel)
113 if (level === prev) return
114 await update($, warnLevel, () => level)
115 if (level > prev) {
116 await update($, isBandHidden, () => false)
117 $.ui.toast(
118 level === 2
119 ? `🔴 Context ${u.percent}% full: time to /clear (or /compact to keep a summary)`
120 : `🟡 Context ${u.percent}% full: consider /clear when this task is done`,
121 { timeoutMs: 8000 },
122 )
123 }
124}
125
126/** Everything the assistant wrote since the person's last prompt, newest last. */
127async function turnTexts($: Api): Promise<string[]> {
128 const rows = await $.session.messages()
129 const out: string[] = []
130 for (let i = rows.length - 1; i >= 0; i--) {
131 const row = rows[i]
132 if (row === undefined) continue
133 if (row.role === 'user' && (row.toolResults?.length ?? 0) === 0 && row.text.trim() !== '') break
134 if (row.role === 'assistant' && row.text.trim() !== '') out.unshift(row.text)
135 }
136 return out
137}
138
139async function refreshUsage($: Api, withBreakdown: boolean) {
140 const u = await $.session.usage(withBreakdown ? { breakdown: 'summary', columns: 60 } : undefined)
141 const categories: CategoryRow[] | undefined = u.context.breakdown?.categories.map(c => ({
142 name: c.name,
143 tokens: c.tokens,
144 color: c.color,
145 kind: c.kind,
146 }))
147 await update($, usage, prev => ({
148 tokens: u.context.tokens,
149 window: u.context.window,
150 percent: u.context.percent,
151 usd: u.cost?.usd,
152 startedAt: u.startedAt,
153 categories: categories ?? prev.categories,
154 }))
155}
156
157async function statusLine($: Api, forcedTtl: CacheTtl | null, limits: Limits) {
158 const [u, c, list, ts] = await Promise.all([read($, usage), read($, cache), read($, undone), read($, tasks)])
159 const now = await $.clock.now()
160 const parts: string[] = []
161 const open = list.length + ts.filter(t => t.status !== 'completed').length
162 const level = contextLevel(u.percent, limits.warnAt, limits.urgentAt)
163 if (u.percent !== undefined) {
164 parts.push(level === 2 ? `🔴 ctx ${u.percent}% → /clear` : level === 1 ? `🟡 ctx ${u.percent}%` : `ctx ${u.percent}%`)
165 }
166 if (u.usd !== undefined) parts.push(`$${u.usd.toFixed(2)}`)
167 const ttl = forcedTtl ?? c.ttl ?? '5m'
168 if (c.isObserved && c.lastAt !== null) {
169 const left = c.lastAt + ttlMs(ttl) - now
170 parts.push(left > 0 ? `cache warm ${clock(left)}` : 'cache cold')
171 }
172 if (open > 0) parts.push(`⚠ ${open} left undone (/desk)`)
173 $.ui.status(parts.length ? 'desk · ' + parts.join(' · ') : undefined)
174}
175
176
177function clampPercent(value: unknown, fallback: number): number {
178 const n = typeof value === 'number' ? value : Number(value)
179 return Number.isFinite(n) && n > 0 && n <= 100 ? n : fallback
180}
181
182export const register: Register = (on, options) => {
183 const opts = (options ?? {}) as Options
184 const forcedTtl: CacheTtl | null =
185 opts.cacheTtl === '5m' || opts.cacheTtl === '1h' ? opts.cacheTtl : null
186 const warnAt = clampPercent(opts.warnPercent, 60)
187 const limits: Limits = { warnAt, urgentAt: Math.max(warnAt, clampPercent(opts.urgentPercent, 80)) }
188
189 // ---------- session ----------
190
191 on('session.start', async ($, e, next) => {
192 await $.command.register({
193 name: 'desk',
194 description: 'Show or hide the desk: left undone, context, cost, prompt cache',
195 immediate: true,
196 })
197 await $.command.register({
198 name: 'desk-clear',
199 description: 'Clear the desk\'s "Left undone" list',
200 immediate: true,
201 })
202
203 try {
204 await refreshUsage($, true)
205 } catch {
206 // usage is not ready before the first response; the next measure fills it
207 }
208
209 // One tick a second keeps the cache countdown and the status line current.
210 $.clock.every(1000, () => {
211 void (async () => {
212 await statusLine($, forcedTtl, limits)
213 const c = await read($, cache)
214 if (c.isObserved) $.ui.invalidate('ui.render')
215 })()
216 })
217
218 if (opts.autoOpen !== false) void $.ui.open({ id: PANE, title: 'Desk' })
219
220 return next(e)
221 })
222
223 on('command.run', { command: 'desk' }, async $ => {
224 const isUp = (await $.ui.panes()).some(p => p.id === PANE)
225 if (isUp) {
226 await $.ui.close({ id: PANE })
227 return {}
228 }
229 await $.ui.open({ id: PANE, title: 'Desk' })
230 return {}
231 })
232
233 on('command.run', { command: 'desk-clear' }, async $ => {
234 await update($, undone, () => [])
235 await update($, tasks, () => [])
236 await statusLine($, forcedTtl, limits)
237 return { text: 'Cleared the "Left undone" list.' }
238 })
239
240 // /clear resets the session's state: keep what is left undone for the fresh session.
241 on('session.end', { reason: 'clear' }, async ($, e, next) => {
242 const [list, ts, id] = await Promise.all([read($, undone), read($, tasks), read($, nextId)])
243 const open = ts.filter(t => t.status !== 'completed')
244 if (list.length + open.length > 0) {
245 const now = await $.clock.now()
246 let n = id
247 const carried: UndoneItem[] = [
248 ...list,
249 ...open.map(t => ({ id: n++, kind: 'task' as const, text: t.subject, at: now })),
250 ]
251 await $.store.set('carry', { at: now, undone: carried, nextId: n })
252 }
253 return next(e)
254 }).catch(($, e, next) => next(e))
255
256 on('classic.SessionStart', { source: 'clear' }, async ($, e, next) => {
257 const saved = (await $.store.get('carry')) as { at?: number; undone?: UndoneItem[]; nextId?: number } | undefined
258 if (saved && Array.isArray(saved.undone) && (await $.clock.now()) - (saved.at ?? 0) < 120_000) {
259 await update($, undone, () => saved.undone ?? [])
260 await update($, nextId, () => saved.nextId ?? 1)
261 await $.store.delete('carry')
262 $.ui.toast(`Carried ${saved.undone.length} left-undone item(s) into the fresh session (/desk)`)
263 }
264 await statusLine($, forcedTtl, limits)
265 return next(e)
266 }).catch(($, e, next) => next(e))
267
268 // Context and cost move: keep the snapshot current.
269 on('session.measure', async ($, e, next) => {
270 await update($, usage, prev => ({
271 ...prev,
272 tokens: e.context.tokens,
273 window: e.context.window,
274 percent: e.context.percent,
275 usd: e.cost?.usd ?? prev.usd,
276 }))
277 await checkContext($, limits)
278 await statusLine($, forcedTtl, limits)
279 return next(e)
280 })
281
282 // ---------- left undone: what Claude said ----------
283
284 on('turn.complete', async ($, e, next) => {
285 const result = await next(e)
286 if (e.agentId !== undefined) return result // a subagent's turn
287
288 const now = await $.clock.now()
289 const u = e.usage
290 if (u) {
291 const context = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
292 const isCached = u.cache_read_input_tokens + u.cache_creation_input_tokens > 0
293 await update($, cache, prev => ({
294 ...prev,
295 lastAt: now,
296 isObserved: prev.isObserved || isCached,
297 readTokens: prev.readTokens + u.cache_read_input_tokens,
298 inputTokens: prev.inputTokens + context,
299 lastContext: context,
300 }))
301 }
302
303 if (e.reason === 'answer' || e.reason === 'aborted') {
304 let texts: string[] = []
305 try {
306 texts = await turnTexts($)
307 } catch {
308 texts = []
309 }
310 if (e.answer && !texts.includes(e.answer)) texts.push(e.answer)
311 const said: string[] = []
312 for (const t of texts) for (const one of findSaid(t)) if (!said.includes(one)) said.push(one)
313 const added = await addItems(
314 $,
315 said.map(text => ({ kind: 'said' as const, text })),
316 )
317 if (added > 0) $.ui.toast(`⚠ Left undone: +${added} (/desk to see)`)
318 }
319
320 try {
321 await refreshUsage($, true)
322 } catch {
323 // keep the previous snapshot
324 }
325 await checkContext($, limits)
326 await statusLine($, forcedTtl, limits)
327 return result
328 })
329
330 // ---------- left undone: TODOs written into files ----------
331
332 on('tool.call', async ($, e, next) => {
333 const tool = String(e.tool)
334 if (tool === 'TaskCreate' || tool === 'TaskUpdate' || tool === 'TodoWrite') {
335 const ran = await next(e)
336 if (ran.deny !== undefined || ran.isError === true || e.agentId !== undefined) return ran
337 await trackTasks($, tool, e as unknown as Record<string, unknown>, ran.result)
338 await statusLine($, forcedTtl, limits)
339 return ran
340 }
341 if (tool !== 'Write' && tool !== 'Edit' && tool !== 'MultiEdit' && tool !== 'NotebookEdit') {
342 return next(e)
343 }
344 const input = e as unknown as Record<string, unknown>
345 const file = typeof input.file_path === 'string' ? input.file_path : (input.notebook_path as string | undefined)
346
347 let before = ''
348 let after = ''
349 if (tool === 'Write') {
350 after = String(input.content ?? '')
351 if (file) {
352 try {
353 before = await $.fs.read(file)
354 } catch {
355 before = ''
356 }
357 }
358 } else if (tool === 'Edit') {
359 before = String(input.old_string ?? '')
360 after = String(input.new_string ?? '')
361 } else if (tool === 'MultiEdit' && Array.isArray(input.edits)) {
362 for (const ed of input.edits as Record<string, unknown>[]) {
363 before += String(ed.old_string ?? '') + '\n'
364 after += String(ed.new_string ?? '') + '\n'
365 }
366 } else if (tool === 'NotebookEdit') {
367 after = String(input.new_source ?? '')
368 }
369
370 const ran = await next(e)
371 if (ran.deny !== undefined || ran.isError === true) return ran
372
373 const markers = findMarkers(before, after)
374 if (markers.length > 0) {
375 const short = file ? file.split('/').slice(-2).join('/') : undefined
376 const added = await addItems(
377 $,
378 markers.map(text => ({ kind: 'file' as const, text, file: short })),
379 )
380 if (added > 0) await statusLine($, forcedTtl, limits)
381 }
382 return ran
383 }).catch(($, e, next) => next(e)) // an observer: if it fails, the edit goes on as usual
384
385 // ---------- which cache lifetime the session uses ----------
386
387 on('classic.Stop', async ($, e, next) => {
388 if (forcedTtl === null) {
389 const path = (e as unknown as { transcript_path?: string }).transcript_path
390 if (path) {
391 try {
392 const { stdout } = await $.process.run(['tail', '-c', '300000', path], { timeoutMs: 5000 })
393 const ttl = ttlFromTranscriptTail(stdout)
394 if (ttl) await update($, cache, prev => ({ ...prev, ttl }))
395 } catch {
396 // keep what we had
397 }
398 }
399 }
400 return next(e)
401 }).catch(($, e, next) => next(e))
402
403 // ---------- drawing ----------
404
405 let isHelpOpen = false
406
407 // The band above the prompt: shown while the context is getting full, even with the pane closed.
408 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
409 const [u, hidden, list, ts] = await Promise.all([read($, usage), read($, isBandHidden), read($, undone), read($, tasks)])
410 const level = contextLevel(u.percent, limits.warnAt, limits.urgentAt)
411 if (level === 0 || hidden || e.props.hasSurvey) return next(e)
412 const { Box, Text, Button } = $.ui.resolve(e)
413 const color = level === 2 ? 'error' : 'warning'
414 const open = list.length + ts.filter(t => t.status !== 'completed').length
415 return (
416 <Box flexDirection="column">
417 <Text wrap="wrap">
418 <Text color={color} bold>
419 {level === 2 ? '🔴' : '🟡'} Context {u.percent}% full
420 </Text>
421 <Text dimColor>
422 {level === 2
423 ? ' · answers get slower, pricier and less sharp: /clear starts fresh, /compact keeps a summary'
424 : ' · /clear when this task is done (or /compact to keep a summary)'}
425 {open > 0 ? ` · ${open} left undone will carry over` : ''}
426 </Text>
427 </Text>
428 <Box flexDirection="row" columnGap={2}>
429 <Button key="fill-clear" label="Type /clear" hotkey="c" plain onPress={() => $.prompt.fill({ text: '/clear', mode: 'replace' })} />
430 <Button key="fill-compact" label="Type /compact" hotkey="k" plain onPress={() => $.prompt.fill({ text: '/compact', mode: 'replace' })} />
431 <Button key="hide-warn" label="Hide" hotkey="h" plain dimColor onPress={() => update($, isBandHidden, () => true)} />
432 </Box>
433 </Box>
434 )
435 })
436
437 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
438 const { Box, Text, Button } = $.ui.resolve(e)
439 const [list, ts, u, c] = await Promise.all([read($, undone), read($, tasks), read($, usage), read($, cache)])
440 const now = await $.clock.now()
441 const width = Math.max(20, Math.min(e.props.bodyColumns ?? 40, 80))
442 const openTasks = ts.filter(t => t.status !== 'completed')
443 const openCount = list.length + openTasks.length
444 const level = contextLevel(u.percent, limits.warnAt, limits.urgentAt)
445
446 const kindLabel = (k: UndoneItem['kind']) => (k === 'said' ? 'Claude said' : k === 'file' ? 'in a file' : 'task list')
447
448 // Left undone
449 const undoneRows = list.slice().reverse().map((it: UndoneItem) => (
450 <Box flexDirection="column" key={`u-${it.id}`}>
451 <Box flexDirection="row" columnGap={1}>
452 <Text color="error" bold>
453 U{it.id}
454 </Text>
455 <Text dimColor>· {hhmm(it.at)} ·</Text>
456 <Text color="error">{kindLabel(it.kind)}</Text>
457 <Button
458 key={`done-${it.id}`}
459 label="✓"
460 plain
461 dimColor
462 onPress={() => update($, undone, l => l.filter(x => x.id !== it.id))}
463 />
464 </Box>
465 {it.file && <Text dimColor wrap="truncate-start">{it.file}</Text>}
466 <Text wrap="wrap">{it.text}</Text>
467 </Box>
468 ))
469
470 const taskRows = openTasks.map((t: TaskItem) => (
471 <Box flexDirection="row" columnGap={1} key={`t-${t.id}`}>
472 <Text color={t.status === 'in_progress' ? 'warning' : undefined}>{t.status === 'in_progress' ? '◐' : '◻'}</Text>
473 <Text wrap="wrap">{t.subject}</Text>
474 </Box>
475 ))
476
477 const askClaude = async () => {
478 const [items, all] = await Promise.all([read($, undone), read($, tasks)])
479 const lines = [
480 ...items.map(it => `- ${it.kind === 'file' && it.file ? `${it.file}: ` : ''}${it.text}`),
481 ...all.filter(t => t.status !== 'completed').map(t => `- (task, ${t.status}) ${t.subject}`),
482 ]
483 if (lines.length === 0) return
484 await $.prompt.fill({
485 text: `Earlier you left these undone. Please finish them, or tell me why not:\n${lines.join('\n')}`,
486 mode: 'replace',
487 })
488 }
489
490 const help = (
491 <Box flexDirection="column">
492 <Text dimColor wrap="wrap">How "Left undone" fills up, from three sources:</Text>
493 <Text wrap="wrap">1. Claude said: after each turn, Claude's text is scanned for admissions ("I didn't run…", "not yet tested", "I won't implement until…", "ยังไม่ได้…") and for bullets under OPEN / FLAGGED / Remaining / Next steps.</Text>
494 <Text wrap="wrap">2. In a file: when Claude writes or edits a file, new lines with TODO / FIXME / XXX / HACK / NotImplementedError are caught.</Text>
495 <Text wrap="wrap">3. Task list: Claude's own to-dos (TaskCreate / TodoWrite) that are not completed; ◐ in progress, ◻ pending. They drop off once done.</Text>
496 <Text dimColor wrap="wrap">✓ removes one · x clears all · f types a request for Claude to finish them (you press Enter). The list survives /clear.</Text>
497 </Box>
498 )
499 const toggleHelp = () => {
500 isHelpOpen = !isHelpOpen
501 $.ui.invalidate('ui.render')
502 }
503 // Context bar
504 const used = u.categories.filter(k => k.kind === 'used')
505 const total = u.window || 1
506 const barCols = Math.max(10, width - 2)
507 const segs = used
508 .map(k => ({ k, n: Math.round((k.tokens / total) * barCols) }))
509 .filter(s => s.n > 0)
510 const usedCols = segs.reduce((a, s) => a + s.n, 0)
511 const fallbackUsed = u.percent !== undefined ? Math.round((u.percent / 100) * barCols) : 0
512 const bar =
513 segs.length > 0 ? (
514 <Box flexDirection="row">
515 {segs.map((s, i) => (
516 <Text key={`seg-${i}`} color={s.k.color}>
517 {'█'.repeat(s.n)}
518 </Text>
519 ))}
520 <Text dimColor>{'░'.repeat(Math.max(0, barCols - usedCols))}</Text>
521 </Box>
522 ) : (
523 <Box flexDirection="row">
524 <Text color="promptBorder">{'█'.repeat(fallbackUsed)}</Text>
525 <Text dimColor>{'░'.repeat(Math.max(0, barCols - fallbackUsed))}</Text>
526 </Box>
527 )
528
529 const rows = u.categories
530 .filter(k => k.kind !== 'deferred' && k.tokens > 0)
531 .slice()
532 .sort((a, b) => (a.kind === 'used' ? 0 : 1) - (b.kind === 'used' ? 0 : 1) || b.tokens - a.tokens)
533 .map((k: CategoryRow, i) => (
534 <Box flexDirection="row" columnGap={1} key={`cat-${i}`}>
535 <Text color={k.kind === 'used' ? k.color : undefined} dimColor={k.kind !== 'used'}>
536 ■
537 </Text>
538 <Text dimColor={k.kind !== 'used'}>{k.name}</Text>
539 <Text dimColor>{kTokens(k.tokens)}</Text>
540 </Box>
541 ))
542
543 // Prompt cache
544 const ttl = forcedTtl ?? c.ttl ?? '5m'
545 const left = c.lastAt !== null ? c.lastAt + ttlMs(ttl) - now : -1
546 const hit = c.inputTokens > 0 ? Math.round((c.readTokens / c.inputTokens) * 100) : null
547 const cacheLines =
548 !c.isObserved || c.lastAt === null ? (
549 <Text dimColor>No cached request yet.</Text>
550 ) : left > 0 ? (
551 <Box flexDirection="column">
552 <Text>
553 <Text color="success" bold>
554 ● warm
555 </Text>{' '}
556 · goes cold in {clock(left)} ({ttl} cache)
557 </Text>
558 <Text dimColor>Next message reads ~{kTokens(c.lastContext)} from cache: cheap.</Text>
559 </Box>
560 ) : (
561 <Box flexDirection="column">
562 <Text>
563 <Text color="warning" bold>
564 ○ cold
565 </Text>{' '}
566 · expired {duration(-left)} ago ({ttl} cache)
567 </Text>
568 <Text dimColor>Next message re-caches ~{kTokens(c.lastContext)} tokens: pricier.</Text>
569 </Box>
570 )
571
572 const elapsed = u.startedAt !== undefined ? duration(now - u.startedAt) : undefined
573
574 const banner =
575 level > 0 ? (
576 <Box flexDirection="column" borderStyle="bold" borderColor={level === 2 ? 'error' : 'warning'} paddingX={1}>
577 <Text color={level === 2 ? 'error' : 'warning'} bold>
578 {level === 2 ? '🔴 Context nearly full' : '🟡 Context getting full'} ({u.percent}%)
579 </Text>
580 <Text wrap="wrap">
581 {level === 2
582 ? 'Time for /clear: long contexts make answers slower, pricier and less sharp. Use /compact to keep a summary instead.'
583 : 'Finish the current task, then /clear (or /compact to keep a summary).'}
584 </Text>
585 {openCount > 0 && <Text dimColor wrap="wrap">Left undone ({openCount}) carries over to the fresh session.</Text>}
586 <Box flexDirection="row" columnGap={2}>
587 <Button key="p-clear" label="Type /clear" hotkey="c" plain onPress={() => $.prompt.fill({ text: '/clear', mode: 'replace' })} />
588 <Button key="p-compact" label="Type /compact" hotkey="k" plain onPress={() => $.prompt.fill({ text: '/compact', mode: 'replace' })} />
589 </Box>
590 </Box>
591 ) : null
592
593 return (
594 <Box flexDirection="column" rowGap={1}>
595 {banner}
596 <Box flexDirection="column" borderStyle="round" borderColor="error" paddingX={1}>
597 <Box flexDirection="row" columnGap={2}>
598 <Text color="error" bold>
599 Left undone {openCount > 0 ? `(${openCount})` : ''}
600 </Text>
601 <Button key="help" label={isHelpOpen ? 'hide help' : 'how it works'} hotkey="h" plain dimColor onPress={toggleHelp} />
602 </Box>
603 {isHelpOpen && help}
604 {openCount === 0 ? (
605 <Text dimColor>Nothing left undone. Press h to see what gets caught.</Text>
606 ) : (
607 <Box flexDirection="column" rowGap={1}>
608 {undoneRows}
609 {openTasks.length > 0 && (
610 <Box flexDirection="column">
611 <Text dimColor>Task list · not completed</Text>
612 {taskRows}
613 </Box>
614 )}
615 <Box flexDirection="row" columnGap={2}>
616 <Button key="ask" label="Ask Claude to finish" hotkey="f" variant="primary" onPress={askClaude} />
617 <Button
618 key="clear"
619 label="Clear all"
620 hotkey="x"
621 onPress={async () => {
622 await update($, undone, () => [])
623 await update($, tasks, () => [])
624 }}
625 />
626 </Box>
627 </Box>
628 )}
629 </Box>
630
631 <Box flexDirection="column" borderStyle="round" borderColor="warning" paddingX={1}>
632 <Text color="warning" bold>
633 Where your context went
634 </Text>
635 <Text>
636 {u.percent !== undefined ? `${u.percent}% used` : 'no response yet'}
637 {u.tokens !== undefined ? ` · ${kTokens(u.tokens)} / ${kTokens(total)}` : ''}
638 </Text>
639 {bar}
640 {rows}
641 </Box>
642
643 <Box flexDirection="column" borderStyle="round" borderColor="success" paddingX={1}>
644 <Text color="success" bold>
645 Prompt cache
646 </Text>
647 {cacheLines}
648 {hit !== null && <Text dimColor>Hit ratio this session: {hit}%</Text>}
649 </Box>
650
651 <Box flexDirection="row" columnGap={2}>
652 {u.usd !== undefined && <Text color="warning">${u.usd.toFixed(2)}</Text>}
653 {elapsed && <Text dimColor>⏱ {elapsed}</Text>}
654 <Text dimColor>/desk hides</Text>
655 </Box>
656 </Box>
657 )
658 })
659}
660hooks/detect.ts 179 lines1// Pure helpers: no `$`, so tests can call them directly.
2
3const VERBS =
4 '(?:proceed|change|touch|write|edit|run|test|verify|execute|check|implement|finish|complete|build|fix|confirm|install|update|add|wire|deploy|migrate|cover|handle|try|reproduce|compile|lint|start|validate|commit|push)'
5
6// Sentences in which Claude admits something was not done.
7const SAID: RegExp[] = [
8 new RegExp(
9 `\\bI(?:'ll| will)? (?:did not|didn't|have not|haven't|could not|couldn't|was unable to|wasn't able to|cannot|can't|did not get to|didn't get to|did not yet|haven't yet|won't|will not|am not going to|didn't)\\s+(?:\\w+\\s+){0,2}${VERBS}`,
10 'i',
11 ),
12 /\bnot (?:yet )?(?:been )?(?:implemented|run|tested|verified|wired up|handled|supported|done|fixed|covered|checked)\b/i,
13 /\b(?:untested|unverified|unimplemented|unresolved|still open|blocked on|waiting (?:on|for) (?:you|your|approval|confirmation))\b/i,
14 /\buntil you (?:say|confirm|approve|decide)\b/i,
15 /\b(?:left (?:as )?(?:a |an )?(?:TODO|stub|placeholder)|stubbed(?: out)?|placeholder (?:implementation|value|logic))\b/i,
16 /\b(?:skipped|skipping|didn't run|did not run)\b/i,
17 /\byou(?:'ll| will)? (?:need|have) to (?:run|install|add|set|configure|update|create|restart|deploy|fill in|replace)\b/i,
18 /\b(?:still needs? to be|remains? to be (?:done|implemented|tested))\b/i,
19 /(?:ยังไม่ได้|ไม่ได้รัน|ไม่ได้ทดสอบ|ไม่ได้ตรวจ|ข้ามไป|ข้ามการ|ยังไม่เสร็จ|ต้องทำต่อ|ค้างไว้|ไว้ทีหลัง|ยังไม่ได้ทำ)/,
20]
21
22// Markers that mark unfinished code when they appear in a newly written line.
23const MARKER = /\b(?:TODO|FIXME|XXX|HACK)\b|NotImplementedError|not implemented|unimplemented!\(/i
24
25const MAX_TEXT = 220
26
27function clip(text: string): string {
28 const one = text.replace(/\s+/g, ' ').trim()
29 return one.length > MAX_TEXT ? one.slice(0, MAX_TEXT - 1) + '…' : one
30}
31
32/** Split an answer into sentences, leaving out fenced code. */
33export function sentences(answer: string): string[] {
34 const prose = answer.replace(/```[\s\S]*?```/g, '\n')
35 return prose
36 .split(/\n+|(?<=[.!?])\s+/)
37 .map(s => s.replace(/^\s*(?:[-*+]|\d+[.)])\s+/, '').replace(/^#+\s*/, '').trim())
38 .filter(s => s.length > 3)
39}
40
41// A line that opens a list of open work: "OPEN", "FLAGGED -", "Remaining:", "## Next steps".
42const HEADING =
43 /^\s*(?:#{1,4}\s*)?(?:\*\*)?(OPEN|FLAGGED|BLOCKED|PENDING|REMAINING|TODO|NOT DONE|LEFT OUT|NEXT STEPS?|FOLLOW[- ]?UPS?|OPEN QUESTIONS|KNOWN ISSUES|CAVEATS?|LIMITATIONS|UNRESOLVED|Open|Flagged|Blocked|Pending|Remaining|Not done|Left out|Next steps?|Follow[- ]?ups?|Open questions|Known issues|Caveats?|Limitations|Unresolved|ยังไม่ได้ทำ|ค้างอยู่|งานค้าง|สิ่งที่เหลือ|ขั้นต่อไป)(?:\*\*)?(?:\s*[::]|\s{2,}|\s+(?=[-•*])|\s*$)(.*)$/
44const BULLET = /^\s*(?:[-•*]|\d+[.)])\s+(.*)$/
45
46/** Bullets listed under a heading such as OPEN, FLAGGED or "Remaining:". */
47export function findSections(answer: string): string[] {
48 const lines = answer.replace(/```[\s\S]*?```/g, '\n').split('\n')
49 const found: string[] = []
50 let isInside = false
51 let label = ''
52 let current: string | null = null
53 const flush = () => {
54 if (current !== null) {
55 const text = clip((label ? label + ': ' : '') + current.replace(/\*\*|__|`/g, ''))
56 if (current.trim().length > 3 && !found.includes(text)) found.push(text)
57 }
58 current = null
59 }
60 for (const line of lines) {
61 const h = HEADING.exec(line)
62 if (h) {
63 flush()
64 isInside = true
65 label = (h[1] ?? '').toUpperCase()
66 const rest = (h[2] ?? '').trim()
67 const b = BULLET.exec(rest)
68 if (b) current = b[1] ?? ''
69 else if (rest.length > 3) current = rest
70 continue
71 }
72 if (!isInside) continue
73 if (line.trim() === '') {
74 flush()
75 isInside = false
76 continue
77 }
78 const b = BULLET.exec(line)
79 if (b) {
80 flush()
81 current = b[1] ?? ''
82 } else if (/^\s+/.test(line) && current !== null) {
83 current += ' ' + line.trim()
84 } else {
85 flush()
86 isInside = false
87 }
88 }
89 flush()
90 return found.slice(0, 12)
91}
92
93/** The sentences of an answer that say something was left undone. */
94export function findSaid(answer: string): string[] {
95 const found: string[] = findSections(answer)
96 const inSections = found.join(' \n ')
97 for (const s of sentences(answer)) {
98 if (SAID.some(re => re.test(s))) {
99 const text = clip(s.replace(/\*\*|__|`/g, ''))
100 const probe = text.slice(0, 40)
101 if (!found.includes(text) && !inSections.includes(probe)) found.push(text)
102 }
103 }
104 return found.slice(0, 12)
105}
106
107/** Lines with TODO-like markers that are in `after` but were not in `before`. */
108export function findMarkers(before: string, after: string): string[] {
109 const old = new Set(before.split('\n').map(l => l.trim()))
110 const found: string[] = []
111 for (const line of after.split('\n')) {
112 const t = line.trim()
113 if (t && MARKER.test(t) && !old.has(t)) {
114 const text = clip(t)
115 if (!found.includes(text)) found.push(text)
116 }
117 }
118 return found.slice(0, 8)
119}
120
121/** 182340 -> "182.3K" */
122export function kTokens(n: number): string {
123 if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M'
124 if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K'
125 return String(Math.round(n))
126}
127
128/** 3725000 -> "1h02m", 192000 -> "3m12s" */
129export function duration(ms: number): string {
130 const s = Math.max(0, Math.floor(ms / 1000))
131 const h = Math.floor(s / 3600)
132 const m = Math.floor((s % 3600) / 60)
133 const sec = s % 60
134 if (h > 0) return `${h}h${String(m).padStart(2, '0')}m`
135 if (m > 0) return `${m}m${String(sec).padStart(2, '0')}s`
136 return `${sec}s`
137}
138
139/** "3:07" for a countdown. */
140export function clock(ms: number): string {
141 const s = Math.max(0, Math.ceil(ms / 1000))
142 const m = Math.floor(s / 60)
143 return `${m}:${String(s % 60).padStart(2, '0')}`
144}
145
146export function ttlMs(ttl: '5m' | '1h'): number {
147 return ttl === '1h' ? 3_600_000 : 300_000
148}
149
150/**
151 * Reads the cache lifetime the last response used from the tail of a
152 * transcript (JSONL): `ephemeral_1h_input_tokens` > 0 means 1h.
153 */
154export function ttlFromTranscriptTail(tail: string): '5m' | '1h' | null {
155 const lines = tail.split('\n').reverse()
156 for (const line of lines) {
157 if (!line.includes('"usage"') || !line.includes('"assistant"')) continue
158 const h1 = /"ephemeral_1h_input_tokens"\s*:\s*(\d+)/.exec(line)
159 const m5 = /"ephemeral_5m_input_tokens"\s*:\s*(\d+)/.exec(line)
160 if (h1 && Number(h1[1]) > 0) return '1h'
161 if (m5 && Number(m5[1]) > 0) return '5m'
162 }
163 return null
164}
165
166/** Clock time "14:02" from epoch ms, in local time. */
167export function hhmm(at: number): string {
168 const d = new Date(at)
169 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
170}
171
172/** 0 fine, 1 getting full, 2 nearly full. */
173export function contextLevel(percent: number | undefined, warnAt: number, urgentAt: number): 0 | 1 | 2 {
174 if (percent === undefined) return 0
175 if (percent >= urgentAt) return 2
176 if (percent >= warnAt) return 1
177 return 0
178}
179types/index.d.ts 57 lines1export type UndoneKind = 'said' | 'file' | 'task'
2
3export type TaskItem = {
4 id: string
5 subject: string
6 status: 'pending' | 'in_progress' | 'completed'
7}
8
9export type UndoneItem = {
10 id: number
11 kind: UndoneKind
12 text: string
13 file?: string
14 at: number
15}
16
17export type CategoryRow = {
18 name: string
19 tokens: number
20 color: string
21 kind: 'used' | 'free' | 'buffer' | 'deferred'
22}
23
24export type UsageSnapshot = {
25 tokens?: number
26 window: number
27 percent?: number
28 usd?: number
29 startedAt?: number
30 categories: CategoryRow[]
31}
32
33export type CacheTtl = '5m' | '1h'
34
35export type CacheState = {
36 lastAt: number | null
37 ttl: CacheTtl | null
38 isObserved: boolean
39 readTokens: number
40 inputTokens: number
41 lastContext: number
42}
43
44declare module 'claude-code' {
45 interface PluginState {
46 'terminal-desk': {
47 undone: UndoneItem[]
48 tasks: TaskItem[]
49 warnLevel: number
50 isBandHidden: boolean
51 nextId: number
52 usage: UsageSnapshot
53 cache: CacheState
54 }
55 }
56}
57