A collapsible task history of the current session in a side pane: each prompt, its result, open questions, files and duration.

A Claude Code mod that keeps a compact, collapsible history of the tasks you gave the current session, in a pane beside the chat. Each entry shows what you asked, what came of it, what Claude is waiting for, which files changed and how long it took, so you can keep track of a long session (or several sessions side by side) without scrolling back through the whole transcript.
╭ Tasks · my-project ──────────────────────╮
│ ▸ ⟳ Build task history mod 2m14s│ ← running now, live
│ 5 tools · 1 file │
│ ▸ ? Fix the login bug 12m│ ← Claude is waiting for you
│ Add a regression test as well? │ ← click: a reply goes into the prompt
│ ▾ ✓ Translate the README 40m│ ← expanded
│ → Translated to English, FAQ │
│ shortened │
│ done · 14:02–14:03 · 1m12s · 6 tools │
│ ↑ Show in conversation │ ← scrolls the chat to the prompt
│ ▸ 2 files changed │ ← expands to the paths
│ ▸ Prompt │ ← expands to the full prompt
│ ↳ “also make the FAQ shorter” │ ← a follow-up, grouped
│ ── Yesterday ── │ ← earlier days under their day
│ ▸ ✓ Update composer packages 17:45│ ← with the clock time
│ → 3 packages updated, tests pass │ ← the result, collapsed too
╰──────────────────────────────────────────╯
resultLines turns that off). Once the list reaches back past today, each day gets a heading (Yesterday, Fri 24 Oct).14:02–14:03), duration and tool calls. The changed files and the full original prompt each fold out one level deeper.⟳ running, ✓ done, ? Claude ended with a question and waits for you, ✗ interrupted or failed. A waiting task shows its open question even when collapsed.✓ at once, and its details keep the question as ✓ <question>. This holds when your answer becomes a task of its own, and without summaries too.↳ lines instead of becoming tasks of their own.? <question> when Claude waits for you, ✓ <title> – <result> when it is done. Handy when several sessions run side by side.⟳ Fix the login bug · 1m12s), so it stays visible with the pane closed.Edit, Write and NotebookEdit, the mod compares git status before and after each turn, so files changed by shell commands (sed, mv, a formatter, a generator) are listed too.claude --resume brings it back. A summary cut off by a reload or update is asked for again. A session that ran before the mod was on is rebuilt from its transcript (raw text, no summaries)./history.At the prompt of a Claude Code terminal session:
/plugin install task-history --marketplace crysix/claude-task-history
Answer y to add the marketplace, then pick a scope (the user scope makes it available in every session).
From a shell:
claude plugin install task-history --marketplace crysix/claude-task-history
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with crysix/claude-task-history, then Add plugin. A mod installed from a terminal at the user scope loads in the desktop app's local sessions too, as long as both use the same Claude Code configuration.
Which machine counts: a session loads the plugins of the machine it runs on. When the desktop app runs its sessions in WSL or on a remote host over SSH, install the mod there (with /plugin install in a terminal on that machine); the desktop app's own plugin settings do not list it. A session the app runs natively on Windows or macOS uses that system's Claude Code configuration.
Updating: on the machine the sessions run on, claude plugin update task-history@claude-task-history, then start a new session (or /reload-plugins in a running one).
| Command | What it does |
|---|---|
/history | Show or hide the pane |
/history open | Show the pane |
/history close | Hide the pane |
/history clear | Empty the history of the current session |
/history runs at once, even while Claude is working.
Mouse: in the terminal and the desktop app, click a task to expand or collapse it, and click files changed or Prompt to fold those out. Click an open point to put a reply into the prompt, and Show in conversation to scroll the chat to the task's prompt. In VS Code, click the ▸ / ▾ link in front of the row, and the other links the same way; a click on one opens no browser.
The desktop app's own buttons flicker under the pointer, so there the mod draws its rows itself (hooks/row.tsx, a surface module) and handles the click. If that module cannot run, the pane falls back to the links. Show in conversation is a link there in any case: only a click of yours may scroll the chat, and the module's presses come from its code.
Keyboard: give the pane the keyboard with ctrl+x then tab (or a click). Tab walks the rows, Enter expands or collapses, the arrow keys scroll and Esc returns to the prompt.
If you close the pane by hand, it stays closed in later sessions until you open it again with /history.
The mod listens to the session's own events. Nothing is scraped from the screen.
| Event | What the mod does |
|---|---|
prompt.submit | Remembers what you typed, slash commands as typed (/review 42) |
session.append | Notes the transcript id of your prompt, for Show in conversation |
turn.start | Starts a new task, or adds a follow-up to the last task |
tool.call | Counts tool calls and collects the files Edit, Write and NotebookEdit changed |
turn.complete | Records end time, duration, status and the files git status shows as changed, then asks for the summary |
What counts as a new task. Every prompt you send starts a turn. A turn is added to the last task as a follow-up when:
ja, ok, yes, mach weiter, go ahead, a single letter or number, ...), orTurns Claude starts on its own (a background task finishing, for example) continue the task that is there. Subagents never become tasks; their tool calls count towards the task that started them.
Storage. The tasks of the running session are held in the mod's session state. They are saved in the mod's own store (a JSON file in your Claude Code configuration folder) under the session's id, for the last 30 sessions. Prompts are kept up to 4,000 characters.
| Option | Default | Meaning |
|---|---|---|
summaries | true | Summarize finished tasks with a model. false: raw text, no model calls |
summaryModel | haiku | The model alias or id used for summaries |
notify | all | Notifications for tasks of 20 s or more: all, waiting (only when Claude asks you something), off |
resultLines | true | Show each task's one-line result under its collapsed row. false: one line per task |
statusLine | true | Show the running or waiting task in the status line |
gitFiles | true | Find changed files through git status before and after each turn |
You set these when you install the mod, or later in /config. They are stored in your settings.json under pluginConfigs.
Claude Code has notifications of its own (when it waits for your input). If you get two, set notify to off or waiting.
One small model call is made per finished turn, through the session's own API client and credentials (the same provider and account as the session itself). It sends the request (up to about 3,000 characters), your follow-ups, the previous task's title and result, and an excerpt of Claude's final reply (up to about 4,000 characters), and asks for at most 500 tokens back. With Haiku that is a fraction of a cent per task. Set summaries to false to make no calls at all.
↳ line under the task it was merged into..claude-plugin/plugin.json manifest and options
.claude-plugin/marketplace.json makes this repository installable
hooks/hooks.json names the hooks module
hooks/register.tsx events, persistence, the pane
hooks/logic.ts grouping, summaries, formatting (pure)
hooks/row.tsx the desktop app's pane rows (a surface module)
types/index.d.ts the task type and the mod's state contract
tests/task-history.test.tsx tests on the terminal, desktop and VS Code surfaces
claude plugin validate . # what the engine reads and would refuse
claude plugin test . # run the tests
claude --plugin-dir . # a session with the mod loaded from this folder
A session started with --plugin-dir reloads the mod when you save a file.
Made by crysix.
hooks/register.tsx 931 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Task, TaskStatus } from '../types'
5import type { NotifyLevel } from './logic'
6import type { Part } from './row'
7import {
8 addTask,
9 applySummary,
10 changedPaths,
11 clockTime,
12 cut,
13 dayLabel,
14 escapeMarkdown,
15 excerpt,
16 firstLine,
17 formatAge,
18 formatDuration,
19 isAck,
20 isSameDay,
21 markReplied,
22 newTask,
23 normalize,
24 notification,
25 oneLine,
26 parseGitStatus,
27 patch,
28 parseSummary,
29 projectName,
30 promptKey,
31 relativePath,
32 statusText,
33 SUMMARY_SYSTEM,
34 summaryPrompt,
35 tasksFromMessages,
36 timeSpan,
37} from './logic'
38
39const PANE = 'task-history'
40const COMMAND = 'history'
41const SESSIONS_KEY = 'sessions'
42const OPEN_KEY = 'isOpen'
43const KEEP_SESSIONS = 30
44const TICK_MS = 15000
45/** A turn shorter than this raises no notification: the person is still looking. */
46const NOTIFY_AFTER_MS = 20000
47
48/** Turns the person did not ask for: they carry on the task that is there. */
49const CONTINUATIONS = new Set(['task-notification', 'auto-continuation', 'observer', 'observer-activity'])
50
51const ICONS: Record<TaskStatus, { glyph: string; color: string; word: string }> = {
52 running: { glyph: '⟳', color: 'claude', word: 'running' },
53 done: { glyph: '✓', color: 'success', word: 'done' },
54 waiting: { glyph: '?', color: 'warning', word: 'waiting for you' },
55 aborted: { glyph: '✗', color: 'inactive', word: 'interrupted' },
56 error: { glyph: '✗', color: 'error', word: 'failed' },
57}
58
59const tasks = atom({ plugin: 'task-history', key: 'tasks' } as const, [])
60const activeId = atom({ plugin: 'task-history', key: 'activeId' } as const, null)
61const sessionId = atom({ plugin: 'task-history', key: 'sessionId' } as const, '')
62const expanded = atom({ plugin: 'task-history', key: 'expanded' } as const, [])
63const tick = atom({ plugin: 'task-history', key: 'tick' } as const, 0)
64
65type Engine = EngineInterface
66
67type Submitted = { text: string; isPrompt: boolean; isCommand: boolean; at: number }
68
69function cutStart(text: string, length: number): string {
70 return text.length > length ? `…${text.slice(text.length - length + 1)}` : text
71}
72
73// Module state: a reload starts it over, which costs nothing worth keeping.
74let model = 'haiku'
75let isSummarizing = true
76let notifyLevel: NotifyLevel = 'all'
77let isStatusLine = true
78let isGitTracking = true
79let isResultLine = true
80// The repository's top folder: unknown until asked, null outside a repository.
81let gitRoot: string | null | undefined
82// `git status` as the running turn found it at its start.
83let gitBefore: { turnId: string; entries: Map<string, string> } | undefined
84// Prompts typed while a turn ran: they belong to that turn's task.
85const folded = new Set<string>()
86// The last prompt sent while idle, which the next turn starts with.
87let submitted: Submitted | undefined
88let wantsPane = true
89let hasAutoOpened = false
90let hasLoggedFailure = false
91// Surfaces where the pane's row module failed: they get links instead.
92const faulted = new Set<string>()
93// Surfaces whose conversation cannot be scrolled to a prompt: they show no jump.
94const jumpless = new Set<string>()
95// The transcript rows of prompts no task has taken yet, newest last.
96const promptRows: { key: string; uuid: string; at: number }[] = []
97/** A prompt's row is kept just before its turn starts; one older than this belongs to an earlier prompt. */
98const PROMPT_ROW_MS = 60000
99let cwd = ''
100
101function paneTitle() {
102 return `Tasks · ${projectName(cwd)}`
103}
104
105async function persist($: Engine) {
106 const id = await read($, sessionId)
107
108 if (!id) {
109 return
110 }
111
112 const list = await read($, tasks)
113 const stored = await $.store.get(SESSIONS_KEY)
114 const listed = Array.isArray(stored) ? stored.filter((one): one is string => typeof one === 'string') : []
115 // A session saved while another process rewrote the index is missing from it: it counts as the oldest.
116 const unlisted = (await $.store.keys())
117 .filter(key => key.startsWith('session:'))
118 .map(key => key.slice('session:'.length))
119 .filter(one => !listed.includes(one))
120 const known = [...unlisted, ...listed]
121
122 if (list.length === 0 && !known.includes(id)) {
123 return
124 }
125
126 const sessions = [...known.filter(one => one !== id), id]
127
128 for (const old of sessions.slice(0, -KEEP_SESSIONS)) {
129 await $.store.delete(`session:${old}`)
130 }
131
132 try {
133 await $.store.set(`session:${id}`, list)
134 } catch {
135 // The store is full: let the oldest sessions go and try once more.
136 for (const old of sessions.slice(0, Math.ceil(sessions.length / 2))) {
137 if (old !== id) {
138 await $.store.delete(`session:${old}`)
139 }
140 }
141
142 await $.store.set(`session:${id}`, list)
143 }
144
145 await $.store.set(SESSIONS_KEY, sessions.slice(-KEEP_SESSIONS))
146}
147
148/** Loads the tasks of the session running now, after a start, a /clear or a resume. */
149async function syncSession($: Engine) {
150 const id = await $.session.id()
151
152 if (id === (await read($, sessionId))) {
153 return
154 }
155
156 const stored = await $.store.get(`session:${id}`)
157 let list: Task[] = Array.isArray(stored) ? (stored as Task[]) : []
158
159 if (list.length === 0) {
160 list = tasksFromMessages(await $.session.messages(), await $.clock.now())
161 }
162
163 // Nothing runs in a session that was just loaded.
164 list = list.map(task => (task.status === 'running' ? { ...task, status: 'aborted', runningSince: null } : task))
165
166 await update($, sessionId, () => id)
167 await update($, tasks, () => list)
168 await update($, activeId, () => null)
169 await update($, expanded, () => [])
170 await updateStatus($)
171}
172
173async function resumeTask($: Engine, id: string, now: number, isReply: boolean) {
174 await update($, tasks, list =>
175 patch(list, id, task => ({ ...(isReply ? markReplied(task) : task), status: 'running', runningSince: now })),
176 )
177 await update($, activeId, () => id)
178}
179
180async function addFollowUp($: Engine, id: string, text: string, now: number) {
181 await update($, tasks, list =>
182 patch(list, id, task => ({ ...task, followUps: [...task.followUps, { text: text.slice(0, 2000), at: now }] })),
183 )
184}
185
186async function summarize($: Engine, id: string, answer: string, turnMs: number) {
187 const list = await read($, tasks)
188 const index = list.findIndex(task => task.id === id)
189 const task = list[index]
190
191 if (task === undefined) {
192 return
193 }
194
195 const previous = task.needsGrouping && index > 0 ? list[index - 1] : undefined
196 let target = id
197
198 try {
199 const reply = await $.model.complete({
200 model,
201 system: SUMMARY_SYSTEM,
202 prompt: summaryPrompt(task, answer, previous),
203 maxTokens: 500,
204 effort: 'low',
205 timeoutMs: 30000,
206 })
207
208 if (!reply.isAnswered) {
209 throw new Error(reply.reason)
210 }
211
212 const summary = parseSummary(reply.text)
213
214 if (summary === undefined) {
215 throw new Error('the reply held no summary')
216 }
217
218 let mergedInto: string | undefined
219
220 await update($, tasks, current => {
221 const applied = applySummary(current, id, summary)
222 mergedInto = applied.mergedInto
223
224 return applied.tasks
225 })
226
227 const merged = mergedInto
228
229 if (merged !== undefined) {
230 target = merged
231 await update($, activeId, current => (current === id ? merged : current))
232 await update($, expanded, keys => keys.map(key => (key === id ? merged : key)))
233 }
234 } catch (error) {
235 if (!hasLoggedFailure) {
236 hasLoggedFailure = true
237 $.ui.log(`task-history: no summary (${error instanceof Error ? error.message : String(error)}); raw text stays`)
238 }
239 }
240
241 // Done either way: a summary that failed is not asked again.
242 await update($, tasks, current => patch(current, target, one => ({ ...one, pendingAnswer: null })))
243 await persist($)
244 await updateStatus($)
245 await notify($, target, turnMs)
246}
247
248/** Asks again for the summaries a reload cut off (its timers go with the old module). */
249async function retryPending($: Engine) {
250 for (const task of await read($, tasks)) {
251 const answer = task.pendingAnswer
252
253 if (isSummarizing && typeof answer === 'string' && answer && task.status !== 'running') {
254 $.clock.after(1, () => void summarize($, task.id, answer, 0))
255 }
256 }
257}
258
259async function updateStatus($: Engine) {
260 if (!isStatusLine) {
261 return
262 }
263
264 const list = await read($, tasks)
265 const id = await read($, activeId)
266 const task = list.find(one => one.id === id) ?? list.at(-1)
267
268 $.ui.status(statusText(task, await $.clock.now()))
269}
270
271async function notify($: Engine, id: string, turnMs: number) {
272 if (turnMs < NOTIFY_AFTER_MS) {
273 return
274 }
275
276 const task = (await read($, tasks)).find(one => one.id === id)
277 // A task the person replied to already needs no notification: they have seen it.
278 const text = task === undefined || task.isReplied === true ? undefined : notification(task, notifyLevel)
279
280 if (text !== undefined) {
281 await $.ui.notify(text, { title: `Claude · ${projectName(cwd)}` })
282 }
283}
284
285async function gitStatus($: Engine): Promise<Map<string, string> | undefined> {
286 if (!isGitTracking) {
287 return undefined
288 }
289
290 try {
291 if (gitRoot === undefined) {
292 const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { timeoutMs: 5000 })
293 gitRoot = top.exitCode === 0 && top.stdout.trim() ? top.stdout.trim().replace(/\\/g, '/') : null
294 }
295
296 if (gitRoot === null) {
297 return undefined
298 }
299
300 const status = await $.process.run(['git', 'status', '--porcelain=v1', '-z', '--untracked-files=all'], {
301 cwd: gitRoot,
302 timeoutMs: 5000,
303 })
304
305 return status.exitCode === 0 && !status.isStdoutTruncated ? parseGitStatus(status.stdout) : undefined
306 } catch {
307 return undefined
308 }
309}
310
311async function snapshotGit($: Engine, turnId: string) {
312 gitBefore = undefined
313 const entries = await gitStatus($)
314
315 if (entries !== undefined) {
316 gitBefore = { turnId, entries }
317 }
318}
319
320/** The files the turn changed by any means, Bash included, by `git status` before and after. */
321async function gitChanges($: Engine, turnId: string): Promise<string[]> {
322 const before = gitBefore
323 gitBefore = undefined
324
325 if (before === undefined || before.turnId !== turnId) {
326 return []
327 }
328
329 const after = await gitStatus($)
330 const root = gitRoot
331
332 return after === undefined || typeof root !== 'string'
333 ? []
334 : changedPaths(before.entries, after).map(path => `${root}/${path}`)
335}
336
337async function toggleRow($: Engine, key: string) {
338 await update($, expanded, keys => (keys.includes(key) ? keys.filter(one => one !== key) : [...keys, key]))
339}
340
341async function fillPrompt($: Engine, id: string) {
342 const task = (await read($, tasks)).find(one => one.id === id)
343
344 if (task !== undefined) {
345 await $.prompt.fill({ text: task.prompt })
346 }
347}
348
349/** Puts what takes up an open point into the prompt: the summary's reply for it, else the point itself. */
350async function fillPoint($: Engine, id: string, index: number) {
351 const task = (await read($, tasks)).find(one => one.id === id)
352 const text = task?.openPrompts?.[index] || task?.openPoints[index]
353
354 if (text) {
355 await $.prompt.fill({ text })
356 }
357}
358
359/** Gives the task running now the transcript row of its prompt, or keeps the row for the task about to start. */
360async function linkPrompt($: Engine, key: string, uuid: string) {
361 const id = await read($, activeId)
362 const task = (await read($, tasks)).find(one => one.id === id)
363
364 if (task !== undefined && task.messageId === undefined && promptKey(task.prompt) === key) {
365 await update($, tasks, list => patch(list, task.id, one => ({ ...one, messageId: uuid })))
366
367 return
368 }
369
370 promptRows.push({ key, uuid, at: await $.clock.now() })
371
372 if (promptRows.length > 10) {
373 promptRows.shift()
374 }
375}
376
377/** The transcript id of the newest recent prompt row with this key, taken off the list. */
378function takePromptRow(key: string, now: number): string | undefined {
379 const index = promptRows.findLastIndex(row => row.key === key && now - row.at < PROMPT_ROW_MS)
380
381 return index < 0 ? undefined : promptRows.splice(index, 1)[0]?.uuid
382}
383
384/** Scrolls the conversation to the prompt that started a task; where it cannot, says so and stops offering it. */
385async function jump($: Engine, surface: string, id: string) {
386 const messageId = (await read($, tasks)).find(one => one.id === id)?.messageId
387
388 if (messageId === undefined) {
389 return
390 }
391
392 let deny: string | undefined
393 let isUnsupported = false
394
395 try {
396 deny = (await $.ui.scroll({ to: { requestId: messageId }, block: 'start' })).deny
397 isUnsupported = deny !== undefined && /no-surface|none scrolls|not person-initiated/.test(deny)
398 } catch (error) {
399 deny = error instanceof Error ? error.message : String(error)
400 isUnsupported = true
401 }
402
403 if (deny === undefined) {
404 return
405 }
406
407 $.ui.toast(`task-history: cannot show the prompt in the conversation (${deny})`)
408
409 if (isUnsupported) {
410 jumpless.add(surface)
411 $.ui.invalidate('ui.render')
412 }
413}
414
415async function refresh($: Engine) {
416 const now = await $.clock.now()
417 const isRunning = (await read($, tasks)).some(task => task.status === 'running')
418
419 if (isRunning || now - (await read($, tick)) >= 60000) {
420 await update($, tick, () => now)
421 }
422
423 if (isRunning) {
424 await updateStatus($)
425 }
426}
427
428export const register: Register = (on, options) => {
429 isSummarizing = options.summaries !== false
430 model =
431 typeof options.summaryModel === 'string' && options.summaryModel.trim() ? options.summaryModel.trim() : 'haiku'
432 notifyLevel = options.notify === 'waiting' || options.notify === 'off' ? options.notify : 'all'
433 isStatusLine = options.statusLine !== false
434 isGitTracking = options.gitFiles !== false
435 isResultLine = options.resultLines !== false
436
437 on('session.start', async ($, e, next) => {
438 cwd = e.cwd
439 await $.command.register({
440 name: COMMAND,
441 description: 'Show or hide the task history pane',
442 argumentHint: '[open|close|clear]',
443 immediate: true,
444 })
445 wantsPane = (await $.store.get(OPEN_KEY)) !== false
446 await syncSession($)
447 await retryPending($)
448 await updateStatus($)
449 $.clock.every(TICK_MS, () => void refresh($))
450
451 return next(e)
452 })
453
454 on('session.end', async ($, e, next) => {
455 await persist($)
456
457 if (e.reason === 'clear' || e.reason === 'resume') {
458 // The process goes on under another session: load its tasks once it has.
459 await update($, sessionId, () => '')
460 await update($, tasks, () => [])
461 await update($, activeId, () => null)
462 $.clock.after(500, () => void syncSession($))
463 }
464
465 return next(e)
466 })
467
468 on('prompt.submit', async ($, e, next) => {
469 const isOwnCommand = e.command?.name === COMMAND
470
471 if (!isOwnCommand) {
472 const now = await $.clock.now()
473 const text = e.command ? `/${e.command.name}${e.command.args ? ` ${e.command.args}` : ''}` : e.text
474 const isPrompt = e.isMeta !== true && !CONTINUATIONS.has(e.origin.kind)
475 const running = e.turnId !== undefined && !e.wait ? await read($, activeId) : null
476
477 if (running !== null) {
478 if (isPrompt) {
479 folded.add(normalize(e.text))
480 await addFollowUp($, running, text, now)
481 }
482 } else {
483 submitted = { text, isPrompt, isCommand: e.command !== undefined, at: now }
484 }
485 }
486
487 return next(e)
488 }).catch(($, e, next) => next(e))
489
490 // The row a prompt is kept as: its id is what the conversation scrolls back to.
491 // The row is stored by `next` alone, so a failure after it costs the jump and nothing else.
492 on('session.append', { door: 'prompt' }, async ($, e, next) => {
493 const stored = await next(e)
494
495 if (e.agentId === undefined && e.message.type === 'user' && e.message.isMeta !== true) {
496 const text = e.message.content.map(block => (typeof block.text === 'string' ? block.text : '')).join('\n')
497 await linkPrompt($, promptKey(text), e.uuid).catch(() => undefined)
498 }
499
500 return stored
501 })
502
503 on('turn.start', async ($, e, next) => {
504 if (e.agentId !== undefined) {
505 return next(e)
506 }
507
508 await syncSession($)
509 await snapshotGit($, e.turnId)
510
511 const now = await $.clock.now()
512 const typed = normalize(e.text)
513 const entry = submitted
514 submitted = undefined
515
516 const matched =
517 entry !== undefined && (normalize(entry.text) === typed || (entry.isCommand && now - entry.at < 15000))
518 ? entry
519 : undefined
520 const last = (await read($, tasks)).at(-1)
521 const text = matched?.text ?? e.text
522 // Whatever the person sends next answers what the task in front waits on.
523 const isReply = matched?.isPrompt !== false
524
525 if (folded.delete(typed) || typed === '' || !isReply) {
526 if (last !== undefined) {
527 await resumeTask($, last.id, now, isReply)
528 }
529 } else if (last !== undefined && isAck(text)) {
530 await addFollowUp($, last.id, text, now)
531 await resumeTask($, last.id, now, true)
532 } else {
533 const messageId = takePromptRow(promptKey(text), now)
534 const task = { ...newTask(e.turnId, text, now, last !== undefined), ...(messageId !== undefined && { messageId }) }
535 await update($, tasks, list => addTask(last === undefined ? list : patch(list, last.id, markReplied), task))
536 await update($, activeId, () => task.id)
537 }
538
539 await updateStatus($)
540
541 return next(e)
542 }).catch(($, e, next) => next(e))
543
544 on('tool.call', async ($, e, next) => {
545 const ran = await next(e)
546 const id = await read($, activeId)
547
548 if (id === null) {
549 return ran
550 }
551
552 const path = e.tool === 'Edit' || e.tool === 'Write' ? e.file_path : e.tool === 'NotebookEdit' ? e.notebook_path : undefined
553 const changed = path !== undefined && ran.deny === undefined && ran.isError !== true ? path : undefined
554
555 await update($, tasks, list =>
556 patch(list, id, task => ({
557 ...task,
558 tools: task.tools + 1,
559 files: changed !== undefined && !task.files.includes(changed) ? [...task.files, changed] : task.files,
560 })),
561 )
562
563 return ran
564 }).catch(($, e, next) => next(e))
565
566 on('turn.complete', async ($, e, next) => {
567 const result = await next(e)
568
569 if (e.agentId !== undefined) {
570 return result
571 }
572
573 const id = await read($, activeId)
574
575 if (id === null) {
576 return result
577 }
578
579 const endsAsking = /\?\s*$/.test(e.answer.trim())
580 const status: TaskStatus = e.isAborted ? 'aborted' : e.reason === 'answer' ? (endsAsking ? 'waiting' : 'done') : 'error'
581 const line = firstLine(e.answer)
582 const isOwed = isSummarizing && e.answer.trim().length > 0
583 const touched = await gitChanges($, e.turnId)
584 const now = await $.clock.now()
585
586 await update($, tasks, list =>
587 patch(list, id, task => ({
588 ...task,
589 status,
590 endedAt: now,
591 runningSince: null,
592 durationMs: task.durationMs + e.durationMs,
593 result: isSummarizing && task.isSummarized ? task.result : line || task.result,
594 files: [...new Set([...task.files, ...touched])],
595 isReplied: false,
596 pendingAnswer: isOwed ? excerpt(e.answer, 1500, 2500) : null,
597 })),
598 )
599 await update($, activeId, current => (current === id ? null : current))
600 await persist($)
601 await updateStatus($)
602
603 if (isOwed) {
604 const answer = e.answer
605 $.clock.after(1, () => void summarize($, id, answer, e.durationMs))
606 } else {
607 await notify($, id, e.durationMs)
608 }
609
610 return result
611 }).catch(($, e, next) => next(e))
612
613 on('command.run', { command: COMMAND }, async ($, e) => {
614 const arg = e.args.trim().toLowerCase()
615
616 if (arg === 'clear') {
617 await update($, tasks, () => [])
618 await update($, activeId, () => null)
619 await update($, expanded, () => [])
620 await persist($)
621 await updateStatus($)
622
623 return { text: 'Task history cleared.' }
624 }
625
626 const isUp = (await $.ui.panes()).some(pane => pane.id === PANE)
627
628 if (arg === 'close' || (isUp && arg !== 'open')) {
629 wantsPane = false
630 await $.store.set(OPEN_KEY, false)
631 await $.ui.close({ id: PANE })
632
633 return { text: `Task history hidden. /${COMMAND} shows it again.` }
634 }
635
636 wantsPane = true
637 await $.store.set(OPEN_KEY, true)
638 const opened = await $.ui.open({ id: PANE, title: paneTitle() })
639
640 return { text: opened.isPlaced ? 'Task history shown.' : `Task history is open, not drawn yet: ${opened.reason}` }
641 })
642
643 // A press on a row the row module drew: `insert:<id>` fills the prompt with the task's,
644 // `point:<id>:<n>` with what takes up an open point, any other key folds.
645 on('ui.message', async ($, e, next) => {
646 const data: unknown = e.data
647 const isPress = typeof data === 'object' && data !== null && 'press' in data && data.press === true
648 const key = e.element
649
650 if (e.requestId === PANE && isPress) {
651 if (key.startsWith('insert:')) {
652 await fillPrompt($, key.slice('insert:'.length))
653 } else if (key.startsWith('point:')) {
654 const at = key.lastIndexOf(':')
655 await fillPoint($, key.slice('point:'.length, at), Number(key.slice(at + 1)))
656 } else {
657 await toggleRow($, key)
658 }
659 }
660
661 return next(e)
662 }).catch(($, e, next) => next(e))
663
664 on('ui.fault', ($, e, next) => {
665 if (e.requestId === PANE && !faulted.has(e.surface)) {
666 faulted.add(e.surface)
667 $.ui.log(`task-history: rows on ${e.surface} fell back to links (${e.reason})`)
668 }
669
670 return next(e)
671 })
672
673 on('ui.close', { id: PANE }, async ($, e, next) => {
674 if (e.origin.kind === 'person') {
675 wantsPane = false
676 await $.store.set(OPEN_KEY, false)
677 }
678
679 return next(e)
680 }).catch(($, e, next) => next(e))
681
682 // Opens the pane by itself only where it docks as a sidebar.
683 on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => {
684 const isFullscreen = e.viewport?.isFullscreen
685 const docks = isFullscreen === true || (e.surface === 'desktop' && isFullscreen !== false)
686
687 if (wantsPane && !hasAutoOpened && docks) {
688 hasAutoOpened = true
689 void $.ui.open({ id: PANE, title: paneTitle() })
690 }
691
692 return next(e)
693 })
694
695 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
696 const elements = $.ui.resolve(e)
697 const { Box, Button, Markdown, Text } = elements
698 const list = await read($, tasks)
699 const open = new Set(await read($, expanded))
700 await read($, tick)
701 const now = await $.clock.now()
702 const width = Math.max(24, e.props.bodyColumns)
703 const { surface } = e
704
705 const toggle = (key: string) => () => void toggleRow($, key)
706 const insert = (task: Task) => () => void fillPrompt($, task.id)
707 const takeUp = (task: Task, index: number) => () => void fillPoint($, task.id, index)
708 const jumpTo = (task: Task) => () => void jump($, surface, task.id)
709
710 // The terminal makes a whole row one Button. The desktop app's buttons
711 // flicker under the pointer, plain or not, though nothing is redrawn: there
712 // the row module (row.tsx) draws each row as text and posts its presses.
713 // Elsewhere (VS Code draws no Client, or the module failed) a press is a link.
714 const isRowButton = surface === 'terminal'
715 const Client = surface === 'desktop' && 'Client' in elements && !faulted.has(surface) ? elements.Client : undefined
716
717 const row = <T,>(key: string, parts: Part[], fallback: () => T) =>
718 Client ? <Client key={key} module="./row.tsx" props={{ parts }} width="100%" /> : fallback()
719
720 const link = (key: string, label: string, onPress: () => void, isDim = false) => {
721 const href = `https://task-history.invalid/${encodeURIComponent(key)}`
722
723 return (
724 <Markdown
725 key={key}
726 text={`[${escapeMarkdown(label)}](${href})`}
727 dimColor={isDim}
728 pressableLinks={[href]}
729 onLinkPress={onPress}
730 />
731 )
732 }
733
734 const fold = (key: string, isOpen: boolean, label: string) =>
735 isRowButton ? (
736 <Button key={key} plain dimColor onPress={toggle(key)}>
737 {isOpen ? '▾ ' : '▸ '}
738 {label}
739 </Button>
740 ) : (
741 row(key, [{ text: `${isOpen ? '▾' : '▸'} ${label}`, isDim: true }], () =>
742 link(key, `${isOpen ? '▾' : '▸'} ${label}`, toggle(key), true),
743 )
744 )
745
746 // An open point: a press puts what takes it up into the prompt.
747 const point = (task: Task, index: number, text: string) => {
748 const key = `point:${task.id}:${index}`
749
750 return isRowButton ? (
751 <Button key={key} plain onPress={takeUp(task, index)}>
752 <Text color="warning">{text}</Text>
753 </Button>
754 ) : (
755 row(key, [{ text, color: 'warning' }], () => link(key, text, takeUp(task, index)))
756 )
757 }
758
759 // Only the person's own press may scroll the conversation, and the row
760 // module's presses are its code's: on desktop the jump is a link as well.
761 const jumpLink = (task: Task) => {
762 if (task.messageId === undefined || jumpless.has(surface)) {
763 return null
764 }
765
766 const key = `jump:${task.id}`
767 const label = '↑ Show in conversation'
768
769 return isRowButton ? (
770 <Button key={key} plain dimColor onPress={jumpTo(task)}>
771 {label}
772 </Button>
773 ) : (
774 link(key, label, jumpTo(task), true)
775 )
776 }
777
778 if (list.length === 0) {
779 return (
780 <Box flexDirection="column">
781 <Text dimColor>No tasks yet.</Text>
782 <Text dimColor>Each prompt you send shows up here with its result.</Text>
783 </Box>
784 )
785 }
786
787 const runningFor = (task: Task) => task.durationMs + (task.runningSince === null ? 0 : now - task.runningSince)
788
789 // Running: how long so far. Today: how long ago. Before: the clock time, under its day.
790 const when = (task: Task) => {
791 if (task.status === 'running') {
792 return formatDuration(runningFor(task))
793 }
794
795 if (task.isRebuilt === true) {
796 return ''
797 }
798
799 return isSameDay(task.startedAt, now) ? formatAge(now - task.startedAt) : clockTime(task.startedAt)
800 }
801
802 const glance = (task: Task) => {
803 if (task.status === 'running') {
804 const files = task.files.length > 0 ? ` · ${task.files.length} ${task.files.length === 1 ? 'file' : 'files'}` : ''
805
806 return (
807 <Text dimColor>
808 {' '}
809 {task.tools} tools{files}
810 </Text>
811 )
812 }
813
814 const first = task.openPoints[0]
815
816 if (task.status === 'waiting' && first !== undefined) {
817 return <Box paddingLeft={4}>{point(task, 0, cut(first, width - 4))}</Box>
818 }
819
820 // The result, one line, so the list reads without expanding.
821 if (isResultLine && task.result.length > 0) {
822 return <Text dimColor>{` → ${cut(task.result, width - 6)}`}</Text>
823 }
824
825 return null
826 }
827
828 const details = (task: Task) => {
829 const filesKey = `files:${task.id}`
830 const promptFold = `prompt:${task.id}`
831 const filesOpen = open.has(filesKey)
832 const promptOpen = open.has(promptFold)
833 const icon = ICONS[task.status]
834 const time = task.status === 'running' ? formatDuration(runningFor(task)) : formatDuration(task.durationMs)
835 const facts = [icon.word, timeSpan(task, now), time, `${task.tools} tools`].filter(fact => fact.length > 0)
836 const fileCount = `${task.files.length} ${task.files.length === 1 ? 'file' : 'files'} changed`
837
838 return (
839 <Box flexDirection="column" paddingLeft={4}>
840 {task.result.length > 0 && <Text>→ {task.result}</Text>}
841 {(task.answered ?? []).map(answered => (
842 <Text dimColor>✓ {answered}</Text>
843 ))}
844 {task.openPoints.map((one, index) => point(task, index, `? ${one}`))}
845 <Text dimColor>{facts.join(' · ')}</Text>
846 {jumpLink(task)}
847 {task.files.length > 0 && fold(filesKey, filesOpen, fileCount)}
848 {filesOpen && (
849 <Box flexDirection="column" paddingLeft={2}>
850 {task.files.map(file => (
851 <Text dimColor>{cutStart(relativePath(file, cwd), width - 8)}</Text>
852 ))}
853 </Box>
854 )}
855 {fold(promptFold, promptOpen, 'Prompt')}
856 {promptOpen && (
857 <Box flexDirection="column" paddingLeft={2}>
858 <Text>{cut(task.prompt, 2000)}</Text>
859 {isRowButton ? (
860 <Button key={`insert:${task.id}`} label="Insert into prompt" onPress={insert(task)} />
861 ) : (
862 row(`insert:${task.id}`, [{ text: '› Insert into prompt', isDim: true }], () =>
863 link(`insert:${task.id}`, 'Insert into prompt', insert(task)),
864 )
865 )}
866 </Box>
867 )}
868 {task.followUps.map(followUp => (
869 <Text dimColor>↳ “{cut(oneLine(followUp.text), width - 10)}”</Text>
870 ))}
871 </Box>
872 )
873 }
874
875 const ordered = [...list].reverse()
876 const days = ordered.map(task => (task.isRebuilt === true ? 'Earlier' : dayLabel(task.startedAt, now)))
877 // Days are named once the list reaches back past today.
878 const isDated = days.some(day => day !== 'Today')
879
880 return (
881 <Box flexDirection="column">
882 {ordered.map((task, index) => {
883 const isOpen = open.has(task.id)
884 const icon = ICONS[task.status]
885 const age = when(task)
886 const day = days[index] ?? ''
887
888 return (
889 <Box flexDirection="column">
890 {isDated && day !== days[index - 1] && <Text dimColor>── {day} ──</Text>}
891 {isRowButton ? (
892 <Button key={task.id} plain onPress={toggle(task.id)}>
893 {isOpen ? '▾ ' : '▸ '}
894 <Text color={icon.color} bold>
895 {icon.glyph}
896 </Text>
897 {` ${cut(task.title, width - age.length - 6)} `}
898 <Text dimColor>{age}</Text>
899 </Button>
900 ) : (
901 row(
902 task.id,
903 [
904 { text: isOpen ? '▾' : '▸' },
905 { text: icon.glyph, color: icon.color, isBold: true },
906 { text: task.title, isFill: true },
907 ...(age.length > 0 ? [{ text: age, isDim: true }] : []),
908 ],
909 () => (
910 <Box flexDirection="row" gap={1}>
911 {link(task.id, isOpen ? '▾' : '▸', toggle(task.id))}
912 <Text color={icon.color} bold>
913 {icon.glyph}
914 </Text>
915 <Box flexGrow={1} flexShrink={1}>
916 <Text wrap="truncate-end">{task.title}</Text>
917 </Box>
918 {age.length > 0 && <Text dimColor>{age}</Text>}
919 </Box>
920 ),
921 )
922 )}
923 {isOpen ? details(task) : glance(task)}
924 </Box>
925 )
926 })}
927 </Box>
928 )
929 })
930}
931hooks/logic.ts 570 lines1import type { SessionMessage } from 'claude-code'
2
3import type { Task, TaskStatus } from '../types'
4
5export const MAX_TASKS = 100
6const MAX_PROMPT = 4000
7const MAX_ANSWERED = 6
8
9/** Replies that only answer or confirm the task before: never a task of their own. */
10const ACKS = new Set([
11 'ja', 'jo', 'jep', 'jup', 'yes', 'y', 'yep', 'yeah', 'sure', 'ok', 'okay', 'oki', 'k',
12 'nein', 'no', 'n', 'nope', 'passt', 'gut', 'super', 'perfekt', 'perfect', 'great',
13 'danke', 'thanks', 'thx', 'weiter', 'mach', 'machs', 'mach\'s', 'das', 'so', 'bitte',
14 'continue', 'go', 'ahead', 'los', 'leg', 'do', 'it', 'klar', 'genau', 'top', 'fine',
15 'sounds', 'good', 'schön', 'cool', 'nice', 'alles', 'richtig', 'right', 'correct',
16])
17
18export function oneLine(text: string): string {
19 return text.replace(/\s+/g, ' ').trim()
20}
21
22export function cut(text: string, length: number): string {
23 if (length < 2) {
24 return text.slice(0, Math.max(0, length))
25 }
26
27 return text.length > length ? `${text.slice(0, length - 1)}…` : text
28}
29
30/** The key two spellings of one prompt share. */
31export function normalize(text: string): string {
32 return oneLine(text).toLowerCase()
33}
34
35/** The key a task and the transcript row of its prompt share, short enough to compare often. */
36export function promptKey(text: string): string {
37 return normalize(text).slice(0, 300)
38}
39
40/** Text shown as a markdown link's label, its marks taken literally. */
41export function escapeMarkdown(text: string): string {
42 return text.replace(/[\\[\]*_`<>]/g, mark => `\\${mark}`)
43}
44
45export function fallbackTitle(prompt: string): string {
46 return cut(oneLine(prompt), 60) || '(empty prompt)'
47}
48
49/** The first line of a reply that says something, markdown marks stripped. */
50export function firstLine(answer: string): string {
51 const line = answer
52 .split('\n')
53 .map(row => row.replace(/^[\s#>*\-+|`]+/, '').replace(/[*_`]+/g, '').trim())
54 .find(row => row.length > 0)
55
56 return cut(line ?? '', 160)
57}
58
59/** "ja", "ok, mach weiter", "B": a reply that only continues the task before. */
60export function isAck(prompt: string): boolean {
61 const text = normalize(prompt).replace(/[^\p{L}\p{N}'\s]/gu, ' ').trim()
62
63 if (text.length === 0) {
64 return false
65 }
66
67 const words = text.split(/\s+/)
68
69 return text.length <= 3 || (words.length <= 4 && words.every(word => ACKS.has(word)))
70}
71
72export function newTask(id: string, prompt: string, now: number, needsGrouping: boolean): Task {
73 return {
74 id,
75 prompt: prompt.slice(0, MAX_PROMPT),
76 title: fallbackTitle(prompt),
77 result: '',
78 openPoints: [],
79 openPrompts: [],
80 answered: [],
81 isReplied: false,
82 status: 'running',
83 startedAt: now,
84 runningSince: now,
85 durationMs: 0,
86 tools: 0,
87 files: [],
88 followUps: [],
89 isSummarized: false,
90 needsGrouping,
91 pendingAnswer: null,
92 }
93}
94
95export function addTask(list: readonly Task[], task: Task): Task[] {
96 return [...list, task].slice(-MAX_TASKS)
97}
98
99export function patch(list: readonly Task[], id: string, change: (task: Task) => Task): Task[] {
100 return list.map(task => (task.id === id ? change(task) : task))
101}
102
103function addAnswered(answered: readonly string[] | undefined, points: readonly string[]): string[] {
104 return [...new Set([...(answered ?? []), ...points])].slice(-MAX_ANSWERED)
105}
106
107/** The person replied to the task: the questions it waited on count as answered. */
108export function markReplied(task: Task): Task {
109 if (task.status !== 'waiting') {
110 return { ...task, isReplied: true }
111 }
112
113 return {
114 ...task,
115 status: 'done',
116 openPoints: [],
117 openPrompts: [],
118 answered: addAnswered(task.answered, task.openPoints),
119 isReplied: true,
120 }
121}
122
123export function formatDuration(ms: number): string {
124 const seconds = Math.max(0, Math.round(ms / 1000))
125
126 if (seconds < 60) {
127 return `${seconds}s`
128 }
129
130 const minutes = Math.floor(seconds / 60)
131
132 if (minutes < 10) {
133 return `${minutes}m${String(seconds % 60).padStart(2, '0')}s`
134 }
135
136 if (minutes < 60) {
137 return `${minutes}m`
138 }
139
140 return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}m`
141}
142
143/** How long ago, as compact as the row allows: "now", "12m", "3h", "2d". */
144export function formatAge(ms: number): string {
145 const minutes = Math.floor(Math.max(0, ms) / 60000)
146
147 if (minutes < 1) {
148 return 'now'
149 }
150
151 if (minutes < 60) {
152 return `${minutes}m`
153 }
154
155 const hours = Math.floor(minutes / 60)
156
157 return hours < 24 ? `${hours}h` : `${Math.floor(hours / 24)}d`
158}
159
160const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
161const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
162
163/** The local time of day, "14:32". */
164export function clockTime(ms: number): string {
165 const date = new Date(ms)
166
167 return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
168}
169
170function startOfDay(ms: number): number {
171 const date = new Date(ms)
172 date.setHours(0, 0, 0, 0)
173
174 return date.getTime()
175}
176
177export function isSameDay(one: number, other: number): boolean {
178 return startOfDay(one) === startOfDay(other)
179}
180
181/** The local day a time falls on, as a list heads it: "Today", "Yesterday", "Fri 24 Oct". */
182export function dayLabel(ms: number, now: number): string {
183 // Rounded, since a day that changes the clock is an hour short or long.
184 const days = Math.round((startOfDay(now) - startOfDay(ms)) / 86400000)
185
186 if (days <= 0) {
187 return 'Today'
188 }
189
190 if (days === 1) {
191 return 'Yesterday'
192 }
193
194 const date = new Date(ms)
195 const year = date.getFullYear() === new Date(now).getFullYear() ? '' : ` ${date.getFullYear()}`
196
197 return `${DAYS[date.getDay()]} ${date.getDate()} ${MONTHS[date.getMonth()]}${year}`
198}
199
200/** When a task ran by the clock: "14:32", "14:32–14:36", the end's day named when it is another. */
201export function timeSpan(task: Task, now: number): string {
202 if (task.isRebuilt === true) {
203 return ''
204 }
205
206 const start = clockTime(task.startedAt)
207 const end = task.status === 'running' || task.endedAt === undefined ? undefined : task.endedAt
208
209 if (end === undefined) {
210 return start
211 }
212
213 if (!isSameDay(task.startedAt, end)) {
214 return `${start}–${dayLabel(end, now)} ${clockTime(end)}`
215 }
216
217 return clockTime(end) === start ? start : `${start}–${clockTime(end)}`
218}
219
220/** A path below `cwd` as relative, on Windows (`C:\x\y`) as on POSIX; any other as it is. */
221export function relativePath(path: string, cwd: string): string {
222 const file = path.replace(/\\/g, '/')
223 const base = cwd.replace(/\\/g, '/').replace(/\/+$/, '')
224 const isWindows = /^[a-z]:\//i.test(base)
225 const isBelow = isWindows
226 ? file.toLowerCase().startsWith(`${base.toLowerCase()}/`)
227 : file.startsWith(`${base}/`)
228
229 return isBelow ? file.slice(base.length + 1) : file
230}
231
232export function projectName(cwd: string): string {
233 return cwd.split(/[\\/]/).filter(Boolean).at(-1) ?? cwd
234}
235
236export type Summary = {
237 isFollowUp: boolean
238 title: string
239 result: string
240 openPoints: string[]
241 /** What the person would send to take up each open point; '' where the model gave none. */
242 openPrompts: string[]
243 isWaitingForUser: boolean
244}
245
246export const SUMMARY_SYSTEM =
247 'You summarize the tasks a person gives a coding assistant, for a compact task list. ' +
248 'Reply with one JSON object and nothing else.'
249
250/** Keeps a long text's head and tail, where a request and a reply say the most. */
251export function excerpt(text: string, head: number, tail: number): string {
252 return text.length <= head + tail ? text : `${text.slice(0, head)}\n[…]\n${text.slice(-tail)}`
253}
254
255export function summaryPrompt(task: Task, answer: string, previous: Task | undefined): string {
256 const parts: string[] = []
257
258 if (previous !== undefined) {
259 const asked = [...(previous.answered ?? []), ...previous.openPoints].slice(-3)
260 const points = asked.length > 0 ? `\nasked or proposed to the person: ${asked.join(' | ')}` : ''
261
262 parts.push(
263 `The task before this one:\n<previous>\ntitle: ${previous.title}\nresult: ${previous.result}${points}\n</previous>`,
264 )
265 }
266
267 parts.push(`The person's request:\n<request>\n${excerpt(task.prompt, 2500, 500)}\n</request>`)
268
269 if (task.followUps.length > 0) {
270 const replies = task.followUps.map(one => `- ${cut(oneLine(one.text), 300)}`).join('\n')
271 parts.push(`What the person said on this task afterwards, in order:\n<followups>\n${replies}\n</followups>`)
272 }
273
274 if (task.result) {
275 parts.push(`The result of this task so far: ${task.result}`)
276 }
277
278 parts.push(`The assistant's latest reply:\n<reply>\n${excerpt(answer, 1500, 2500)}\n</reply>`)
279
280 const fields = [
281 previous !== undefined
282 ? '- "isFollowUp": true only when the request answers a question of the task before or takes up what it ' +
283 'proposed, confirms, corrects or refines its result, or asks to go on with it; false when it asks for a ' +
284 'different result or action the task before did not propose, even on the same project (a release after a ' +
285 'license, tests after a fix)'
286 : '- "isFollowUp": false',
287 '- "title": the whole task in at most 6 words, no trailing period',
288 '- "result": what the whole task achieved so far, in at most 20 words, keeping what the result so far says ' +
289 'unless the latest reply changes it; if nothing was done yet, what the assistant waits for',
290 '- "openPoints": up to 3 questions or proposed next steps the reply leaves to the person, else []; each an ' +
291 'object with "text", the point in a few words, and "prompt", what the person would send to take it up, ' +
292 'as an instruction to the assistant (for "Add a regression test?": "Yes, add a regression test for the ' +
293 'session check")',
294 '- "isWaitingForUser": true when the reply ends by asking the person a question or for a decision',
295 ]
296
297 parts.push(
298 `Return a JSON object with these fields:\n${fields.join('\n')}\n` +
299 'Write title, result and openPoints in the language of the request.',
300 )
301
302 return parts.join('\n\n')
303}
304
305export function parseSummary(text: string): Summary | undefined {
306 const start = text.indexOf('{')
307 const end = text.lastIndexOf('}')
308
309 if (start < 0 || end <= start) {
310 return undefined
311 }
312
313 try {
314 const raw: unknown = JSON.parse(text.slice(start, end + 1))
315
316 if (typeof raw !== 'object' || raw === null) {
317 return undefined
318 }
319
320 const value = raw as Record<string, unknown>
321 const title = typeof value.title === 'string' ? oneLine(value.title) : ''
322
323 if (title.length === 0) {
324 return undefined
325 }
326
327 // A point is `{ text, prompt }`; a plain string, as models answered before, stands for its text.
328 const points = (Array.isArray(value.openPoints) ? value.openPoints : [])
329 .map((point: unknown) => {
330 const item = typeof point === 'object' && point !== null ? (point as Record<string, unknown>) : { text: point }
331
332 return {
333 text: typeof item.text === 'string' ? oneLine(item.text) : '',
334 prompt: typeof item.prompt === 'string' ? oneLine(item.prompt) : '',
335 }
336 })
337 .filter(point => point.text.length > 0)
338 .slice(0, 3)
339
340 return {
341 isFollowUp: value.isFollowUp === true,
342 title: cut(title, 80),
343 result: typeof value.result === 'string' ? cut(oneLine(value.result), 240) : '',
344 openPoints: points.map(point => cut(point.text, 200)),
345 openPrompts: points.map(point => cut(point.prompt, 500)),
346 isWaitingForUser: value.isWaitingForUser === true,
347 }
348 } catch {
349 return undefined
350 }
351}
352
353function settledStatus(status: TaskStatus, isWaiting: boolean): TaskStatus {
354 if (status === 'done' || status === 'waiting') {
355 return isWaiting ? 'waiting' : 'done'
356 }
357
358 return status
359}
360
361type Settled = { status: TaskStatus; openPoints: string[]; openPrompts: string[]; answered: string[] }
362
363/** A summary's points and status for its task; questions the person replied to before it came are answered. */
364function settle(task: Task, summary: Summary): Settled {
365 const isAnswered = summary.isWaitingForUser && task.isReplied === true
366
367 return {
368 status: settledStatus(task.status, summary.isWaitingForUser && !isAnswered),
369 openPoints: isAnswered ? [] : summary.openPoints,
370 openPrompts: isAnswered ? [] : summary.openPrompts,
371 answered: isAnswered ? summary.openPoints : [],
372 }
373}
374
375/**
376 * Puts a summary on its task. A task the summary calls a follow-up is folded
377 * into the one before it: its prompt becomes a follow-up there, its time,
378 * tools and files add up, and the summary speaks for the whole.
379 */
380export function applySummary(
381 list: readonly Task[],
382 id: string,
383 summary: Summary,
384): { tasks: Task[]; mergedInto?: string } {
385 const index = list.findIndex(task => task.id === id)
386 const task = list[index]
387
388 if (task === undefined) {
389 return { tasks: [...list] }
390 }
391
392 const previous = index > 0 ? list[index - 1] : undefined
393 const settled = settle(task, summary)
394
395 if (task.needsGrouping && summary.isFollowUp && previous !== undefined) {
396 const merged: Task = {
397 ...previous,
398 title: previous.isSummarized ? previous.title : summary.title,
399 pendingAnswer: null,
400 followUps: [...previous.followUps, { text: task.prompt, at: task.startedAt }, ...task.followUps],
401 durationMs: previous.durationMs + task.durationMs,
402 tools: previous.tools + task.tools,
403 files: [...new Set([...previous.files, ...task.files])],
404 status: settled.status,
405 runningSince: task.runningSince,
406 result: summary.result || previous.result,
407 openPoints: settled.openPoints,
408 openPrompts: settled.openPrompts,
409 endedAt: task.endedAt ?? previous.endedAt,
410 answered: addAnswered(previous.answered, [...(task.answered ?? []), ...settled.answered]),
411 isReplied: task.isReplied,
412 isSummarized: true,
413 }
414
415 return {
416 tasks: list.filter(one => one.id !== id).map(one => (one.id === previous.id ? merged : one)),
417 mergedInto: previous.id,
418 }
419 }
420
421 return {
422 tasks: patch(list, id, one => ({
423 ...one,
424 title: summary.title,
425 result: summary.result || one.result,
426 openPoints: settled.openPoints,
427 openPrompts: settled.openPrompts,
428 answered: addAnswered(one.answered, settled.answered),
429 status: settled.status,
430 isSummarized: true,
431 needsGrouping: false,
432 pendingAnswer: null,
433 })),
434 }
435}
436
437/** True for a user row the person typed, not a tool result or an injected block. */
438function isTypedPrompt(message: SessionMessage): boolean {
439 const text = message.text.trim()
440
441 return (
442 message.role === 'user' &&
443 (message.toolResults ?? []).length === 0 &&
444 text.length > 0 &&
445 !text.startsWith('<') &&
446 !text.startsWith('[Request interrupted')
447 )
448}
449
450/**
451 * The task list of a transcript the mod did not watch (a session resumed
452 * from before the mod was on): raw titles and results, no summaries.
453 */
454export function tasksFromMessages(messages: readonly SessionMessage[], now: number): Task[] {
455 const tasks: Task[] = []
456 let current: Task | undefined
457 let answer = ''
458
459 const close = () => {
460 if (current !== undefined) {
461 current.result = firstLine(answer)
462 current.status = 'done'
463 current.runningSince = null
464 }
465 }
466
467 messages.forEach((message, index) => {
468 if (isTypedPrompt(message)) {
469 if (current !== undefined && isAck(message.text)) {
470 current.followUps.push({ text: message.text, at: now })
471
472 return
473 }
474
475 close()
476 current = { ...newTask(`transcript-${index}`, message.text, now, false), isRebuilt: true }
477 tasks.push(current)
478 answer = ''
479
480 return
481 }
482
483 if (message.role === 'assistant' && current !== undefined) {
484 current.tools += message.toolUses.length
485
486 for (const use of message.toolUses) {
487 const path = use.input.file_path ?? use.input.notebook_path
488 const isEdit = use.tool === 'Edit' || use.tool === 'Write' || use.tool === 'NotebookEdit'
489
490 if (isEdit && typeof path === 'string' && !current.files.includes(path)) {
491 current.files.push(path)
492 }
493 }
494
495 if (message.text.trim()) {
496 answer = message.text
497 }
498 }
499 })
500
501 close()
502
503 return tasks.slice(-MAX_TASKS)
504}
505
506/** `git status --porcelain=v1 -z` as path to its two-letter code; a rename's old path is skipped. */
507export function parseGitStatus(stdout: string): Map<string, string> {
508 const entries = new Map<string, string>()
509 const fields = stdout.split('\0')
510
511 for (let index = 0; index < fields.length; index += 1) {
512 const field = fields[index] ?? ''
513
514 if (field.length < 4) {
515 continue
516 }
517
518 const code = field.slice(0, 2)
519 entries.set(field.slice(3), code)
520
521 if (code.includes('R') || code.includes('C')) {
522 index += 1
523 }
524 }
525
526 return entries
527}
528
529/** The paths whose state differs between two `git status` readings: what the turn touched. */
530export function changedPaths(before: ReadonlyMap<string, string>, after: ReadonlyMap<string, string>): string[] {
531 const changed = [...after].filter(([path, code]) => before.get(path) !== code).map(([path]) => path)
532 const reverted = [...before.keys()].filter(path => !after.has(path))
533
534 return [...changed, ...reverted]
535}
536
537export type NotifyLevel = 'all' | 'waiting' | 'off'
538
539/** The text of the notification a finished task raises, or undefined for none. */
540export function notification(task: Task, level: NotifyLevel): string | undefined {
541 if (level === 'off' || task.status === 'running' || task.status === 'aborted') {
542 return undefined
543 }
544
545 if (task.status === 'waiting') {
546 return `? ${task.openPoints[0] ?? task.title}`
547 }
548
549 if (level !== 'all') {
550 return undefined
551 }
552
553 return task.status === 'error' ? `✗ ${task.title}: failed` : `✓ ${task.title}${task.result ? ` – ${task.result}` : ''}`
554}
555
556/** The status line entry for the task in front: running or waiting; undefined clears it. */
557export function statusText(task: Task | undefined, now: number): string | undefined {
558 if (task?.status === 'running') {
559 const elapsed = task.durationMs + (task.runningSince === null ? 0 : now - task.runningSince)
560
561 return `⟳ ${cut(task.title, 40)} · ${formatDuration(elapsed)}`
562 }
563
564 if (task?.status === 'waiting') {
565 return `? ${cut(task.title, 40)}`
566 }
567
568 return undefined
569}
570hooks/row.tsx 64 lines1import type { ClientModule } from 'claude-code'
2
3/** One run of text in a row: `isFill` takes the free room and cuts its end. */
4export type Part = { text: string; color?: string; isBold?: boolean; isDim?: boolean; isFill?: boolean }
5
6export type RowProps = { parts: Part[] }
7
8type RowState = { isHovered: boolean }
9
10/**
11 * A row of the task history on a surface whose buttons flicker under the
12 * pointer: drawn as plain text and pressed anywhere on it, by a click or by
13 * Enter once a click gave it the focus. A press posts `{ press: true }`.
14 */
15const Row: ClientModule<RowProps, RowState> = (props, surface) => {
16 const { Box, Text } = surface.elements
17 const isHovered = surface.state?.isHovered === true
18
19 surface.onPointer(event => {
20 if (event.type === 'down' && event.button === 'left') {
21 surface.post({ press: true })
22 } else if (event.type === 'enter' || event.type === 'leave') {
23 surface.setState({ isHovered: event.type === 'enter' })
24 }
25 })
26 surface.onKey(event => {
27 if (event.key === 'return' || event.key === ' ') {
28 surface.post({ press: true })
29 }
30 })
31
32 // Under the pointer the row lights in the accent colour; nothing moves.
33 const draw = (part: Part) => {
34 const color = part.color ?? (isHovered ? 'claude' : undefined)
35
36 return (
37 <Text
38 {...(color !== undefined && { color })}
39 {...(part.isBold === true && { bold: true })}
40 {...(part.isDim === true && !isHovered && { dimColor: true })}
41 {...(part.isFill === true && { wrap: 'truncate-end' as const })}
42 >
43 {part.text}
44 </Text>
45 )
46 }
47
48 return (
49 <Box flexDirection="row" gap={1} width="100%">
50 {props.parts.map(part =>
51 part.isFill ? (
52 <Box flexGrow={1} flexShrink={1}>
53 {draw(part)}
54 </Box>
55 ) : (
56 draw(part)
57 ),
58 )}
59 </Box>
60 )
61}
62
63export default Row
64types/index.d.ts 61 lines1export type TaskStatus = 'running' | 'done' | 'waiting' | 'aborted' | 'error'
2
3export type FollowUp = { text: string; at: number }
4
5export type Task = {
6 /** The id of the turn that started the task. */
7 id: string
8 /** The prompt as the person typed it (cut to a few thousand characters). */
9 prompt: string
10 /** A short title: the summary's, or the prompt's opening words until then. */
11 title: string
12 /** One line on what came of it: the summary's, or the reply's first line. */
13 result: string
14 /** Questions or next steps the last reply left to the person. */
15 openPoints: string[]
16 /** What the person would send to take up each open point, in step with `openPoints`; '' where none came. */
17 openPrompts?: string[]
18 /** Questions the task waited on that the person has answered since: shown checked off. */
19 answered?: string[]
20 /** True once the person replied to the latest answer: a summary that comes after finds its questions answered. */
21 isReplied?: boolean
22 status: TaskStatus
23 startedAt: number
24 /** When the task's last turn ended; absent until one has. */
25 endedAt?: number
26 /** When the turn running now began; null while no turn runs on the task. */
27 runningSince: number | null
28 /** The finished turns' time, summed. */
29 durationMs: number
30 tools: number
31 /** Absolute paths of the files Edit, Write and NotebookEdit changed. */
32 files: string[]
33 /** Short replies and corrections the person gave on this task. */
34 followUps: FollowUp[]
35 isSummarized: boolean
36 /** True until the summary has said whether the task merely continues the one before. */
37 needsGrouping: boolean
38 /** The reply a summary is still owed for, kept so a reload can catch up; null once done. */
39 pendingAnswer?: string | null
40 /** The transcript id of the prompt that started the task, to scroll the conversation back to it. */
41 messageId?: string
42 /** Rebuilt from a transcript the mod did not watch: when it ran is not known. */
43 isRebuilt?: boolean
44}
45
46declare module 'claude-code' {
47 interface PluginState {
48 'task-history': {
49 tasks: Task[]
50 /** The task whose turn runs now. */
51 activeId: string | null
52 /** The session the tasks belong to. */
53 sessionId: string
54 /** Keys of the expanded rows: a task id, or `files:<id>` / `prompt:<id>`. */
55 expanded: string[]
56 /** Bumped by a timer so ages and running times stay fresh. */
57 tick: number
58 }
59 }
60}
61