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

A Claude Code mod (function hooks) that keeps the transcript readable when you only care about the result.
▸ 12 步:讀 5、改 2 檔、測 1、指令 3、子代理 1(1 失敗)· 48s ← click to expand that turn
(final reply, shown in full)
The interface text is Traditional Chinese.
▸ 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 again to fold it. Other turns are not affected; new turns start folded.| Folded | Expanded |
|---|---|
![]() | ![]() |
? / ?), and AskUserQuestion questions and answers.| Colour | Example | Meaning |
|---|---|---|
| yellow | 執行中 · 8 步:讀 7、改 1 檔 · 正在 Edit main.ts · 42s | running: tally, current call, elapsed time (ticks every second) |
| yellow | ⧗ 等待中:子代理 2、背景工作 1、排程喚醒 1 | turn ended, subagents / background tasks / scheduled wake-ups still pending |
| green | ✓ 已完成 · 5 步:讀 2、改 1 檔、指令 2(4 失敗)· 15s | turn 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 步:… · 15s | turn interrupted, refused or errored |


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.
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
Read 2 files, Listed 1 directory) do not mark which calls inside them failed.Ctrl+O view. A mod cannot tell whether that view is open, so use the summary row to expand a turn.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.hooks/register.tsx 13 lines1import 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}
13hooks/band.tsx 68 lines1import { 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}
68hooks/fold.tsx 93 lines1import { 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}
93hooks/record.ts 250 lines1import { 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}
250hooks/statusline.tsx 44 lines1import { 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}
44hooks/guard.ts 24 lines1import 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}
24hooks/state.ts 19 lines1import 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 })
19hooks/status.ts 83 lines1import 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}
83hooks/keys.ts 16 lines1export 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)
16hooks/tally.ts 97 lines1import 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}
97types/index.d.ts 56 lines1export 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