SLOPSHOPPER

turn-digest

Folds each turn's intermediate process into one clickable summary row and shows a coloured activity / stall-check line under the prompt.

spinnerrowsguardstatustimer
v0.1.0MITupdated 2026-10-18refinism-B/turn-digest
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · turn-digest
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ 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 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ✓ 已完成 · 9 步:讀 2、改 3 檔、測 1、指令 3(1 失敗)· 0s ⟨Claude Code's own drawing⟩

Draws

Prompt hint
✓ 已完成 · 9 步:讀 2、改 3 檔、測 1、指令 3(1 失敗)· 0s ⟨Claude Code's own drawing⟩
README

turn-digest

繁體中文

A Claude Code mod (function hooks) that keeps the transcript readable when you only care about the result.

  • Folds each turn's process into one row. Intermediate text, tool calls and tool results collapse into a single clickable summary; the final reply stays in full.
  • Shows what is happening now in a coloured line just above the hint line under the prompt.
  • Flags a turn that says it is waiting but has nothing running, e.g. a reply ending in "I'll report back once the agent finishes" when no agent, background task or scheduled wake-up exists.
▸ 12 步:讀 5、改 2 檔、測 1、指令 3、子代理 1(1 失敗)· 48s     ← click to expand that turn
(final reply, shown in full)

The interface text is Traditional Chinese.

What you see

Summary row

▸ N 步:讀 a、改 b 檔、測 c、指令 d、子代理 e(f 失敗)· time (N steps: reads, files edited, test runs, other commands, subagents, failures, elapsed time; zero counts are left out).

  • Click the row to expand that turn; the row becomes ▾ 收合 · …. Click again to fold it. Other turns are not affected; new turns start folded.
FoldedExpanded
A folded turn: one summary row, then the final replyThe same turn expanded: the plan, the tool rows, then the final reply
  • Folded: intermediate text, tool-call rows, tool results.
  • Never folded: your prompts, the final reply, intermediate text containing a question mark (? / ?), and AskUserQuestion questions and answers.
  • When the mod cannot tell for sure whether a row belongs to a turn, it shows the row as is.
  • Thinking is not shown while it streams (main conversation only; subagents untouched).

Activity line

ColourExampleMeaning
yellow執行中 · 8 步:讀 7、改 1 檔 · 正在 Edit main.ts · 42srunning: tally, current call, elapsed time (ticks every second)
yellow⧗ 等待中:子代理 2、背景工作 1、排程喚醒 1turn ended, subagents / background tasks / scheduled wake-ups still pending
green✓ 已完成 · 5 步:讀 2、改 1 檔、指令 2(4 失敗)· 15sturn ended, nothing pending
red⚠ 回合已結束,但沒有任何子代理或背景工作在跑the reply's last paragraph says it is waiting (a fixed list of Chinese and English phrases), but nothing is running
red✕ 回合中斷 · 5 步:… · 15sturn interrupted, refused or errored

Running: a yellow line above the prompt's hint line

Done: a green line with the turn's tally

The stall warning is shown on this line only: no sound, no notification, nothing is sent on your behalf. The mod makes no model calls.

Requirements

  • Claude Code with function-hook mods. Tested on Claude Code 2.1.302, Windows 10.
  • Fullscreen terminal. The mod only changes the fullscreen terminal; everywhere else it leaves Claude Code's drawing alone.

Install

At the prompt of a Claude Code terminal session:

/plugin install turn-digest --marketplace refinism-B/turn-digest

Answer y to add the marketplace, then choose a scope (user scope is listed first).

Or from a shell:

claude plugin install turn-digest --marketplace refinism-B/turn-digest

Known issues

  • The failure count can be higher than the failures you see. It counts every call Claude Code reports as an error, including shell commands that exit non-zero. Claude Code's grouped rows (Read 2 files, Listed 1 directory) do not mark which calls inside them failed.
  • Intermediate text shows briefly before it folds. Whether a text block is intermediate is only known once the turn moves on.
  • Folding also applies in the Ctrl+O view. A mod cannot tell whether that view is open, so use the summary row to expand a turn.
  • Turns from before the mod loaded are not folded.
  • Background-task and scheduled wake-up counts depend on the Stop event. claude plugin validate reports that, where an organization manages Claude Code, Stop hooks only run in plugins the organization lists in managed settings.

License

MIT

