SLOPSHOPPER

bottom-line

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…

newpanebandrowsguardcommand
v0.6.0MITupdated 2026-10-26SamiAbi/claude-mods/bottom-line
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · bottom-line
│ ┃ bottom-line-settings ✕ › fix the failing auth test and add an audit log call │ ┃ Claude Code does not list this mod's │ ┃ switches here. ⏺ Read(src/auth.ts) │ ┃ Set them in ~/.claude/settings.json: ⎿ Read 6 lines │ ┃ "pluginConfigs": { "bottom-line@inline": { ⏺ Update(src/auth.ts) │ ┃ "options": { "skillsLine": false } } } ⎿ Added 2 lines, removed 1 line │ ┃ The switches: hideText, skillsLine, ⏺ Bash(bun test) │ ┃ imagesRow, helpers, keepFinished (all on by ⎿ 3 pass, 1 fail │ ┃ default). │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /bottom-line │ ⎿ bottom-line: Bottom line is off. Claude’s work shows as usual. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · bottom-line-settings
Claude Code does not list this mod's switches here. Set them in ~/.claude/settings.json: "pluginConfigs": { "bottom-line@inline": { "options": { "skillsLine": false } } } The switches: hideText, skillsLine, imagesRow, helpers, keepFinished (all on by default).
Pane · bottom-line-history
No tasks yet. Each task Claude finishes is kept here.
README

claude-mods

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.

The mods

bottom-line

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 │
            └────────┘ └────┘
  • A spinner, a progress bar that fills as steps finish, and a timer for the whole task and for the current step.
  • The line under the current step says exactly what Claude is doing right now.
  • The next step is marked → Next:.
  • Once Claude uses a skill, a skills line appears at the bottom: every skill used in the task, each once, the newest lit and twinkling. Skills you start with /name count too; skills used by Claude's helpers (subagents) do not.
  • Once a picture is in play, an images row shows thumbnails: pictures you paste or drag into your prompt, pictures Claude opens, and screenshots it takes. The newest that fit are shown, the rest counted.
  • When Claude finishes, its whole final message stays in the conversation — everything it wrote after its last real action, even when a plan update falls in the middle of it. Links Claude hands you (an artifact's page, a file it sends) stay on screen too. The rest of its text is hidden, and the finished list stays above the prompt until your next prompt, ending with ✦ 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:

SwitchOff means
Hide Claude's text while it worksClaude's messages and tool steps show as usual; the list still shows
Skills lineno ✦ Skills line
Images rowno ▣ Images row, and no thumbnails are made
Helpersno helper or background-command rows
Keep the finished listthe 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.

Pictures in the images row

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.

  • macOS: any picture kind (PNG, JPG, HEIC, GIF, …) becomes a small thumbnail, made by the Mac's own sips and kept a day in ~/Library/Caches/bottom-line.
  • Linux and Windows: PNG pictures (most screenshots) show as they are, up to 1 MB; other kinds are left out of the row.

Install

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.

License

MIT. See LICENSE.

Source 2 files
hooks/register.tsx 1434 lines
1import { 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 lines
1export 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