SLOPSHOPPER

task-history

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

newpanebandguardcommandtoast
v0.3.0MITupdated 2026-10-26crysix/claude-task-history
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · task-history
│ ┃ Tasks · app ✕ › fix the failing auth test and add an audit log call │ ┃ ── Earlier ── │ ┃ ▸ ⏺ Read(src/auth.ts) │ ┃ → I updated src/auth.ts to reject ⎿ Read 6 lines │ ┃ expired claims an… ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /history │ ⎿ task-history: Task history shown. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Tasks · app
── Earlier ── ▸ → I updated src/auth.ts to reject expired claims an…
README

Task History for Claude Code

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
╰──────────────────────────────────────────╯

Features

  • One line per task, newest first: status, short title, and the running time (while it runs), its age (today) or its clock time (earlier days). Below it, dimmed, the task's one-line result, so the list reads without expanding anything (resultLines turns that off). Once the list reaches back past today, each day gets a heading (Yesterday, Fri 24 Oct).
  • Expand a task to see a one-line result, the questions or next steps Claude left open, when it ran by the clock (14:02–14:03), duration and tool calls. The changed files and the full original prompt each fold out one level deeper.
  • Back to the conversation. Show in conversation scrolls the chat to the prompt that started the task, so the list works as a table of contents for a long session.
  • Open points are one click away. Each open question or proposed next step is clickable: it puts a reply that takes it up into the prompt (for Add a regression test?: “Yes, add a regression test for the session check”), for you to send or change first.
  • Status at a glance: ⟳ running, ✓ done, ? Claude ended with a question and waits for you, ✗ interrupted or failed. A waiting task shows its open question even when collapsed.
  • Answered questions are checked off. The next prompt you send answers what a waiting task asked: the task turns ✓ 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.
  • Follow-ups are grouped. Short replies (“yes”, “ok, go on”, “B”) and corrections to the same task are listed under that task as ↳ lines instead of becoming tasks of their own.
  • AI summaries. When a task finishes, a small model (Haiku by default) turns the prompt and Claude's final reply into a short title, a one-line result and the open points with a reply for each, in the language of your prompt. Until it answers, or if it fails, the raw prompt and the reply's first line are shown.
  • Insert a prompt again: the expanded prompt has an Insert into prompt button that puts it back into the input box.
  • Notifications. When a task that ran for 20 seconds or more ends, a desktop notification says so: ? <question> when Claude waits for you, ✓ <title> – <result> when it is done. Handy when several sessions run side by side.
  • Status line. The running or waiting task also shows in the status line (⟳ Fix the login bug · 1m12s), so it stays visible with the pane closed.
  • All changed files. Besides 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.
  • Survives resumes and reloads. The history is saved per session, so 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).

Requirements

  • Claude Code with mods (function hooks): tested with 2.1.310.
  • For the right-hand column, the terminal's fullscreen layout and a window at least 110 columns wide. The pane opens by itself there. On the terminal's main screen (no fullscreen layout, e.g. inside tmux by default) Claude Code places panes inline above the prompt instead; open it there with /history.
  • The pane is also drawn in the desktop app's Code tab and in the VS Code extension. Where it sits there is up to the app.
  • The mod works only in Claude Code, not in the claude.ai chat.

Install

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

Usage

CommandWhat it does
/historyShow or hide the pane
/history openShow the pane
/history closeHide the pane
/history clearEmpty 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.

How it works

The mod listens to the session's own events. Nothing is scraped from the screen.

EventWhat the mod does
prompt.submitRemembers what you typed, slash commands as typed (/review 42)
session.appendNotes the transcript id of your prompt, for Show in conversation
turn.startStarts a new task, or adds a follow-up to the last task
tool.callCounts tool calls and collects the files Edit, Write and NotebookEdit changed
turn.completeRecords 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:

  1. the prompt is a short acknowledgement (ja, ok, yes, mach weiter, go ahead, a single letter or number, ...), or
  2. you typed it while Claude was still working on the task, or
  3. the summary model judges it a continuation, answer or correction of the task before it. In that case the task is merged into the previous one once the summary arrives.

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

Configuration

OptionDefaultMeaning
summariestrueSummarize finished tasks with a model. false: raw text, no model calls
summaryModelhaikuThe model alias or id used for summaries
notifyallNotifications for tasks of 20 s or more: all, waiting (only when Claude asks you something), off
resultLinestrueShow each task's one-line result under its collapsed row. false: one line per task
statusLinetrueShow the running or waiting task in the status line
gitFilestrueFind 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.

Cost and privacy

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.

Limitations

  • Only the current session is shown, not other sessions running in parallel.
  • Changed files through git work only inside a git repository. There, a file that was already modified before the turn and is changed again by a shell command is not detected, and files you edit yourself while Claude works are counted towards the task.
  • Show in conversation needs a surface that scrolls the chat to a message: the terminal's fullscreen layout does. Where a surface cannot, the first click says so and the link is not offered there again in that session.
  • A session rebuilt from its transcript has no times and no Show in conversation: the transcript the mod reads holds neither.
  • Grouping by the model can be wrong now and then. A wrongly merged task still shows its prompt as a ↳ line under the task it was merged into.

Development

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

Author

Made by crysix.

License

MIT

Source 4 files
hooks/register.tsx 931 lines
1import { 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}
931
hooks/logic.ts 570 lines
1import 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}
570
hooks/row.tsx 64 lines
1import 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
64
types/index.d.ts 61 lines
1export 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