SLOPSHOPPER

terminal-desk

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

newpanebandguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · terminal-desk
│ ┃ terminal-desk ✕ › fix the failing auth test and add an audit log call │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ Left undone h: how it works │ ⏺ Read(src/auth.ts) │ ┃ │ Nothing left undone. Press h to see what │ ⎿ Read 6 lines │ ┃ │ gets caught. │ ⏺ Update(src/auth.ts) │ ┃ ╰──────────────────────────────────────────╯ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ╭─────────────────────────────────────────── ⎿ 3 pass, 1 fail │ ┃ │ Where your context went │ ┃ │ 49% used · 97.4K / 200.0K ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ██████████████████████████░░░░░░░░░░░░░░░░ │ ┃ ╰─────────────────────────────────────────── ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ ╭──────────────────────────────────────────╮ › /desk │ ┃ │ Prompt cache │ │ ┃ │ ● warm · goes cold in 5:00 (5m cache) │ │ ┃ │ Next message reads ~97.4K from cache: │ │ ┃ │ cheap. │ │ ┃ │ Hit ratio this session: 93% │ │ ┃ ╰──────────────────────────────────────────╯ │ ┃ │ ┃ $0.42 ⏱ 30m00s /desk hides │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ terminal-desk: desk · ctx 49% · $0.42 · cache warm 5:00

Draws

Pane · terminal-desk
╭──────────────────────────────────────────────────────────╮ │ Left undone h: how it works │ │ Nothing left undone. Press h to see what gets caught. │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ Where your context went │ │ 49% used · 97.4K / 200.0K │ │ ██████████████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ Prompt cache │ │ ● warm · goes cold in 5:00 (5m cache) │ │ Next message reads ~97.4K from cache: cheap. │ │ Hit ratio this session: 93% │ ╰──────────────────────────────────────────────────────────╯ $0.42 ⏱ 30m00s /desk hides
README

terminal-desk

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.

terminal-desk demo: the desk pane beside a Claude Code session

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

What is this?

Long Claude Code sessions hide three things you usually only notice when it's too late:

  1. Work that quietly didn't happen. Claude writes "I didn't run the tests" halfway down a long answer, leaves a // 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.
  2. A context window that keeps filling. As the conversation grows, every message re-sends more tokens, answers slow down, cost goes up and Claude gets less sharp. Nothing tells you when to start fresh.
  3. A prompt cache you can't see. The same message can cost a few cents or a few dollars depending on whether the cache is still warm (see Warm vs. cold prompt cache).

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.

terminal-desk screenshot: Left undone, context breakdown, prompt cache and a red "context nearly full" warning

Features

Left undone

After every turn, the desk collects unfinished work from three sources:

SourceWhat it catchesLabel in the pane
What Claude saidAdmissions 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 editedLines 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 listItems 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:

KeyAction
✓Dismiss one item
xClear the whole list (or run /desk-clear)
fAsk Claude to finish: types a request listing every open item into your prompt. Nothing is sent until you press Enter.
hShow 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.

Where your context went

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.

Context warning: time to /clear

Context usedWhat 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:

  • Type /clear and Type /compact: put the command in your prompt. Nothing runs until you press Enter.
  • Hide: hides the band until the next level.

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.

Prompt cache

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.

Always-on status line

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.

Warm vs. cold prompt cache

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.

  • Warm cache (hot prompt): you send the next message while the cache is still alive. Claude reads the conversation from cache at about a tenth of the normal input price (less on some models), and the reply starts faster.
  • Cold cache (cold prompt): the cache expired, for example because you stepped away for more than 5 minutes. The next message has to write the entire context to the cache again, at 1.25× the normal input price (2× for the 1-hour cache), and it takes longer to start.

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

Install

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.

Usage

CommandWhat it does
/deskShow or hide the pane
/desk-clearEmpty the Left undone list
  • The pane opens by itself when a session starts if the terminal is wide enough (about 144 columns). Otherwise run /desk and it opens above the prompt.
  • In Claude Code's fullscreen layout the pane docks as a sidebar on the right, as in the screenshot.
  • Works in the terminal and in the Code tab of the Claude desktop app.

Settings

/plugin → terminal-desk → configure:

SettingDefaultMeaning
autoOpentrueOpen the pane when a session starts
cacheTtlautoCache lifetime: auto (read from the transcript), 5m or 1h
warnPercent60Context % for the yellow warning
urgentPercent80Context % for the red warning

Update

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.

Uninstall

claude plugin uninstall terminal-desk@terminal-desk

If you installed with install.sh, use bash ~/.claude/mods/terminal-desk/install.sh --remove.

Develop

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
PathWhat's there
hooks/register.tsxThe pane, the warning band, the status line, /desk and every event hook
hooks/detect.tsThe "Left undone" patterns and small formatting helpers
types/index.d.tsThe mod's session state
tests/desk.test.tsDetection, pane, warning and task-list tests on the terminal and desktop surfaces
.claude-plugin/Plugin manifest, settings and the marketplace entry

Notes

  • Costs are Claude Code's own estimates at list price, the same figure /cost shows, and can differ from your bill.
  • A mod runs with your permissions inside Claude Code. Everything this one does is in 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.

License

MIT: free to use, modify and share, including in commercial projects, as long as the copyright notice stays with the code.

Source 3 files
hooks/register.tsx 660 lines
1import { 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}
660
hooks/detect.ts 179 lines
1// 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}
179
types/index.d.ts 57 lines
1export 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