While Claude works, hide its text and show a live plan with the skills and images it uses; when it finishes, show only the summary. Keeps a history of past…

Mods for Claude Code: small add-ons that change how Claude looks and behaves in the terminal. Each mod is its own folder here, and each installs on its own.
While Claude works, its text and tool calls are hidden. Above the prompt you see a live plan instead:
⠹ Working · 1 of 3 done ━━━━━━╸─────────── 1m 4s
✓ Read the code
⠹ Write the fix · 23s
└ Build the app
→ Next: Run the tests
✦ Skills simplify · code-review
▣ Images ┌────────┐ ┌────┐
│picture │ │pic │
└────────┘ └────┘
/name count too; skills used by Claude's helpers (subagents) do not.✦ Used, the skills it used, and its pictures.Claude gets a small plan tool of its own (it never asks for permission) and an instruction to send its plan first and end with a full summary that repeats every link and file path you need.
Failures are marked. When a command or test fails, a red ✗ line under the step says what failed and why (the exit code and the last thing it printed), and the step counts it: · ✗ 1 failed. If Claude fixes it and moves on, the step still gets its ✓ but keeps · 1 failed try, in the finished list too.
Click a step to see its actions. Each step with actions has a ▸. Click it and the actions open under the step, each with ✓ or ✗ and how long it took. Click again to close. Clicks work in Claude Code's fullscreen mode. A clicked row is never left highlighted.
Helpers. When Claude starts a helper (a subagent, such as a review or a code search) or leaves a shell command running in the background (a dev server, a long build), it stands under the step that started it:
⠹ Review for bugs · 22s
⚇ ⠹ code-review · Review the diff · 12s
└ Running git diff
⚇ ✓ Explore · Find the callers · 4s
▶ ⠹ Start the dev server · 3m
⚇ is a helper, ▶ a background command; a running one says what it is doing now. Click one to see all its actions. Helpers a helper starts stand one level in. Once a step is done its finished helpers fold into a count (✓ Review for bugs · 2 helpers; click the step to see them), and anything still running stays visible, after the task too (still running · 3m), until it ends. The history keeps each task's helpers under their steps.
History. /bottom-line history opens a panel beside the chat with your past tasks, newest first, one line each: when, how long, the task, and how many steps. Click a task to open it: its steps (with failed tries), the skills and pictures it used, and the first line of its summary. The last 50 tasks are kept.
Settings. In Claude Code's plugin settings, bottom-line has five switches, all on by default:
| Switch | Off means |
|---|---|
| Hide Claude's text while it works | Claude's messages and tool steps show as usual; the list still shows |
| Skills line | no ✦ Skills line |
| Images row | no ▣ Images row, and no thumbnails are made |
| Helpers | no helper or background-command rows |
| Keep the finished list | the list goes away as soon as Claude finishes |
/bottom-line turns the whole mod on or off. Your choice is remembered./bottom-line text shows or hides Claude's text while it works: the same switch as Hide Claude's text while it works, without opening the settings.ctrl+o still shows Claude's full work.Thumbnails are real pictures where the terminal can draw them. Claude Code draws them by itself in kitty, Ghostty and iTerm2 (3.5 or later, in fullscreen rendering). In any other terminal the row shows each picture's name instead, like [dock.png].
If your terminal can draw kitty-protocol pictures but Claude Code does not know it (terminals built on Ghostty's engine, for example), turn pictures on yourself in ~/.claude/settings.json and start a new session:
{
"env": {
"CLAUDE_CODE_FORCE_TERMINAL_IMAGES": "1"
}
}
Only do this where the terminal really draws pictures; elsewhere you would see stray symbols.
sips and kept a day in ~/Library/Caches/bottom-line.At the prompt of a Claude Code terminal session:
/plugin install bottom-line --marketplace SamiAbi/claude-mods
Answer y to add the marketplace, then pick a scope (the user scope is first).
From a shell:
claude plugin install bottom-line --marketplace SamiAbi/claude-mods
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with SamiAbi/claude-mods, then Add plugin.
MIT. See LICENSE.
hooks/register.tsx 1434 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren, Timer } from 'claude-code'
3
4import type { Activity, Helper, HelperStatus, HistoryEntry, HistoryHelper, Step, StepStatus, Thumb } from '../types'
5
6const NAME = 'bottom-line'
7const PLAN_TOOL = 'mcp__bottom-line__plan'
8const PLAN_MATCH = /^mcp__bottom-line__plan$/
9const HISTORY_PANE = 'bottom-line-history'
10const SETTINGS_PANE = 'bottom-line-settings'
11const KEEP = 1000
12const TICK = 120
13const FLASH = 1500
14
15const isOn = atom({ plugin: 'bottom-line', key: 'isOn' } as const, true)
16const isWorking = atom({ plugin: 'bottom-line', key: 'isWorking' } as const, false)
17const steps = atom({ plugin: 'bottom-line', key: 'steps' } as const, [])
18const activity = atom({ plugin: 'bottom-line', key: 'activity' } as const, [])
19const clock = atom({ plugin: 'bottom-line', key: 'clock' } as const, 0)
20const turnSince = atom({ plugin: 'bottom-line', key: 'turnSince' } as const, 0)
21const stepSince = atom({ plugin: 'bottom-line', key: 'stepSince' } as const, 0)
22const turnTitle = atom({ plugin: 'bottom-line', key: 'turnTitle' } as const, '')
23const doneAt = atom({ plugin: 'bottom-line', key: 'doneAt' } as const, {})
24const finish = atom({ plugin: 'bottom-line', key: 'finish' } as const, null)
25const openStep = atom({ plugin: 'bottom-line', key: 'openStep' } as const, null)
26const openHelper = atom({ plugin: 'bottom-line', key: 'openHelper' } as const, null)
27const turnNow = atom({ plugin: 'bottom-line', key: 'turnNow' } as const, '')
28const helpers = atom({ plugin: 'bottom-line', key: 'helpers' } as const, [])
29const openHistory = atom({ plugin: 'bottom-line', key: 'openHistory' } as const, null)
30const skills = atom({ plugin: 'bottom-line', key: 'skills' } as const, [])
31const pendingSkills = atom({ plugin: 'bottom-line', key: 'pendingSkills' } as const, [])
32const images = atom({ plugin: 'bottom-line', key: 'images' } as const, [])
33const pendingImages = atom({ plugin: 'bottom-line', key: 'pendingImages' } as const, [])
34const turnTexts = atom({ plugin: 'bottom-line', key: 'turnTexts' } as const, [])
35const turnTools = atom({ plugin: 'bottom-line', key: 'turnTools' } as const, [])
36const tailTexts = atom({ plugin: 'bottom-line', key: 'tailTexts' } as const, [])
37const summaries = atom({ plugin: 'bottom-line', key: 'summaries' } as const, [])
38const hiddenTexts = atom({ plugin: 'bottom-line', key: 'hiddenTexts' } as const, [])
39const hiddenTools = atom({ plugin: 'bottom-line', key: 'hiddenTools' } as const, [])
40
41const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
42const PULSE = ['╸', '━', '╸', ' ']
43const BAR = 18
44const DOTS = ['', '.', '..', '...']
45const TWINKLE = ['✦', '✦', '✧', '✧']
46const OPEN_ACTIONS = 8
47const HISTORY_KEEP = 50
48
49const THUMB_ROWS = 4
50const IMAGE_FILE = /\.(png|jpe?g|gif|webp|heic|tiff?|bmp)$/i
51
52// Tools that keep the plan, not do the work: never an action of their own.
53const PLAN_KEEPING = ['TodoWrite', 'TaskUpdate', 'TaskCreate', 'TaskList', 'TaskGet', PLAN_TOOL]
54
55// Tools that hand the person something to open (a page's link, a file): their rows show
56// once the turn ends, so a link is never lost with the work around it.
57const DELIVERING = ['Artifact', 'SendUserFile']
58
59// Every base64 picture in a row's blocks, a tool result's own blocks included.
60type FoundImage = { data: string; toolUseId?: string }
61const imageBlocks = (content: unknown, toolUseId?: string): FoundImage[] => {
62 if (!Array.isArray(content)) return []
63 return content.flatMap((block: unknown): FoundImage[] => {
64 if (!block || typeof block !== 'object') return []
65 const b = block as { type?: unknown; source?: { type?: unknown; data?: unknown }; content?: unknown; tool_use_id?: unknown }
66 if (b.type === 'image' && b.source?.type === 'base64' && typeof b.source.data === 'string') {
67 return [{ data: b.source.data, toolUseId }]
68 }
69 if (b.type === 'tool_result') return imageBlocks(b.content, typeof b.tool_use_id === 'string' ? b.tool_use_id : undefined)
70 return []
71 })
72}
73
74// A small PNG of one picture, whatever its kind, made by the Mac's own sips in the
75// person's cache (a day's worth kept); its box is as wide as its shape asks.
76async function makeThumb(
77 $: EngineInterface,
78 data: string,
79 id: string,
80): Promise<{ file?: string; png?: string; columns: number } | null> {
81 const script = [
82 'd="$HOME/Library/Caches/bottom-line"; mkdir -p "$d" && find "$d" -type f -mtime +1 -delete',
83 'i="$d/$1.src"; o="$d/$1.png"',
84 'base64 -D -o "$i" && sips -s format png -Z 320 "$i" --out "$o" >/dev/null 2>&1 && rm -f "$i"',
85 'sips -g pixelWidth -g pixelHeight "$o" | awk -v o="$o" \'/pixelWidth/{w=$2} /pixelHeight/{h=$2} END{printf "%s\\t%s\\t%s", o, w, h}\'',
86 ].join('\n')
87 try {
88 const ran = await $.process.run(['/bin/sh', '-c', script, 'sh', id], { stdin: data, timeoutMs: 20000 })
89 const [file, width, height] = ran.stdout.trim().split('\t')
90 const w = Number(width)
91 const h = Number(height)
92 if (ran.exitCode === 0 && file && w > 0 && h > 0) return { file, columns: boxColumns(w, h) }
93 } catch {
94 // No shell or no sips (Linux, Windows): a PNG can still be shown as it is.
95 }
96
97 return pngAsItIs(data)
98}
99
100// A terminal cell is about twice as tall as it is wide.
101const boxColumns = (width: number, height: number) =>
102 Math.max(4, Math.min(24, Math.round((THUMB_ROWS * 2 * width) / height)))
103
104// A PNG small enough to hand the terminal whole (1 MiB), sized from its own header.
105const PNG_SIGNATURE = 'iVBORw0KGgo'
106const pngAsItIs = (data: string) => {
107 if (!data.startsWith(PNG_SIGNATURE) || (data.length * 3) / 4 > 1024 * 1024) return null
108 try {
109 const head = atob(data.slice(0, 32))
110 const word = (at: number) =>
111 ((head.charCodeAt(at) << 24) | (head.charCodeAt(at + 1) << 16) | (head.charCodeAt(at + 2) << 8) | head.charCodeAt(at + 3)) >>> 0
112 const width = word(16)
113 const height = word(20)
114 if (!(width > 0) || !(height > 0)) return null
115
116 return { png: data, columns: boxColumns(width, height) }
117 } catch {
118 return null
119 }
120}
121
122// Add a skill once, keeping the order skills were first used in.
123const withSkill = (list: string[], name: string) => (list.includes(name) ? list : [...list, name])
124
125// The skills line: the names that fit the width, the rest counted.
126const fitSkills = (names: string[], room: number) => {
127 const shown: string[] = []
128 let used = 0
129 for (const name of names) {
130 const cost = name.length + (shown.length > 0 ? 3 : 0)
131 if (shown.length > 0 && used + cost > room - 10) break
132 shown.push(name)
133 used += cost
134 }
135
136 return { shown, more: names.length - shown.length }
137}
138
139// A bar with no end known: a bright run that sweeps across and back.
140const sweep = (frame: number) => {
141 const run = 4
142 const span = BAR - run
143 const step = frame % (span * 2)
144 const from = step <= span ? step : span * 2 - step
145
146 return { before: from, run, after: BAR - from - run }
147}
148
149// What Claude is told; with its text left on screen, the first line says so.
150const planText = (isTextHidden: boolean) =>
151 [
152 '# Bottom line',
153 isTextHidden
154 ? 'While you work, the person does not see your text or your tool calls. They see only your plan, live, and when you finish, only your final message.'
155 : 'While you work, the person follows your plan, live, above the prompt.',
156 `- For every prompt, even a question or a one-step task, your very first action is to call ${PLAN_TOOL} with every step, in plain words, the first one in_progress (a question is two steps at least: finding the answer, then writing it). Call it again each time a step finishes or the next one starts, always with the whole list and exactly one step in_progress until all are done. Change the plan when you learn more.`,
157 isTextHidden
158 ? '- Your final message (everything you write after your last tool call, plan updates aside) is the only text the person reads. Make it a complete, short summary: what you did, what you found, and anything they must do or decide. Put every link, artifact URL and file path they need in it, even if you gave it earlier.'
159 : '- End with a complete, short summary: what you did, what you found, and anything they must do or decide. Put every link, artifact URL and file path they need in it.',
160 ].join('\n')
161
162const PLAN_SPEC = {
163 name: 'plan',
164 description:
165 'Show the person your plan for this task, live. Send the whole list every time: each step in plain words with its status. Exactly one step is in_progress while you work.',
166 inputSchema: {
167 type: 'object',
168 properties: {
169 steps: {
170 type: 'array',
171 items: {
172 type: 'object',
173 properties: {
174 title: { type: 'string', description: 'The step, in a few plain words' },
175 status: { type: 'string', enum: ['pending', 'in_progress', 'completed'] },
176 },
177 required: ['title', 'status'],
178 },
179 },
180 },
181 required: ['steps'],
182 },
183 isDeferred: false,
184} as const
185
186const short = (text: string, room: number) =>
187 text.length > room ? `${text.slice(0, Math.max(1, room - 1))}…` : text
188
189const base = (path: unknown) =>
190 typeof path === 'string' ? (path.split('/').pop() ?? path) : ''
191
192const since = (from: number, to: number) => {
193 const seconds = Math.max(0, Math.round((to - from) / 1000))
194 return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`
195}
196
197const clockTime = (at: number) => {
198 const when = new Date(at)
199 return `${String(when.getHours()).padStart(2, '0')}:${String(when.getMinutes()).padStart(2, '0')}`
200}
201
202const plural = (count: number, one: string, many: string) => `${count} ${count === 1 ? one : many}`
203
204// One plain line for what a tool call is doing.
205const describe = (tool: string, input: Record<string, unknown>) => {
206 switch (tool) {
207 case 'Read':
208 return `Reading ${base(input.file_path)}`
209 case 'Edit':
210 case 'Write':
211 case 'NotebookEdit':
212 return `Editing ${base(input.file_path ?? input.notebook_path)}`
213 case 'Bash':
214 return typeof input.description === 'string'
215 ? input.description
216 : `Running ${String(input.command ?? '')}`
217 case 'Grep':
218 case 'Glob':
219 return `Searching for ${String(input.pattern ?? '')}`
220 case 'WebSearch':
221 return `Searching the web: ${String(input.query ?? '')}`
222 case 'WebFetch':
223 return 'Reading a web page'
224 case 'Agent':
225 return `Asking a helper: ${String(input.description ?? '')}`
226 case 'Skill':
227 return `Using the ${String(input.skill ?? '')} skill`
228 default:
229 return `Using ${tool.replace(/^mcp__[^_]+__/, '')}`
230 }
231}
232
233// Why a call failed, in one line: the exit code where there is one, and the last
234// thing it said (a test run's verdict comes last).
235const failureReason = (text: string | undefined) => {
236 const lines = (text ?? '').split('\n').map(line => line.trim()).filter(line => line.length > 0)
237 if (lines.length === 0) return 'failed'
238 const exit = /^exit code (\d+)$/i.exec(lines[0] ?? '')
239 const last = lines[lines.length - 1] ?? ''
240 if (exit && lines.length > 1) return `exit ${exit[1]} · ${last}`
241 return exit ? `exit ${exit[1]}` : last
242}
243
244const capped = <T,>(list: T[]) => list.slice(-KEEP)
245const trimmed = (text: string) => text.trim()
246// Text as compared: the screen may draw a block with its spacing changed.
247const plain = (text: string) => text.replace(/\s+/g, ' ').trim()
248const isStatus = (value: unknown): value is StepStatus =>
249 value === 'pending' || value === 'in_progress' || value === 'completed'
250
251// A new plan: note which steps just finished (they flash) and when the current one began.
252const setPlan = async ($: EngineInterface, next: Step[]) => {
253 const at = await $.clock.now()
254 const before = await read($, steps)
255 const was = (id: string) => before.find(step => step.id === id)
256 const finished = next.filter(step => step.status === 'completed' && was(step.id)?.status !== 'completed')
257 const current = next.find(step => step.status === 'in_progress')
258 const wasCurrent = before.find(step => step.status === 'in_progress')
259
260 if (finished.length > 0) {
261 await update($, doneAt, map => ({ ...map, ...Object.fromEntries(finished.map(step => [step.id, at])) }))
262 }
263 if (current && current.title !== wasCurrent?.title) await update($, stepSince, () => at)
264 await update($, steps, () => next)
265}
266
267// A block of a final message is never hidden, nor any part of one.
268const isHidden = async ($: EngineInterface, text: string) => {
269 const key = plain(text)
270 if (key.length > 0 && (await read($, summaries)).some(summary => plain(summary).includes(key))) return false
271 if (await read($, isWorking)) return true
272
273 return (await read($, hiddenTexts)).some(hidden => plain(hidden) === key)
274}
275
276// The task just finished, kept for the history panel (newest first, fifty kept).
277async function keepInHistory($: EngineInterface, id: string, isAborted: boolean, answer: string) {
278 const at = await read($, turnSince)
279 const ended = await $.clock.now()
280 const list = await read($, steps)
281 const recent = (await read($, activity)).filter(action => action.helper === undefined)
282 const failuresOf = (title: string) => recent.filter(action => action.step === title && action.failed).length
283 const turn = await read($, turnNow)
284 const team = (await read($, helpers)).filter(helper => helper.turn === turn)
285 const lastTitle = list[list.length - 1]?.title
286 const helpersOf = (title: string) => historyHelpers(team, title, title === lastTitle)
287 const firstStep = list[0]?.title ?? recent[0]?.title ?? 'Task'
288 const asked = (await read($, turnTitle)) || firstStep
289 const entry: HistoryEntry = {
290 id,
291 at,
292 seconds: Math.max(0, Math.round((ended - at) / 1000)),
293 title: short(asked, 80),
294 isAborted,
295 steps:
296 list.length > 0
297 ? list.map(step => ({
298 title: step.title,
299 status: step.status,
300 failures: failuresOf(step.title),
301 ...(helpersOf(step.title).length > 0 ? { helpers: helpersOf(step.title) } : {}),
302 }))
303 : recent.slice(-30).map(action => ({
304 title: action.title,
305 status: 'completed' as const,
306 failures: action.failed ? 1 : 0,
307 })),
308 skills: await read($, skills),
309 images: (await read($, images)).length,
310 summary: short((answer.split('\n').find(line => line.trim().length > 0) ?? '').trim(), 200),
311 }
312 const before = await $.store.get('history')
313 const kept = Array.isArray(before) ? (before as HistoryEntry[]) : []
314 await $.store.set('history', [entry, ...kept.filter(old => old.id !== id)].slice(0, HISTORY_KEEP))
315}
316
317// A step's helpers as the history keeps them; the last step also takes those started
318// before any plan (they belong to no step).
319const historyHelpers = (team: Helper[], title: string, takesLoose: boolean): HistoryHelper[] =>
320 team
321 .filter(helper => !helper.parent && (helper.step === title || (takesLoose && helper.step === undefined)))
322 .map(helper => ({
323 name: helper.name,
324 job: helper.job,
325 kind: helper.kind,
326 seconds: Math.max(0, Math.round(((helper.endAt ?? helper.at) - helper.at) / 1000)),
327 status: helper.status,
328 }))
329
330// How a helper ended, from its loop's reason or its notice's status word.
331const helperEnd = (word: string): HelperStatus =>
332 word === 'answer' || word === 'completed' ? 'done' : word === 'aborted' || word === 'killed' ? 'stopped' : 'failed'
333
334// A new helper: of the task shown, under the step Claude is on, or under the helper that
335// started it.
336async function addHelper($: EngineInterface, fresh: Pick<Helper, 'id' | 'kind' | 'name' | 'job'> & { parent?: string }) {
337 const team = await read($, helpers)
338 if (team.some(helper => helper.id === fresh.id)) return
339 const parent = fresh.parent && team.some(helper => helper.id === fresh.parent) ? fresh.parent : undefined
340 const current = (await read($, steps)).find(step => step.status === 'in_progress')
341 const helper: Helper = {
342 id: fresh.id,
343 kind: fresh.kind,
344 name: fresh.name,
345 job: fresh.job,
346 turn: await read($, turnNow),
347 ...(parent ? { parent } : current ? { step: current.title } : {}),
348 at: await $.clock.now(),
349 status: 'running',
350 }
351 await update($, helpers, list => (list.some(one => one.id === helper.id) ? list : [...list, helper].slice(-60)))
352}
353
354// A helper ended. Only its mark changes here: a notice reaches the mod where it has one
355// second, so the history is written a moment later, outside it.
356async function endHelper($: EngineInterface, id: string, status: HelperStatus) {
357 const at = await $.clock.now()
358 await update($, helpers, list =>
359 list.map(one => (one.id === id && one.status === 'running' ? { ...one, status, endAt: at } : one)),
360 )
361 $.clock.after(50, () => {
362 void settleHelpers($)
363 })
364}
365
366// Ended helpers of a task no longer being worked on: written into that task's history,
367// and, for a task no longer shown, let go.
368async function settleHelpers($: EngineInterface) {
369 const shown = await read($, turnNow)
370 const working = await read($, isWorking)
371 const team = await read($, helpers)
372 const turns = [...new Set(team.filter(one => one.status !== 'running' && (one.turn !== shown || !working)).map(one => one.turn))]
373 if (turns.length === 0) return
374
375 const before = await $.store.get('history')
376 const kept = Array.isArray(before) ? (before as HistoryEntry[]) : []
377 const patched = kept.map(entry => {
378 if (!turns.includes(entry.id)) return entry
379 const ofTask = team.filter(one => one.turn === entry.id)
380 const lastTitle = entry.steps[entry.steps.length - 1]?.title
381 return {
382 ...entry,
383 steps: entry.steps.map(step => {
384 const helpersNow = historyHelpers(ofTask, step.title, step.title === lastTitle)
385 return helpersNow.length > 0 ? { ...step, helpers: helpersNow } : step
386 }),
387 }
388 })
389 await $.store.set('history', patched)
390 await update($, helpers, list => list.filter(one => one.turn === shown || one.status === 'running'))
391}
392
393// The task id and how it ended, from a background task's notice.
394const taskNotice = (text: string) => {
395 const id = /<task-id>([^<]+)<\/task-id>/.exec(text)?.[1]?.trim()
396 const status = /<status>([^<]+)<\/status>/.exec(text)?.[1]?.trim()
397 return id && status ? { id, status } : null
398}
399
400const rowText = (content: unknown) =>
401 typeof content === 'string'
402 ? content
403 : Array.isArray(content)
404 ? content
405 .map(block => (block && typeof block === 'object' && 'text' in block ? String((block as { text: unknown }).text) : ''))
406 .join('\n')
407 : ''
408
409// The mod was called focus-list before: once, its saved history and on/off choice come
410// along from that name's own file (where a shell can read it).
411async function bringOldSaves($: EngineInterface) {
412 if ((await $.store.get('movedFromFocusList')) === true) return
413 await $.store.set('movedFromFocusList', true)
414 try {
415 const ran = await $.process.run(['/bin/sh', '-c', 'cat "$HOME"/.claude/plugins/store/focus-list_*.json 2>/dev/null | head -c 4000000'])
416 if (ran.exitCode !== 0 || !ran.stdout.trim().startsWith('{')) return
417 const old = JSON.parse(ran.stdout) as { isOn?: unknown; history?: unknown }
418 if ((await $.store.get('history')) === undefined && Array.isArray(old.history)) await $.store.set('history', old.history)
419 if ((await $.store.get('isOn')) === undefined && typeof old.isOn === 'boolean') await $.store.set('isOn', old.isOn)
420 } catch {
421 // Nothing to bring: a fresh start under the new name.
422 }
423}
424
425// What each of Claude's tool calls is called, for naming the pictures it brings back.
426const toolNames = new Map<string, string>()
427
428// The animation's heartbeat: runs only while Claude works.
429let ticker: Timer | undefined
430
431function startTicking($: EngineInterface) {
432 ticker?.cancel()
433 ticker = $.clock.every(TICK, () => {
434 void $.clock.now().then(at => update($, clock, () => at))
435 })
436}
437
438function stopTicking() {
439 ticker?.cancel()
440 ticker = undefined
441}
442
443export const register: Register = (on, options) => {
444 const isTextHidden = options.hideText !== false
445 const hasSkillsLine = options.skillsLine !== false
446 const hasImagesRow = options.imagesRow !== false
447 const keepsFinished = options.keepFinished !== false
448 const hasHelpers = options.helpers !== false
449
450 on('session.start', async ($, e, next) => {
451 await bringOldSaves($)
452 const stored = await $.store.get('isOn')
453 await update($, isOn, () => stored !== false)
454 await $.command.register({
455 name: NAME,
456 description: 'The bottom line: no argument turns it on or off; "text" shows or hides Claude’s text while it works; "history" opens your past tasks; "settings" its switches',
457 argumentHint: '[text | history | settings]',
458 })
459 await $.tool.register(PLAN_SPEC)
460 if (await read($, isWorking)) startTicking($)
461
462 return next(e)
463 })
464
465 on('command.run', { command: NAME }, async ($, e) => {
466 const asked = e.args.trim().toLowerCase()
467 if (asked === 'history') {
468 await $.ui.open({ id: HISTORY_PANE, title: 'History' })
469
470 return { text: 'History opened beside the chat.' }
471 }
472 if (asked === 'text') {
473 const row = (await $.config.list()).find(one => one.key === `${NAME}.hideText`)
474 if (!row) {
475 return {
476 text: `Set it in ~/.claude/settings.json: "pluginConfigs" → "${NAME}@inline" → "options" → "hideText": ${isTextHidden ? 'false' : 'true'}.`,
477 }
478 }
479 await $.config.set({ key: row.key, value: !isTextHidden })
480
481 return {
482 text: isTextHidden
483 ? 'Claude’s text now shows while it works. The plan stays above the prompt.'
484 : 'Claude’s text is hidden again while it works. You see the plan, then the final message.',
485 }
486 }
487 if (asked === 'settings') {
488 await $.ui.open({ id: SETTINGS_PANE, title: 'Bottom line settings' })
489 const rows = (await $.config.list()).filter(row => row.key.startsWith(`${NAME}.`))
490
491 return {
492 text:
493 rows.length === 0
494 ? `Settings opened beside the chat. Change them in ~/.claude/settings.json under "pluginConfigs" → "${NAME}@inline" → "options".`
495 : `Settings opened beside the chat: ${rows.map(row => `${row.label} ${row.value === false ? 'off' : 'on'}`).join(' · ')}.`,
496 }
497 }
498
499 const turnOn = !(await read($, isOn))
500 await update($, isOn, () => turnOn)
501 await $.store.set('isOn', turnOn)
502
503 return {
504 text: turnOn
505 ? 'Bottom line is on. While Claude works you see a live plan; when it finishes, only the summary.'
506 : 'Bottom line is off. Claude’s work shows as usual.',
507 }
508 }).catch(() => ({ text: 'Bottom line could not do that. Try again.' }))
509
510 // Ask Claude to keep its plan up to date and to end with a full summary.
511 on('prompt.compose', async ($, e, next) => {
512 const composed = await next(e)
513 if (!(await read($, isOn))) return composed
514
515 return {
516 sections: [...composed.sections, { id: `${NAME}:plan`, text: planText(isTextHidden), scope: 'session' }],
517 }
518 })
519
520 // The plan tool only draws; it never needs the person's yes.
521 on('tool.check', { tool: PLAN_MATCH }, () => ({ decision: 'allow' }))
522
523 on('tool.call', { tool: PLAN_MATCH }, async ($, e) => {
524 if (e.agentId === undefined) await update($, turnTools, list => capped([...list, e.tool_use_id]))
525 const input = e as unknown as { steps?: unknown }
526 const list = Array.isArray(input.steps) ? input.steps : []
527 const plan: Step[] = list.map((step, at) => ({
528 id: `plan:${at}`,
529 title: String((step as { title?: unknown })?.title ?? ''),
530 status: isStatus((step as { status?: unknown })?.status) ? (step as { status: StepStatus }).status : 'pending',
531 }))
532 await setPlan($, plan)
533 const done = plan.filter(step => step.status === 'completed').length
534
535 return { result: `Plan shown: ${done} of ${plan.length} done.` }
536 }).catch(() => ({ result: 'The plan could not be shown; carry on.' }))
537
538 // A skill the person typed as /name expands before its turn starts: hold it for that turn.
539 // Skills Claude loads itself are caught at the Skill tool call, on the main loop alone.
540 on('skill.prompt', async ($, e, next) => {
541 if ((await read($, isOn)) && !(await read($, isWorking))) {
542 await update($, pendingSkills, list => withSkill(list, e.skill))
543 }
544
545 return next(e)
546 })
547
548 // A helper starts: kept under the step Claude is on (or the helper that started it).
549 on('agent.spawn', async ($, e, next) => {
550 const started = await next(e)
551 if (!hasHelpers || started.deny !== undefined || !started.agentId || !(await read($, isOn))) return started
552 await addHelper($, {
553 id: started.agentId,
554 kind: 'agent',
555 name: e.subagentType,
556 job: e.description,
557 ...(e.parentAgentId ? { parent: e.parentAgentId } : {}),
558 })
559
560 return started
561 }).catch(($, e, next) => next(e))
562
563 on('turn.start', async ($, e, next) => {
564 // A helper's own run starting: one with no spawn of its own (a forked skill) is
565 // found in the session's agents.
566 if (e.agentId !== undefined) {
567 if (hasHelpers && (await read($, isOn))) {
568 const id = e.agentId
569 const known = (await read($, helpers)).find(helper => helper.id === id)
570 if (known) {
571 await update($, helpers, list =>
572 list.map(helper => (helper.id === id ? { ...helper, status: 'running' as const, endAt: undefined } : helper)),
573 )
574 } else {
575 const info = (await $.agent.list()).find(agent => agent.id === id)
576 if (info) {
577 await addHelper($, {
578 id,
579 kind: 'agent',
580 name: info.type,
581 job: info.description,
582 ...(info.parentId ? { parent: info.parentId } : {}),
583 })
584 }
585 }
586 }
587
588 return next(e)
589 }
590 if (await read($, isOn)) {
591 const at = await $.clock.now()
592 await update($, steps, () => [])
593 await update($, activity, () => [])
594 await update($, doneAt, () => ({}))
595 await update($, finish, () => null)
596 await update($, openStep, () => null)
597 await update($, openHelper, () => null)
598 await update($, turnNow, () => e.turnId)
599 // Helpers of earlier tasks: the ended ones written into their history and let go; the
600 // running ones stay until they end (their task's history waits on them).
601 await settleHelpers($)
602 await update($, turnTitle, () => (e.text.split('\n').find(line => line.trim().length > 0) ?? '').trim())
603 const typed = await read($, pendingSkills)
604 await update($, skills, () => typed)
605 await update($, pendingSkills, () => [])
606 const given = await read($, pendingImages)
607 await update($, images, () => given)
608 await update($, pendingImages, () => [])
609 await update($, turnTexts, () => [])
610 await update($, tailTexts, () => [])
611 await update($, turnTools, () => [])
612 await update($, turnSince, () => at)
613 await update($, stepSince, () => at)
614 await update($, clock, () => at)
615 await update($, isWorking, () => true)
616 startTicking($)
617 }
618
619 return next(e)
620 })
621
622 // Keep every text block the model writes this turn, so it stays hidden afterwards.
623 on('session.append', async ($, e, next) => {
624 const stored = await next(e)
625 if (e.agentId !== undefined || !(await read($, isOn))) return stored
626
627 // A background task's notice says how a helper or a background command ended.
628 if (hasHelpers && e.message.role === 'user') {
629 const notice = taskNotice(rowText(e.message.content))
630 if (notice) await endHelper($, notice.id, helperEnd(notice.status))
631 }
632
633 // Pictures you give and pictures Claude's tools bring back: thumbnails for the list.
634 // One given before its turn starts is held for that turn, as a typed skill is.
635 if (hasImagesRow && (e.door === 'prompt' || e.door === 'tool-result')) {
636 const found = imageBlocks(e.message.content)
637 const isTurn = await read($, isWorking)
638 for (const [index, picture] of found.entries()) {
639 const id = `${e.uuid}-${index}`.replace(/[^A-Za-z0-9-]/g, '')
640 const made = await makeThumb($, picture.data, id)
641 if (made === null) continue
642 const name = picture.toolUseId ? (toolNames.get(picture.toolUseId) ?? 'image') : 'your image'
643 const thumb: Thumb = { id, name, ...made }
644 if (isTurn) await update($, images, list => [...list, thumb].slice(-20))
645 else await update($, pendingImages, list => [...list, thumb].slice(-20))
646 }
647 }
648
649 if (e.door !== 'response' || !(await read($, isWorking))) return stored
650
651 const content = Array.isArray(e.message.content) ? e.message.content : []
652 const texts = content.flatMap(block =>
653 block && typeof block === 'object' && 'type' in block && block.type === 'text' && 'text' in block
654 ? [trimmed(String(block.text))]
655 : [],
656 )
657 if (texts.length > 0) await update($, turnTexts, list => capped([...list, ...texts]))
658
659 // The final message is all Claude writes after its last real tool call: a plan
660 // update between its parts does not end it.
661 let tail = await read($, tailTexts)
662 for (const block of content) {
663 if (!block || typeof block !== 'object' || !('type' in block)) continue
664 if (block.type === 'text' && 'text' in block) {
665 const text = trimmed(String(block.text))
666 if (text.length > 0) tail = [...tail, text]
667 } else if (block.type === 'tool_use' && !PLAN_KEEPING.includes('name' in block ? String(block.name) : '')) {
668 tail = []
669 }
670 }
671 await update($, tailTexts, () => tail)
672
673 return stored
674 }).catch(($, e, next) => next(e))
675
676 on('tool.call', async ($, e, next) => {
677 // A helper's own action: kept under the helper, the newest its "now".
678 if (e.agentId !== undefined) {
679 const id = e.agentId
680 if (!hasHelpers || PLAN_KEEPING.includes(e.tool) || !(await read($, helpers)).some(helper => helper.id === id)) {
681 return next(e)
682 }
683 const title = describe(e.tool, e as unknown as Record<string, unknown>)
684 const doing: Activity = { id: e.tool_use_id, title, at: await $.clock.now(), helper: id }
685 await update($, activity, list => [...list, doing].slice(-300))
686 await update($, helpers, list => list.map(helper => (helper.id === id ? { ...helper, now: title } : helper)))
687 const ran = await next(e)
688 const endAt = await $.clock.now()
689 const failed = ran.isError === true ? failureReason(ran.text) : undefined
690 await update($, activity, list =>
691 list.map(action => (action.id === e.tool_use_id ? { ...action, endAt, ...(failed ? { failed } : {}) } : action)),
692 )
693
694 return ran
695 }
696 if (!(await read($, isWorking))) return next(e)
697
698 const input = e as unknown as Record<string, unknown>
699 if (!DELIVERING.includes(e.tool)) await update($, turnTools, list => capped([...list, e.tool_use_id]))
700 toolNames.set(
701 e.tool_use_id,
702 e.tool === 'Read' && IMAGE_FILE.test(String(input.file_path ?? ''))
703 ? base(input.file_path)
704 : /screenshot|zoom/i.test(e.tool)
705 ? 'screenshot'
706 : e.tool.replace(/^mcp__[^_]+__/, ''),
707 )
708
709 if (e.tool === 'TodoWrite') {
710 const todos = Array.isArray(input.todos) ? input.todos : []
711 await setPlan(
712 $,
713 todos.map((todo, at) => ({
714 id: `todo:${at}`,
715 title: String(todo?.content ?? ''),
716 status: isStatus(todo?.status) ? todo.status : 'pending',
717 })),
718 )
719 } else if (e.tool === 'TaskUpdate') {
720 const id = String(input.taskId ?? '')
721 const status = input.status
722 const list = await read($, steps)
723 await setPlan(
724 $,
725 status === 'deleted'
726 ? list.filter(step => step.id !== id)
727 : list.map(step =>
728 step.id !== id
729 ? step
730 : {
731 ...step,
732 ...(isStatus(status) ? { status } : {}),
733 ...(typeof input.subject === 'string' ? { title: input.subject } : {}),
734 },
735 ),
736 )
737 }
738 if (e.tool === 'Skill' && typeof input.skill === 'string') {
739 const name = input.skill
740 await update($, skills, list => withSkill(list, name))
741 }
742
743 // Each action is kept with the step it belongs to, when it ended and how it went.
744 const isAction = !PLAN_KEEPING.includes(e.tool)
745 if (isAction) {
746 const current = (await read($, steps)).find(step => step.status === 'in_progress')
747 const doing: Activity = {
748 id: e.tool_use_id,
749 title: describe(e.tool, input),
750 at: await $.clock.now(),
751 ...(current ? { step: current.title } : {}),
752 }
753 await update($, activity, list => [...list, doing].slice(-200))
754 }
755
756 const ran = await next(e)
757
758 if (isAction) {
759 const endAt = await $.clock.now()
760 const failed = ran.isError === true ? failureReason(ran.text) : undefined
761 await update($, activity, list =>
762 list.map(action => (action.id === e.tool_use_id ? { ...action, endAt, ...(failed ? { failed } : {}) } : action)),
763 )
764 }
765
766 // A command left running in the background is a helper until its notice says it ended;
767 // one Claude stops ends there.
768 if (hasHelpers && e.tool === 'Bash' && ran.deny === undefined && !ran.isError) {
769 const backgroundId = (ran.result as { backgroundTaskId?: unknown } | undefined)?.backgroundTaskId
770 if (typeof backgroundId === 'string') {
771 await addHelper($, {
772 id: backgroundId,
773 kind: 'shell',
774 name: typeof input.description === 'string' ? input.description : short(String(input.command ?? ''), 40),
775 job: '',
776 })
777 }
778 }
779 if (hasHelpers && e.tool === 'TaskStop' && ran.deny === undefined && !ran.isError) {
780 const stopped = input.task_id ?? input.shell_id
781 if (typeof stopped === 'string') await endHelper($, stopped, 'stopped')
782 }
783
784 if (e.tool === 'TaskCreate' && ran.deny === undefined && !ran.isError) {
785 const task = (ran.result as { task?: { id?: string; subject?: string } } | undefined)?.task
786 if (task?.id) {
787 const step: Step = { id: task.id, title: task.subject ?? String(input.subject ?? ''), status: 'pending' }
788 await update($, steps, list => [...list, step])
789 }
790 }
791
792 return ran
793 }).catch(($, e, next) => next(e))
794
795 // The turn is over: hide what it wrote, keep only its final message, and file it in the history.
796 on('turn.complete', async ($, e, next) => {
797 if (e.agentId !== undefined) {
798 if (hasHelpers) await endHelper($, e.agentId, helperEnd(e.reason))
799
800 return next(e)
801 }
802 if (await read($, isWorking)) {
803 stopTicking()
804 const answer = trimmed(e.answer)
805 const texts = await read($, turnTexts)
806 // Trusted only where it ends with the answer the engine reports.
807 const seen = await read($, tailTexts)
808 const tail = answer && plain(seen.join(' ')).includes(plain(answer)) ? seen : []
809 const kept = texts.filter(text => text.length > 0 && (plain(answer).includes(plain(text)) || tail.includes(text)))
810 const gone = texts.filter(text => !kept.includes(text))
811 const tools = await read($, turnTools)
812
813 await update($, summaries, list => capped([...list, ...kept, ...tail, ...(answer ? [answer] : [])]))
814 await update($, hiddenTexts, list => capped([...list, ...gone]))
815 await update($, hiddenTools, list => capped([...list, ...tools]))
816
817 // The list stays, finished, until the next prompt: an answered turn
818 // finished the step it was on (the summary, as a rule).
819 const at = await $.clock.now()
820 if (!e.isAborted) {
821 await update($, steps, list =>
822 list.map(step => (step.status === 'in_progress' ? { ...step, status: 'completed' as const } : step)),
823 )
824 }
825 await keepInHistory($, e.turnId, e.isAborted, tail.length > 0 ? tail.join('\n\n') : answer)
826 await update($, clock, () => at)
827 await update($, finish, () => ({ at, isAborted: e.isAborted }))
828 await update($, isWorking, () => false)
829 }
830
831 return next(e)
832 })
833
834 on('session.end', async ($, e, next) => {
835 stopTicking()
836
837 return next(e)
838 })
839
840 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
841 if (!isTextHidden || !(await read($, isOn)) || !(await isHidden($, e.props.text))) return next(e)
842
843 const { Box } = $.ui.resolve(e)
844
845 return <Box display="none" />
846 })
847
848 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
849 if (!(await read($, isOn)) || e.props.isExpanded) return next(e)
850 const hide =
851 e.props.tool === PLAN_TOOL ||
852 (isTextHidden &&
853 ((await read($, isWorking)) || (await read($, hiddenTools)).includes(e.props.tool_use_id)))
854 if (!hide) return next(e)
855
856 const { Box } = $.ui.resolve(e)
857
858 return <Box display="none" />
859 })
860
861 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
862 if (!(await read($, isOn)) || e.props.isExpanded) return next(e)
863 const hide =
864 e.props.tool === PLAN_TOOL ||
865 (isTextHidden &&
866 ((await read($, isWorking)) || (await read($, hiddenTools)).includes(e.props.tool_use_id)))
867 if (!hide) return next(e)
868
869 const { Box } = $.ui.resolve(e)
870
871 return <Box display="none" />
872 })
873
874 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
875 if (!(await read($, isOn)) || e.props.isExpanded) return next(e)
876 const hidden = await read($, hiddenTools)
877 const isWork = isTextHidden && (await read($, isWorking))
878 const hide =
879 isWork ||
880 e.props.calls.every(
881 call =>
882 call.tool === PLAN_TOOL ||
883 (isTextHidden && call.tool_use_id !== undefined && hidden.includes(call.tool_use_id)),
884 )
885 if (!hide) return next(e)
886
887 const { Box } = $.ui.resolve(e)
888
889 return <Box display="none" />
890 })
891
892 // A click on a step or a task opens it; it must not leave the row lit. The engine marks
893 // the row a click lands on until Esc, so the ring is kept off this mod's rows.
894 on('ui.focus', async ($, e, next) => {
895 if (e.origin.kind === 'person' && e.plugin === NAME && /^(step|task|setting|helper):/.test(e.element ?? '')) return {}
896
897 return next(e)
898 })
899
900 // The live plan, above the prompt, while Claude works; still, once it is done.
901 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
902 if (e.props.hasSurvey || !(await read($, isOn))) return next(e)
903 const ended = await read($, finish)
904 const working = await read($, isWorking)
905 if (!working && (ended === null || !keepsFinished)) return next(e)
906
907 const { Box, Button, Text } = $.ui.resolve(e)
908 const at = await read($, clock)
909 const list = await read($, steps)
910 const everything = await read($, activity)
911 const recent = everything.filter(action => action.helper === undefined)
912 const opened = await read($, openStep)
913 const openedHelper = await read($, openHelper)
914 const turnId = await read($, turnNow)
915 const team = hasHelpers ? (await read($, helpers)).filter(helper => helper.turn === turnId) : []
916 const used = hasSkillsLine ? await read($, skills) : []
917 const skillRows = used.length > 0 ? 2 : 0
918 const pictures = hasImagesRow ? await read($, images) : []
919 const Picture = e.surface === 'terminal' ? $.ui.resolve(e).Image : undefined
920 const imageRows = pictures.length > 0 && Picture ? THUMB_ROWS + 1 : 0
921 const frame = Math.floor(at / TICK)
922 const spin = SPINNER[frame % SPINNER.length]
923 const width = Math.max(20, e.props.bodyColumns - 8)
924
925 // A failure's reason takes the room its action's name leaves on the line.
926 const reasonRoom = (title: string) => Math.max(12, width - Math.min(title.length, width / 2) - 8)
927 const actionsOf = (title: string) => recent.filter(action => action.step === title)
928 const failuresOf = (title: string) => actionsOf(title).filter(action => action.failed)
929
930 // The helpers a step started (by title); undefined: those started before any plan.
931 const stepHelpers = (title: string | undefined) => team.filter(helper => !helper.parent && helper.step === title)
932
933 // A step's row is a button: a press opens its actions under it, another closes them.
934 const stepButton = (step: Step, children: RenderChildren[], isDim: boolean) => {
935 const hasActions = actionsOf(step.title).length > 0 || stepHelpers(step.title).length > 0
936 const mark = !hasActions ? ' ' : opened === step.title ? '▾ ' : '▸ '
937
938 return (
939 <Button
940 key={`step:${step.id}`}
941 plain
942 dimColor={isDim}
943 onPress={() => update($, openStep, current => (current === step.title ? null : step.title))}
944 >
945 {mark}
946 {children}
947 </Button>
948 )
949 }
950
951 // Actions, each with how it went and how long it took; the newest kept.
952 const actionLines = (actions: Activity[], pad: string) => {
953 const shown = actions.slice(-OPEN_ACTIONS)
954 const earlier = actions.length - shown.length
955 const room = Math.max(12, width - pad.length - 8)
956
957 return [
958 ...(earlier > 0 ? [<Text dimColor>{`${pad} … ${plural(earlier, 'earlier action', 'earlier actions')}`}</Text>] : []),
959 ...shown.map(action =>
960 action.failed ? (
961 <Text>
962 {pad}
963 <Text color="error">✗ {short(action.title, room)}</Text>
964 <Text dimColor> {action.endAt ? since(action.at, action.endAt) : ''}</Text>
965 </Text>
966 ) : action.endAt ? (
967 <Text dimColor>
968 {pad}
969 <Text color="success">✓</Text> {short(action.title, room)} {since(action.at, action.endAt)}
970 </Text>
971 ) : (
972 <Text>
973 {pad}
974 <Text color="claude">{spin}</Text> {short(action.title, room)}
975 <Text dimColor> {since(action.at, at)}</Text>
976 </Text>
977 ),
978 ),
979 ]
980 }
981
982 // The opened step's actions.
983 const openedActions = (step: Step) => (opened === step.title ? actionLines(actionsOf(step.title), ' ') : [])
984
985 // A helper's row: what it is (⚇ a subagent, ▶ a background command), how it stands and
986 // for how long; a running one says what it does now. A press opens its actions; the
987 // helpers it started stand one level in, deeper ones counted.
988 const helperBlock = (helper: Helper, depth: number): RenderChildren[] => {
989 const pad = ` ${' '.repeat(depth)}`
990 const isRunning = helper.status === 'running'
991 const isOpen = openedHelper === helper.id
992 const started = team.filter(one => one.parent === helper.id)
993 const state =
994 helper.status === 'running' ? (
995 <Text color="claude">{spin}</Text>
996 ) : helper.status === 'done' ? (
997 <Text color="success">✓</Text>
998 ) : helper.status === 'failed' ? (
999 <Text color="error">✗</Text>
1000 ) : (
1001 <Text color="warning">■</Text>
1002 )
1003 const time = `${isRunning && !working ? 'still running · ' : ''}${since(helper.at, helper.endAt ?? at)}`
1004 const jobRoom = Math.max(10, width - pad.length - helper.name.length - time.length - 12)
1005
1006 return [
1007 <Button
1008 key={`helper:${helper.id}`}
1009 plain
1010 dimColor={!isRunning}
1011 onPress={() => update($, openHelper, current => (current === helper.id ? null : helper.id))}
1012 >
1013 {pad}
1014 <Text dimColor>{helper.kind === 'agent' ? '⚇' : '▶'}</Text>
1015 {' '}
1016 {state}
1017 {' '}
1018 <Text bold={isRunning}>{short(helper.name, 28)}</Text>
1019 {helper.job ? <Text dimColor>{` · ${short(helper.job, jobRoom)}`}</Text> : ''}
1020 <Text dimColor>{` · ${time}`}</Text>
1021 {depth >= 1 && started.length > 0 ? <Text dimColor>{` · ${plural(started.length, 'helper', 'helpers')}`}</Text> : ''}
1022 </Button>,
1023 ...(isOpen
1024 ? actionLines(everything.filter(action => action.helper === helper.id), `${pad} `)
1025 : isRunning && helper.now
1026 ? [<Text dimColor>{`${pad} └ ${short(helper.now, Math.max(10, width - pad.length - 6))}`}</Text>]
1027 : []),
1028 ...(depth < 1 ? started.flatMap(one => helperBlock(one, depth + 1)) : []),
1029 ]
1030 }
1031
1032 // A step's helpers: all of them on the step Claude is on (or one opened); elsewhere the
1033 // running ones, the rest folded into a count on the step's row.
1034 const isWhole = (step: Step) => (working && step.status === 'in_progress') || opened === step.title
1035 const helperRows = (step: Step) =>
1036 stepHelpers(step.title)
1037 .filter(helper => isWhole(step) || helper.status === 'running')
1038 .flatMap(helper => helperBlock(helper, 0))
1039 const foldedHelpers = (step: Step) => {
1040 if (isWhole(step)) return ''
1041 const folded = stepHelpers(step.title).filter(helper => helper.status !== 'running').length
1042 return folded > 0 ? <Text dimColor>{` · ${plural(folded, 'helper', 'helpers')}`}</Text> : ''
1043 }
1044 const looseHelpers = stepHelpers(undefined).flatMap(helper => helperBlock(helper, 0))
1045
1046 const openedRows = (() => {
1047 const stepRows = opened === null ? 0 : Math.min(actionsOf(opened).length, OPEN_ACTIONS + 1)
1048 const helperRowsNow = list.reduce((sum, step) => sum + helperRows(step).length, 0) + looseHelpers.length
1049 return stepRows + helperRowsNow
1050 })()
1051
1052 // The task's pictures as thumbnails, the newest that fit; the rest counted.
1053 const imageLine = (isLive: boolean) => {
1054 if (imageRows === 0 || !Picture) return []
1055 const room = Math.max(10, e.props.bodyColumns - 22)
1056 const shown: Thumb[] = []
1057 let used = 0
1058 for (const picture of [...pictures].reverse()) {
1059 if (shown.length > 0 && used + picture.columns + 1 > room) break
1060 shown.unshift(picture)
1061 used += picture.columns + 1
1062 }
1063 const earlier = pictures.length - shown.length
1064
1065 return [
1066 <Text> </Text>,
1067 <Box flexDirection="row">
1068 <Text>
1069 {' '}
1070 <Text color="claude">▣</Text> <Text bold={isLive} dimColor={!isLive}>Images</Text>
1071 {' '}
1072 </Text>
1073 {shown.flatMap((picture, index) => [
1074 ...(index > 0 ? [<Text> </Text>] : []),
1075 <Picture
1076 key={picture.id}
1077 source={picture.file ? { file: picture.file, format: 'png' } : { png: picture.png ?? '' }}
1078 columns={picture.columns}
1079 rows={THUMB_ROWS}
1080 alt={`[${short(picture.name, Math.max(3, picture.columns - 2))}]`}
1081 />,
1082 ])}
1083 {earlier > 0 ? <Text dimColor> +{earlier} earlier</Text> : ''}
1084 </Box>,
1085 ]
1086 }
1087
1088 // While working the newest skill twinkles; once done the line is still.
1089 const skillLine = (label: string, isLive: boolean) => {
1090 if (used.length === 0) return []
1091 const { shown, more } = fitSkills(used, Math.max(20, e.props.bodyColumns - label.length - 6))
1092 const newest = used[used.length - 1]
1093 const mark = isLive ? TWINKLE[Math.floor(at / TICK / 2) % TWINKLE.length] : '✦'
1094
1095 return [
1096 <Text> </Text>,
1097 <Text>
1098 {' '}
1099 <Text color="claude">{mark}</Text> <Text bold={isLive} dimColor={!isLive}>{label}</Text>
1100 {' '}
1101 {shown.flatMap((name, index) => [
1102 ...(index > 0 ? [<Text dimColor> · </Text>] : []),
1103 isLive && name === newest ? <Text color="claude">{name}</Text> : <Text dimColor>{name}</Text>,
1104 ])}
1105 {more > 0 ? <Text dimColor> +{more} more</Text> : ''}
1106 </Text>,
1107 ]
1108 }
1109
1110 if (!working && ended !== null) {
1111 const room = Math.max(2, e.props.maxRows - 1 - skillRows - imageRows - openedRows)
1112 const rows: Step[] =
1113 list.length > 0
1114 ? list
1115 : recent.map(action => ({ id: `did:${action.id}`, title: action.title, status: 'completed' }))
1116 const done = rows.filter(step => step.status === 'completed').length
1117 const hidden = Math.max(0, rows.length - room)
1118 const shown = rows.slice(hidden)
1119 const failedTries = (step: Step) => {
1120 const count = list.length > 0 ? failuresOf(step.title).length : 0
1121 return count > 0 ? <Text color="error"> · {plural(count, 'failed try', 'failed tries')}</Text> : ''
1122 }
1123
1124 return (
1125 <Box flexDirection="column">
1126 <Text>
1127 <Text color={ended.isAborted ? 'warning' : 'success'}>{ended.isAborted ? '■' : '✓'} </Text>
1128 <Text bold>{ended.isAborted ? 'Stopped' : 'Done'}</Text>
1129 <Text dimColor>
1130 {list.length > 0 ? ` · ${done} of ${rows.length} done` : ''} · {since(await read($, turnSince), ended.at)}
1131 </Text>
1132 </Text>
1133 {hidden > 0 && <Text dimColor> … {hidden} more above</Text>}
1134 {shown.flatMap(step => [
1135 step.status === 'completed'
1136 ? stepButton(step, [<Text color="success">✓</Text>, ` ${short(step.title, width)}`, failedTries(step), foldedHelpers(step)], true)
1137 : step.status === 'in_progress'
1138 ? stepButton(step, [<Text color="warning">■</Text>, ` ${short(step.title, width)}`, failedTries(step), foldedHelpers(step)], true)
1139 : stepButton(step, [`○ ${short(step.title, width)}`, foldedHelpers(step)], true),
1140 ...openedActions(step),
1141 ...(list.length > 0 ? helperRows(step) : []),
1142 ])}
1143 {looseHelpers}
1144 {skillLine('Used', false)}
1145 {imageLine(false)}
1146 </Box>
1147 )
1148 }
1149
1150 const flashes = await read($, doneAt)
1151 const turnStart = await read($, turnSince)
1152 const stepStart = await read($, stepSince)
1153 const room = Math.max(3, e.props.maxRows - 1 - skillRows - imageRows - openedRows)
1154 const now = recent[recent.length - 1]
1155 const nowLine = now ? (
1156 <Text dimColor>
1157 {' └ '}
1158 {short(now.title, width - 4)}
1159 </Text>
1160 ) : null
1161
1162 // No plan yet: the actions taken so far are the list, a failed one marked.
1163 if (list.length === 0) {
1164 const done = recent.slice(Math.max(0, recent.length - room + 1), -1)
1165 const bar = sweep(frame)
1166
1167 return (
1168 <Box flexDirection="column">
1169 <Text>
1170 <Text color="claude">{spin} </Text>
1171 <Text bold>Working</Text>
1172 <Text dimColor>{' '}{'─'.repeat(bar.before)}</Text>
1173 <Text color="claude">{'━'.repeat(bar.run)}</Text>
1174 <Text dimColor>{'─'.repeat(bar.after)} {since(turnStart, at)}</Text>
1175 </Text>
1176 {done.map(action =>
1177 action.failed ? (
1178 <Text>
1179 {' '}
1180 <Text color="error">✗ {short(action.title, width / 2)}</Text>
1181 <Text dimColor> — {short(action.failed, reasonRoom(action.title))}</Text>
1182 </Text>
1183 ) : (
1184 <Text dimColor>
1185 {' '}
1186 <Text color="success">✓</Text> {short(action.title, width)}
1187 </Text>
1188 ),
1189 )}
1190 {now ? (
1191 <Text>
1192 {' '}
1193 <Text color="claude">{spin}</Text> <Text bold>{short(now.title, width - 8)}</Text>
1194 <Text dimColor> · {since(now.at, at)}</Text>
1195 </Text>
1196 ) : (
1197 <Text dimColor>
1198 {' '}
1199 <Text color="claude">{spin}</Text> Making a plan{DOTS[Math.floor(frame / 3) % DOTS.length]}
1200 </Text>types/index.d.ts 79 lines1export type StepStatus = 'pending' | 'in_progress' | 'completed'
2export type Step = { id: string; title: string; status: StepStatus }
3// One thing Claude did: its tool call, the step it was on (by title), or the helper
4// that did it (by id), when, and how it went.
5export type Activity = {
6 id: string
7 title: string
8 at: number
9 step?: string
10 helper?: string
11 endAt?: number
12 failed?: string
13}
14export type Finish = { at: number; isAborted: boolean }
15// A thumbnail: a small PNG file the Mac made, or, where it could not, the PNG's own bytes.
16export type Thumb = { id: string; name: string; columns: number; file?: string; png?: string }
17// A helper: a subagent, or a shell command left running in the background. It belongs
18// to the task (turn) and the step it started in, or to the helper that started it.
19export type HelperStatus = 'running' | 'done' | 'failed' | 'stopped'
20export type Helper = {
21 id: string
22 kind: 'agent' | 'shell'
23 name: string
24 job: string
25 turn: string
26 step?: string
27 parent?: string
28 at: number
29 endAt?: number
30 status: HelperStatus
31 now?: string
32}
33// One finished task, as the history panel lists it.
34export type HistoryHelper = { name: string; job: string; kind: 'agent' | 'shell'; seconds: number; status: HelperStatus }
35export type HistoryStep = { title: string; status: StepStatus; failures: number; helpers?: HistoryHelper[] }
36export type HistoryEntry = {
37 id: string
38 at: number
39 seconds: number
40 title: string
41 isAborted: boolean
42 steps: HistoryStep[]
43 skills: string[]
44 images: number
45 summary: string
46}
47
48declare module 'claude-code' {
49 interface PluginState {
50 'bottom-line': {
51 isOn: boolean
52 isWorking: boolean
53 steps: Step[]
54 activity: Activity[]
55 clock: number
56 turnSince: number
57 stepSince: number
58 turnTitle: string
59 turnNow: string
60 doneAt: Record<string, number>
61 finish: Finish | null
62 openStep: string | null
63 openHelper: string | null
64 openHistory: string | null
65 skills: string[]
66 pendingSkills: string[]
67 images: Thumb[]
68 pendingImages: Thumb[]
69 helpers: Helper[]
70 turnTexts: string[]
71 tailTexts: string[]
72 turnTools: string[]
73 summaries: string[]
74 hiddenTexts: string[]
75 hiddenTools: string[]
76 }
77 }
78}
79