Source 11 files
hooks/register.tsx 13 lines
1import type { Register } from 'claude-code'
2import { registerBand } from './band'
3import { registerFolding } from './fold'
4import { registerRecording } from './record'
5import { registerStatusLine } from './statusline'
6
7export const register: Register = on => {
8  registerRecording(on)
9  registerFolding(on)
10  registerBand(on)
11  registerStatusLine(on)
12}
13
hooks/band.tsx 68 lines
1import { atom, memberOf, read, update } from 'claude-code'
2import type { EngineInterface, On, Timer } from 'claude-code'
3import { countAgents, guard } from './guard'
4import { IDLE, row } from './state'
5import { decideStatus } from './status'
6
7const turnFamily = atom({ plugin: 'turn-digest', key: 'turn' } as const, null)
8const activityAtom = atom({ plugin: 'turn-digest', key: 'activity' } as const, IDLE)
9
10const TICK_MS = 1_000
11const POLL_MS = 3_000
12
13// Rebuildable: a reload starts both over from session.start.
14let timer: Timer | undefined
15let lastPoll = 0
16// Polls in a row that found no agent: a turn goes stalled only after two, so the
17// gap between an agent ending and its notification turn starting raises no alarm.
18let quietPolls = 0
19
20/** Moves a running turn's clock; asks for agents every POLL_MS while running or waiting. */
21async function tick($: EngineInterface): Promise<void> {
22  const activity = await read($, activityAtom)
23  if (activity.phase !== 'running' && activity.phase !== 'waiting') {
24    quietPolls = 0
25    return
26  }
27  const now = await $.clock.now()
28  const isPoll = now - lastPoll >= POLL_MS
29  if (isPoll) lastPoll = now
30  const agents = isPoll ? await countAgents($) : activity.waiting.agents
31
32  if (activity.phase === 'running') {
33    await update($, activityAtom, current =>
34      current.turnId === activity.turnId && current.phase === 'running'
35        ? { ...current, now, waiting: { ...current.waiting, agents } }
36        : current,
37    )
38    return
39  }
40  if (!isPoll || activity.phase !== 'waiting' || activity.turnId === null) return
41  quietPolls = agents === 0 ? quietPolls + 1 : 0
42  const turn = await read($, memberOf(turnFamily, row(activity.turnId)))
43  if (turn === null) return
44  const settled = { ...activity.waiting, agents }
45  const phase = decideStatus({ reason: turn.reason, waiting: settled, finalText: turn.finalText ?? '' })
46  if (phase === 'stalled' && quietPolls < 2) return
47  if (phase === activity.phase && agents === activity.waiting.agents) return
48  await update($, activityAtom, current => {
49    if (current.turnId !== activity.turnId || current.phase !== 'waiting') return current
50    const waiting = { ...current.waiting, agents }
51    return { ...current, waiting, phase: decideStatus({ reason: turn.reason, waiting, finalText: turn.finalText ?? '' }) }
52  })
53}
54
55export const registerBand = (on: On) => {
56  on('session.start', async ($, e, next) => {
57    const result = await next(e)
58    // An earlier load pinned the activity through $.ui.status; the hint line draws it now.
59    $.ui.status(undefined)
60    timer?.cancel()
61    timer = $.clock.every(TICK_MS, () => {
62      void guard($, 'tick', () => tick($))
63    })
64
65    return result
66  })
67}
68
hooks/fold.tsx 93 lines
1import { atom, memberOf, read, update } from 'claude-code'
2import type { EngineInterface, On, RenderElement, RenderInput } from 'claude-code'
3import { textKey } from './keys'
4import { IDLE, NEVER_FOLD, row } from './state'
5import { formatSummary } from './tally'
6
7const turnFamily = atom({ plugin: 'turn-digest', key: 'turn' } as const, null)
8const rowTurnFamily = atom({ plugin: 'turn-digest', key: 'rowTurn' } as const, null)
9const anchorFamily = atom({ plugin: 'turn-digest', key: 'anchor' } as const, null)
10const expandedFamily = atom({ plugin: 'turn-digest', key: 'expanded' } as const, false)
11const activityAtom = atom({ plugin: 'turn-digest', key: 'activity' } as const, IDLE)
12
13const UNTOUCHED = 'AskUserQuestion'
14
15type Folded = RenderInput<'AssistantMessage' | 'ToolUse' | 'ToolResult' | 'ToolGroup', 'terminal'>
16
17/** The rows a transcript row stands for, the anchors it may carry, and whether its owners are in doubt. */
18type RowIds = { rows: string[]; anchors: string[]; isDoubtful: boolean }
19
20const idsOf = (e: Folded): RowIds | null => {
21  if (e.component === 'AssistantMessage') {
22    const id = `text:${textKey(e.props.text)}`
23    return { rows: [id], anchors: [id], isDoubtful: false }
24  }
25  if (e.component === 'ToolGroup') {
26    const calls = e.props.calls
27    const rows = calls.flatMap(call => (call.tool_use_id === undefined ? [] : [call.tool_use_id]))
28    if (rows.length === 0) return null
29    const isDoubtful = rows.length !== calls.length || calls.some(call => call.tool === UNTOUCHED)
30    return { rows, anchors: rows.map(id => `tool:${id}`), isDoubtful }
31  }
32  if (e.props.tool === UNTOUCHED) return null
33  const id = e.props.tool_use_id
34  // A call's result row never carries the summary: its use row does.
35  return { rows: [id], anchors: e.component === 'ToolUse' ? [`tool:${id}`] : [], isDoubtful: false }
36}
37
38/** The summary row; expanded, or drawn over a row in doubt, the original follows it. */
39const summary = async ($: EngineInterface, e: Folded, next: (e: Folded) => Promise<RenderElement>, turnId: string, withOriginal: boolean) => {
40  const turn = await read($, memberOf(turnFamily, row(turnId)))
41  if (turn === null) return next(e)
42  const isExpanded = await read($, memberOf(expandedFamily, row(turnId)))
43  // A running turn's anchor follows the band's clock tick; a finished one never reads it.
44  const now = turn.endedAt ?? (await read($, activityAtom)).now
45  const { Box, Button } = $.ui.resolve(e)
46  const toggle = () => update($, memberOf(expandedFamily, row(turnId)), value => !value)
47  const button = (
48    <Button key={`digest-${turnId}`} plain onPress={toggle}>
49      {formatSummary(turn, now, isExpanded)}
50    </Button>
51  )
52  if (!isExpanded && !withOriginal) return button
53
54  return (
55    <Box flexDirection="column">
56      {button}
57      {await next(e)}
58    </Box>
59  )
60}
61
62export const registerFolding = (on: On) => {
63  on('ui.render', { component: ['AssistantMessage', 'ToolUse', 'ToolResult', 'ToolGroup'] }, async ($, e, next) => {
64    if (e.surface !== 'terminal' || e.viewport?.isFullscreen !== true) return next(e)
65    const ids = idsOf(e)
66    if (ids === null) return next(e)
67
68    // Each row reads only its own members, so it redraws only when its turn's fold changes.
69    const owners = await Promise.all(ids.rows.map(id => read($, memberOf(rowTurnFamily, row(id)))))
70    const turns = [...new Set(owners.filter((owner): owner is string => owner !== null && owner !== NEVER_FOLD))]
71    const isUniform = !ids.isDoubtful && turns.length === 1 && owners.every(owner => owner === turns[0])
72
73    if (!isUniform) {
74      // In doubt the original shows; the summary too, when this row is a turn's anchor.
75      for (const turnId of turns) {
76        const anchor = await read($, memberOf(anchorFamily, row(turnId)))
77        if (anchor !== null && ids.anchors.includes(anchor)) return summary($, e, next, turnId, true)
78      }
79      return next(e)
80    }
81
82    const turnId = turns[0] as string
83    const anchor = await read($, memberOf(anchorFamily, row(turnId)))
84    // Never hide a row of a turn that has no summary to open it with.
85    if (anchor === null) return next(e)
86    if (ids.anchors.includes(anchor)) return summary($, e, next, turnId, false)
87    if (await read($, memberOf(expandedFamily, row(turnId)))) return next(e)
88    const { Box } = $.ui.resolve(e)
89
90    return <Box />
91  })
92}
93
hooks/record.ts 250 lines
1import { atom, memberOf, read, update } from 'claude-code'
2import type { EngineInterface, On, TurnStepToolChunk } from 'claude-code'
3import type { CallRecord, TurnRecord } from '../types'
4import { countAgents, guard } from './guard'
5import { isQuestion, norm, textKey } from './keys'
6import { decideStatus } from './status'
7import { IDLE, NEVER_FOLD, row } from './state'
8import { callFile, callLabel, classifyTool } from './tally'
9
10const turnFamily = atom({ plugin: 'turn-digest', key: 'turn' } as const, null)
11const rowTurnFamily = atom({ plugin: 'turn-digest', key: 'rowTurn' } as const, null)
12const anchorFamily = atom({ plugin: 'turn-digest', key: 'anchor' } as const, null)
13const activityAtom = atom({ plugin: 'turn-digest', key: 'activity' } as const, IDLE)
14const lastTurnIdAtom = atom({ plugin: 'turn-digest', key: 'lastTurnId' } as const, null)
15
16const FINISHED = new Set(['completed', 'failed', 'killed'])
17const UNTOUCHED = 'AskUserQuestion'
18
19const patchTurn = ($: EngineInterface, id: string, change: (turn: TurnRecord) => TurnRecord) =>
20  update($, memberOf(turnFamily, row(id)), turn => (turn === null ? null : change(turn)))
21
22/** The summary's row: the first tool row, or (no tool yet) the first folded text. */
23const anchorOf = (turn: TurnRecord): string | null => {
24  if (turn.firstTool !== undefined) return `tool:${turn.firstTool}`
25  const first = turn.midKeys[0]
26  return first === undefined ? null : `text:${first}`
27}
28
29/** Writes the turn's anchor only when it moved: every row of the turn reads it. */
30const syncAnchor = async ($: EngineInterface, turn: TurnRecord | null) => {
31  if (turn === null) return
32  const ref = memberOf(anchorFamily, row(turn.id))
33  const anchor = anchorOf(turn)
34  if ((await read($, ref)) !== anchor) await update($, ref, () => anchor)
35}
36
37/** Marks a text as one that always shows; a turn that had folded it lets it go. */
38const pinText = async ($: EngineInterface, key: string) => {
39  const ref = memberOf(rowTurnFamily, row(`text:${key}`))
40  const owner = await read($, ref)
41  if (owner === NEVER_FOLD) return
42  await update($, ref, () => NEVER_FOLD)
43  if (owner === null) return
44  await syncAnchor($, await patchTurn($, owner, turn => ({ ...turn, midKeys: turn.midKeys.filter(k => k !== key) })))
45}
46
47/** Claims a text for this turn's fold; a text another turn holds too is pinned instead. */
48const claimText = async ($: EngineInterface, turnId: string, key: string): Promise<boolean> => {
49  const ref = memberOf(rowTurnFamily, row(`text:${key}`))
50  const owner = await read($, ref)
51  if (owner === turnId) return true
52  if (owner === NEVER_FOLD) return false
53  if (owner !== null) {
54    await pinText($, key)
55    return false
56  }
57  await update($, ref, () => turnId)
58  return true
59}
60
61const keysOf = (texts: readonly string[]) => texts.filter(text => norm(text) !== '').map(textKey)
62
63/**
64 * Text blocks that closed before a tool chunk are intermediate: fold them,
65 * unless they ask something.
66 */
67const markStep = async ($: EngineInterface, turnId: string, texts: readonly string[], tool?: TurnStepToolChunk) => {
68  const claimed: string[] = []
69  for (const key of keysOf(texts.filter(text => !isQuestion(text)))) {
70    if (await claimText($, turnId, key)) claimed.push(key)
71  }
72  if (tool !== undefined) await update($, memberOf(rowTurnFamily, row(tool.id)), () => turnId)
73  const anchorTool = tool !== undefined && tool.name !== UNTOUCHED ? tool.id : undefined
74  const turn = await patchTurn($, turnId, t => ({
75    ...t,
76    midKeys: [...t.midKeys, ...claimed.filter(key => !t.midKeys.includes(key))],
77    ...(t.firstTool === undefined && anchorTool !== undefined ? { firstTool: anchorTool } : {}),
78  }))
79  await syncAnchor($, turn)
80}
81
82/**
83 * The texts that ended the turn always show: the final step's blocks, the
84 * latest step's blocks when the turn stopped early, and the answer itself.
85 */
86const keepFinal = async ($: EngineInterface, keys: readonly string[]) => {
87  for (const key of new Set(keys)) await pinText($, key)
88}
89
90export const registerRecording = (on: On) => {
91  on('turn.start', async ($, e, next) => {
92    if (e.agentId === undefined) {
93      await guard($, 'turn.start', async () => {
94        const now = await $.clock.now()
95        await update($, memberOf(turnFamily, row(e.turnId)), () => ({ id: e.turnId, startedAt: now, calls: [], midKeys: [], lastKeys: [] }))
96        await update($, activityAtom, () => ({ ...IDLE, turnId: e.turnId, phase: 'running' as const, now }))
97        await update($, lastTurnIdAtom, () => e.turnId)
98      })
99    }
100
101    return next(e)
102  })
103
104  on('turn.step', async function* ($, e, next) {
105    const stream = next(e)
106    const isMain = e.agentId === undefined
107    // Text by block index since the last tool chunk; and every block of the step.
108    const texts = new Map<number, string>()
109    const blocks = new Map<number, string>()
110    let stepTool: string | undefined
111    let result
112    for (;;) {
113      const step = await stream.next()
114      if (step.done) {
115        result = step.value
116        break
117      }
118      const chunk = step.value
119      if (isMain && chunk.kind === 'thinking') continue
120      if (isMain && chunk.kind === 'text') {
121        texts.set(chunk.index, (texts.get(chunk.index) ?? '') + chunk.text)
122        blocks.set(chunk.index, (blocks.get(chunk.index) ?? '') + chunk.text)
123      }
124      if (isMain && chunk.kind === 'tool') {
125        const closed = [...texts.values()]
126        texts.clear()
127        if (stepTool === undefined && chunk.name !== UNTOUCHED) stepTool = chunk.id
128        await guard($, 'turn.step', () => markStep($, e.turnId, closed, chunk))
129      }
130      yield chunk
131    }
132    if (isMain && result !== undefined) {
133      const rest = [...texts.values()]
134      const lastKeys = keysOf([...blocks.values()])
135      const answer = result.answer
136      if (result.toolUses.length > 0) {
137        await guard($, 'turn.step', async () => {
138          if (rest.length > 0) await markStep($, e.turnId, rest)
139          await patchTurn($, e.turnId, turn => ({ ...turn, lastKeys }))
140        })
141      } else {
142        // The last step: its text is the reply. A tool chunk that never became a call anchors nothing.
143        await guard($, 'turn.step', async () => {
144          await keepFinal($, [...lastKeys, ...keysOf([answer])])
145          const turn = await patchTurn($, e.turnId, t => {
146            const { firstTool, ...others } = t
147            const isPhantom = firstTool !== undefined && firstTool === stepTool
148            return { ...(isPhantom ? others : t), finalText: answer, lastKeys }
149          })
150          await syncAnchor($, turn)
151        })
152      }
153    }
154
155    return result
156  })
157
158  on('tool.call', async ($, e, next) => {
159    const id = e.tool_use_id
160    if (e.agentId !== undefined || !id) return next(e)
161    const turnId = await guard($, 'tool.call', async () => {
162      const current = await read($, lastTurnIdAtom)
163      if (current === null) return undefined
164      const file = callFile(e)
165      const call: CallRecord = {
166        id,
167        tool: e.tool,
168        category: classifyTool(e.tool, e),
169        label: callLabel(e.tool, e),
170        ...(file === undefined ? {} : { file }),
171        isRunning: true,
172        isErrored: false,
173      }
174      await update($, memberOf(rowTurnFamily, row(id)), () => current)
175      await patchTurn($, current, turn =>
176        turn.endedAt !== undefined ? turn : { ...turn, calls: [...turn.calls.filter(c => c.id !== id), call] },
177      )
178      return current
179    })
180    const result = await next(e)
181    if (turnId !== undefined) {
182      const isErrored = typeof result.deny === 'string' || result.isError === true
183      await guard($, 'tool.call', () =>
184        patchTurn($, turnId, turn => ({
185          ...turn,
186          calls: turn.calls.map(call => (call.id === id ? { ...call, isRunning: false, isErrored } : call)),
187        })),
188      )
189    }
190
191    return result
192  })
193    // A failure here must never cost the call: let it through (or keep its result).
194    .catch(($, e, next) => next(e))
195
196  on('turn.complete', async ($, e, next) => {
197    const result = await next(e)
198    if (e.agentId !== undefined) return result
199    // Ending the turn comes first: nothing after it may leave the band at 執行中.
200    const now = (await guard($, 'turn.complete', () => $.clock.now())) ?? 0
201    const turn = await guard($, 'turn.complete', async () => {
202      const ended = await patchTurn($, e.turnId, t => ({ ...t, endedAt: now, reason: e.reason }))
203      if (ended === null) return null
204      await keepFinal($, [...ended.lastKeys, ...keysOf([e.answer, ended.finalText ?? ''])])
205      return ended
206    })
207    if (turn === null || turn === undefined) return result
208    const agents = (await guard($, 'turn.complete', () => countAgents($))) ?? 0
209    await guard($, 'turn.complete', () =>
210      update($, activityAtom, current => {
211        // A Stop that came first left this turn's background and cron counts.
212        const kept = current.turnId === e.turnId ? current.waiting : IDLE.waiting
213        const waiting = { agents, background: kept.background, crons: kept.crons }
214        const phase = decideStatus({ reason: e.reason, waiting, finalText: turn.finalText ?? e.answer })
215        return { turnId: e.turnId, phase, waiting, now }
216      }),
217    )
218
219    return result
220  })
221
222  // The only source of background work and scheduled wake-ups.
223  on('classic.Stop', async ($, e, next) => {
224    const result = await next(e)
225    if (e.agent_id !== undefined) return result
226    await guard($, 'classic.Stop', async () => {
227      // Subagents are counted from $.agent.list() already.
228      const background = (e.background_tasks ?? []).filter(
229        task => !FINISHED.has(task.status) && !/agent/i.test(task.type),
230      ).length
231      const crons = (e.session_crons ?? []).length
232      const agents = await countAgents($)
233      const turnId = (await read($, activityAtom)).turnId
234      if (turnId === null) return
235      const turn = await read($, memberOf(turnFamily, row(turnId)))
236      await update($, activityAtom, current => {
237        if (current.turnId !== turnId) return current
238        const waiting = { agents, background, crons }
239        // Before turn.complete: keep the counts, the phase is turn.complete's to set.
240        if (current.phase === 'running' || turn === null || turn.endedAt === undefined) return { ...current, waiting }
241        return { ...current, waiting, phase: decideStatus({ reason: turn.reason, waiting, finalText: turn.finalText ?? '' }) }
242      })
243    })
244
245    return result
246  })
247    // Never let the band's bookkeeping change how the session stops.
248    .catch(($, e, next) => next(e))
249}
250
hooks/statusline.tsx 44 lines
1import { atom, memberOf, read } from 'claude-code'
2import type { Color, On } from 'claude-code'
3import type { Phase } from '../types'
4import { IDLE, row } from './state'
5import { formatActivity } from './status'
6
7const turnFamily = atom({ plugin: 'turn-digest', key: 'turn' } as const, null)
8const activityAtom = atom({ plugin: 'turn-digest', key: 'activity' } as const, IDLE)
9
10// Still going = yellow, finished = green, something to look at = red.
11const COLORS: Record<Phase, Color> = {
12  idle: 'inactive',
13  running: 'warning',
14  waiting: 'warning',
15  done: 'success',
16  stalled: 'error',
17  ended: 'error',
18}
19
20/**
21 * The activity line, drawn by us above the engine's hint line under the prompt
22 * (fullscreen terminal only). It reads the activity, so each write (the 1 s
23 * tick included) redraws it.
24 */
25export const registerStatusLine = (on: On) => {
26  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
27    if (e.surface !== 'terminal' || e.viewport?.isFullscreen !== true) return next(e)
28    const activity = await read($, activityAtom)
29    const turn = activity.turnId === null ? null : await read($, memberOf(turnFamily, row(activity.turnId)))
30    const line = formatActivity(activity, turn, activity.now)
31    if (line === null) return next(e)
32    const { Box, Text } = $.ui.resolve(e)
33
34    return (
35      <Box flexDirection="column">
36        <Text color={COLORS[activity.phase]} wrap="truncate-end">
37          {line}
38        </Text>
39        {await next(e)}
40      </Box>
41    )
42  })
43}
44
hooks/guard.ts 24 lines
1import type { EngineInterface } from 'claude-code'
2
3// Function declarations: the engine's scan follows `$` across files only into these.
4
5const LIVE_AGENT = new Set(['pending', 'running', 'waiting'])
6
7/** Subagents and teammates still at work. */
8export async function countAgents($: EngineInterface): Promise<number> {
9  return (await $.agent.list()).filter(agent => LIVE_AGENT.has(agent.status)).length
10}
11
12/**
13 * Runs bookkeeping that must never cost the person their content or their
14 * tool call: a failure is logged to the debug log and the hook goes on.
15 */
16export async function guard<T>($: EngineInterface, what: string, work: () => Promise<T>): Promise<T | undefined> {
17  try {
18    return await work()
19  } catch (error) {
20    $.ui.log(`turn-digest: ${what}: ${String(error)}`, { to: 'debug' })
21    return undefined
22  }
23}
24
hooks/state.ts 19 lines
1import type { Activity } from '../types'
2
3// The atoms themselves live in each module that reads or writes them: the
4// engine's scan only follows `atom(...)` written in the file that uses it.
5// Families: `atom({ plugin: 'turn-digest', key }, initial)`, a member by
6// `memberOf(family, { requestId: id })`.
7//   turn      id = turnId                               TurnRecord | null
8//   rowTurn   id = tool_use_id | `text:<textKey>`       turnId | NEVER_FOLD | null
9//   anchor    id = turnId                               `tool:<id>` | `text:<key>` | null
10//   expanded  id = turnId                               boolean
11
12export const IDLE: Activity = { turnId: null, phase: 'idle', waiting: { agents: 0, background: 0, crons: 0 }, now: 0 }
13
14/** rowTurn value of a text that must always show: a final reply, or a text two turns share. */
15export const NEVER_FOLD = '!'
16
17/** A family member's address, in the shape `memberOf` keys by. */
18export const row = (id: string) => ({ requestId: id })
19
hooks/status.ts 83 lines
1import type { Activity, Phase, TurnRecord, Waiting } from '../types'
2import { formatDuration, formatTally, joinDots } from './tally'
3
4// Strict on purpose (a miss beats a false alarm): only Claude saying it will
5// come back or report later, not a mere mention of waiting or a suggestion.
6const PROMISES = [
7  /(再|會|就)(通知|告訴|回報|回覆|報告)你/g,
8  /會再回來|再回來(告訴|回報|通知)/g,
9  /等(待)?[^。!?\n]{0,30}(回報|回來|結果|完成|跑完|結束|檢查完)[^。!?\n]{0,4}(後|再)/g,
10  /稍後(再)?(回報|通知|告訴你|回來)/g,
11  /\bI(?:'|’)?(?:ll| will) (?:report|let you know|get back|update you|check back|follow up|notify you)/gi,
12  /\bwill report\b/gi,
13  /\bwaiting (?:for|on)\b/gi,
14  /\bwait(?:ing)? for .{0,60}\b(?:to finish|to complete|results?|report)/gi,
15]
16// A negation this close before a match ("不用等待", "no need to wait for") cancels it.
17const NEGATED = /(不用|無需|無須|毋須|不必|不需|不會|沒有在|no need to|don(?:'|’)t need to|do not need to|without)[\s\S]{0,4}$/i
18
19const lastParagraph = (text: string) =>
20  text
21    .split(/\n\s*\n/)
22    .map(block => block.trim())
23    .filter(Boolean)
24    .pop() ?? ''
25
26/** Whether the reply's last paragraph says it is waiting on something. Strict: a miss beats a false alarm. */
27export const looksLikeWaiting = (text: string): boolean => {
28  const paragraph = lastParagraph(text)
29  for (const pattern of PROMISES) {
30    for (const match of paragraph.matchAll(pattern)) {
31      if (!NEGATED.test(paragraph.slice(0, match.index))) return true
32    }
33  }
34
35  return false
36}
37
38export const decideStatus = (input: {
39  reason: TurnRecord['reason']
40  waiting: Waiting
41  finalText: string
42}): Phase => {
43  if (input.reason !== 'answer') return 'ended'
44  const { agents, background, crons } = input.waiting
45  if (agents + background + crons > 0) return 'waiting'
46
47  return looksLikeWaiting(input.finalText) ? 'stalled' : 'done'
48}
49
50const elapsed = (turn: TurnRecord, now: number) => formatDuration((turn.endedAt ?? now) - turn.startedAt)
51
52/** The band's one line for the phase; `null` draws nothing. */
53export const formatActivity = (activity: Activity, turn: TurnRecord | null, now: number): string | null => {
54  const { waiting } = activity
55  switch (activity.phase) {
56    case 'idle':
57      return null
58    case 'waiting': {
59      const parts = [
60        waiting.agents > 0 ? `子代理 ${waiting.agents}` : '',
61        waiting.background > 0 ? `背景工作 ${waiting.background}` : '',
62        waiting.crons > 0 ? `排程喚醒 ${waiting.crons}` : '',
63      ].filter(Boolean)
64      return `⧗ 等待中:${parts.join('、')}`
65    }
66    case 'stalled':
67      return '⚠ 回合已結束,但沒有任何子代理或背景工作在跑'
68  }
69  if (turn === null) return null
70  if (activity.phase === 'done') return joinDots(['✓ 已完成', formatTally(turn.calls), elapsed(turn, now)])
71  if (activity.phase === 'ended') return joinDots(['✕ 回合中斷', formatTally(turn.calls), elapsed(turn, now)])
72  const last = turn.calls[turn.calls.length - 1]
73  const parts = [
74    '執行中',
75    last ? formatTally(turn.calls) : '',
76    last ? `正在 ${last.label}` : '',
77    waiting.agents > 0 ? `子代理 ${waiting.agents} 個執行中` : '',
78    elapsed(turn, now),
79  ].filter(Boolean)
80
81  return joinDots(parts)
82}
83
hooks/keys.ts 16 lines
1export const norm = (text: string): string => text.replace(/\s+/g, ' ').trim()
2
3/** FNV-1a 32-bit of the normalized text, as 8 hex digits: a text row's id. */
4export const textKey = (text: string): string => {
5  let hash = 0x811c9dc5
6  for (const char of norm(text)) {
7    const code = char.codePointAt(0) ?? 0
8    hash ^= code
9    hash = Math.imul(hash, 0x01000193) >>> 0
10  }
11
12  return hash.toString(16).padStart(8, '0')
13}
14
15export const isQuestion = (text: string): boolean => /[??]/.test(text)
16
hooks/tally.ts 97 lines
1import type { CallRecord, Category, TurnRecord } from '../types'
2
3const READ_TOOLS = new Set(['Read', 'Grep', 'Glob'])
4const EDIT_TOOLS = new Set(['Edit', 'Write'])
5const SHELL_TOOLS = new Set(['Bash', 'PowerShell'])
6const AGENT_TOOLS = new Set(['Agent', 'Task'])
7const TEST_COMMAND = /test|pytest|jest|vitest|go test|cargo test/
8
9// Characters of a label's target kept before the ellipsis.
10const TARGET_WIDTH = 45
11
12const field = (input: unknown, name: string): string | undefined => {
13  if (typeof input !== 'object' || input === null) return undefined
14  const value = (input as Record<string, unknown>)[name]
15  return typeof value === 'string' && value !== '' ? value : undefined
16}
17
18const baseName = (path: string) => path.split(/[\\/]/).filter(Boolean).pop() ?? path
19
20const clip = (text: string, width: number) => {
21  const chars = [...text.replace(/\s+/g, ' ').trim()]
22  return chars.length > width ? `${chars.slice(0, width).join('')}…` : chars.join('')
23}
24
25export const classifyTool = (tool: string, input: unknown): Category => {
26  if (READ_TOOLS.has(tool)) return 'read'
27  if (EDIT_TOOLS.has(tool)) return 'edit'
28  if (AGENT_TOOLS.has(tool)) return 'agent'
29  if (SHELL_TOOLS.has(tool)) return TEST_COMMAND.test(field(input, 'command') ?? '') ? 'test' : 'command'
30  return 'other'
31}
32
33/** The file a call reads or edits, when it names one. */
34export const callFile = (input: unknown): string | undefined => field(input, 'file_path') ?? field(input, 'notebook_path')
35
36export const callLabel = (tool: string, input: unknown): string => {
37  const file = callFile(input)
38  // A shell call's description reads better than its raw command line.
39  const shell = SHELL_TOOLS.has(tool) ? field(input, 'description') : undefined
40  const target =
41    file !== undefined
42      ? baseName(file)
43      : (shell ?? field(input, 'command') ?? field(input, 'pattern') ?? field(input, 'description') ?? field(input, 'url'))
44
45  return target === undefined ? tool : `${tool} ${clip(target, TARGET_WIDTH)}`
46}
47
48export const tally = (calls: readonly CallRecord[]) => {
49  const counts = { steps: calls.length, read: 0, editFiles: 0, test: 0, command: 0, agent: 0, failed: 0 }
50  const edited = new Set<string>()
51  for (const call of calls) {
52    if (call.isErrored) counts.failed += 1
53    if (call.category === 'edit') edited.add(call.file ?? call.id)
54    else if (call.category !== 'other') counts[call.category] += 1
55  }
56  counts.editFiles = edited.size
57
58  return counts
59}
60
61export const formatDuration = (ms: number): string => {
62  const total = Math.max(0, Math.floor(ms / 1000))
63  const hours = Math.floor(total / 3600)
64  const minutes = Math.floor((total % 3600) / 60)
65  const seconds = total % 60
66  if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`
67  if (minutes > 0) return `${minutes}m ${seconds}s`
68
69  return `${seconds}s`
70}
71
72/** `8 步:讀 7、改 1 檔(1 失敗)`, shared by the summary row and the activity line. */
73export const formatTally = (calls: readonly CallRecord[]): string => {
74  const t = tally(calls)
75  const parts = [
76    t.read > 0 ? `讀 ${t.read}` : '',
77    t.editFiles > 0 ? `改 ${t.editFiles} 檔` : '',
78    t.test > 0 ? `測 ${t.test}` : '',
79    t.command > 0 ? `指令 ${t.command}` : '',
80    t.agent > 0 ? `子代理 ${t.agent}` : '',
81  ].filter(Boolean)
82  const detail = parts.length > 0 ? `:${parts.join('、')}` : ''
83  const failed = t.failed > 0 ? `(${t.failed} 失敗)` : ''
84
85  return `${t.steps} 步${detail}${failed}`
86}
87
88/** Joins with ` · `; a full-width parenthesis already spaces the dot. */
89export const joinDots = (parts: readonly string[]): string =>
90  parts.reduce((line, part) => (line === '' ? part : `${line}${line.endsWith(')') ? '· ' : ' · '}${part}`), '')
91
92export const formatSummary = (turn: TurnRecord, now: number, isExpanded: boolean): string => {
93  const body = joinDots([formatTally(turn.calls), formatDuration((turn.endedAt ?? now) - turn.startedAt)])
94
95  return isExpanded ? `▾ 收合 · ${body}` : `▸ ${body}`
96}
97
types/index.d.ts 56 lines
1export type Category = 'read' | 'edit' | 'test' | 'command' | 'agent' | 'other'
2
3export type CallRecord = {
4  /** tool_use_id */
5  id: string
6  tool: string
7  category: Category
8  /** edit/read target path, for the distinct-file count and the label */
9  file?: string
10  /** callLabel() output */
11  label: string
12  isRunning: boolean
13  isErrored: boolean
14}
15
16export type TurnRecord = {
17  /** turnId */
18  id: string
19  /** $.clock.now() ms */
20  startedAt: number
21  endedAt?: number
22  calls: CallRecord[]
23  /** textKey() of folded intermediate texts */
24  midKeys: string[]
25  /** first tool chunk of the turn that is not AskUserQuestion: the summary's row */
26  firstTool?: string
27  /** textKey() of every text block of the latest step: never folded once the turn ends */
28  lastKeys: string[]
29  reason?: 'answer' | 'aborted' | 'refusal' | 'error'
30  finalText?: string
31}
32
33export type Waiting = { agents: number; background: number; crons: number }
34
35export type Phase = 'idle' | 'running' | 'done' | 'waiting' | 'stalled' | 'ended'
36
37/** now: last clock tick (ms) */
38export type Activity = { turnId: string | null; phase: Phase; waiting: Waiting; now: number }
39
40declare module 'claude-code' {
41  interface PluginState {
42    'turn-digest': {
43      /** id = turnId */
44      turn: StateFamily<TurnRecord | null>
45      /** id = tool_use_id or `text:<textKey>` → turnId; `'!'` = this text never folds */
46      rowTurn: StateFamily<string | null>
47      /** id = turnId → the row drawing the summary: `tool:<tool_use_id>` | `text:<textKey>`; null = fold nothing */
48      anchor: StateFamily<string | null>
49      /** id = turnId */
50      expanded: StateFamily<boolean>
51      activity: Activity
52      lastTurnId: string | null
53    }
54  }
55}
56