SLOPSHOPPER

Claudinator

Give Claude Code a glow-up: six switchable looks and nine mixable ingredients for tool calls, the working line and turn receipts, plus panes to navigate long…

newpanebandspinnerrowsguard
v1.1.1MITupdated 2026-10-05patheonsceo/claudinator
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · claudinator
│ ┃ Claudinator ✕ ❯ fix the failing au╭────────────────────────────────────────────╮ │ ┃ ◆ Claudinator │ claudinator │ │ ┃ Hairline · 3 ingredients on ⏺ Read(src/auth.ts) │ Try /look hairline, /look zen, /look share │ │ ┃ ⎿ Read 6 lines │ or /look use HL-43H. /claudinator opens │ │ ┃ LOOK ────────────────────────────────────── │ the picker. │ │ ┃ ──────────── ◆ Edit src/auth.ts ╰────────────────────────────────────────────╯ │ ┃ › 1: Hairline ■■■ Swiss-quiet ⏺ Bash(bun test) ╭────────────────────────────────────────────╮ │ ┃ 2: Broadsheet ■■■ Reads like a stor ⎿ 3 pass, 1 fail │ claudinator │ │ ┃ 3: Mission Control ■■■ A precision instr │ Pinned: I made refresh reject expired │ │ ┃ 4: Prism ■■■ Every file its co ● Done. refresh now re│ claims, added an audit call,… │ │ ┃ 5: Sumi ■■■ Ink and space ╰────────────────────────────────────────────╯ │ ┃ 6: Blueprint ■■■ A technical drawi │ ┃ 7: Off □□□ Claude Code's own ── ◆ Turn 1 · 42s · 3 files · +16 −1 · ────────── 49% ctx ─────────── │ ┃ ─────────── │ ┃ INGREDIENTS ─────────────────────────────── ─────────── │ ┃ ──────────── ─────────── │ ┃ ● r: Recency fade ● d: Mini diffs ─────────── │ ┃ ─────────── │ ┃ COMBOS ──────────────────────────────────── │ ┃ ──────────── │ ┃ w: Daily driver s: Storyteller o: Show- › /claudinator │ ┃ │ ┃ SHARE ───────────────────────────────────── │ ┃ ──────────── │ ┃ Your code HL-43H │ ┃ Others type /look use HL-43H ••• Thinking –––––––––––––––– 0:00 ■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■ thinking 0.9s · tools 0.2s ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Claudinator
◆ Claudinator Hairline · 3 ingredients on LOOK ────────────────────────────────────────────────────── › 1: Hairline ■■■ Swiss-quiet 2: Broadsheet ■■■ Reads like a story 3: Mission Control ■■■ A precision instrument 4: Prism ■■■ Every file its color 5: Sumi ■■■ Ink and space 6: Blueprint ■■■ A technical drawing 7: Off □□□ Claude Code's own INGREDIENTS ─────────────────────────────────────────────── ● r: Recency fade ● d: Mini diffs ○ f: COMBOS ──────────────────────────────────────────────────── w: Daily driver s: Storyteller o: Show-off x: Doof mo SHARE ───────────────────────────────────────────────────── Your code HL-43H Others type /look use HL-43H Every row in this session follows your choice. Esc closes.
Pane · Claudinator
c: Chapters l: Ledger p: Pins ──────────────────────────━━━━━━━───────────────────────── ─────────────────────────────── ◇ I made refresh reject expired claims, added an audit call,… pinned at turn 1 remove /pin a note to add one
Spinner
••• Thinking –––––––––––––––– 0:00 ■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■ thinking 0.9s · tools 0.2s
Turn line
── ◆ Turn 1 · 42s · 3 files · +16 −1 · ────────── 49% ctx ────────────────────────────────────────── ────────────────────────────────────────── ────────────────────────────────────────── ──────────────────────────────────────────
Your message
❯ fix the failing auth test and add an audit log call
Tool row
◆ Edit src/auth.ts
README

Claudinator

Give Claude Code a glow-up. Claudinator restyles the parts of Claude Code you look at all day: tool calls, the thinking line, the line that closes each turn, and the prompt rows. Pick one of six looks, mix in ingredients, and every change applies live to the session in front of you. Panes let you jump around long sessions, and a share code carries your exact setup to a friend.

One session switching through all six looks

Install

claude plugin marketplace add patheonsceo/claudinator
claude plugin install claudinator@claudinator

Then start Claude Code. Hairline is on by default. Type /claudinator to open the picker.

Requires Claude Code v2.1.287 or later. Fullscreen mode (CLAUDE_CODE_NO_FLICKER=1) gives the full experience: switching looks repaints the whole session. In the classic layout, new rows follow your choice and rows in your terminal history keep theirs.

Try it

  • Watch a turn. Ask Claude to fix something and run the tests. Each step is one considered row, edits show their changed lines, and the turn closes with a receipt.
  • Switch looks live. Type /claudinator and press 1 to 6. Your whole session redraws in each look.
  • Read a long session like a story. Type /look storyteller: Broadsheet with headlines and footnotes, so Claude's prose reads cleanly and the tool calls sit in numbered notes.
  • Find your way back. Type /chapters to see every turn by its headline, and press Enter on one to jump to it.
  • Share your setup. Type /look share and send the code. Your friend types /look use and the code.

Looks

LookWhat it is
1HairlineSwiss-quiet: an icon column, aligned metadata, color only where something changed.
2BroadsheetThe session reads like a story: sentences for tool calls, chapter headlines, a typewriter while Claude thinks.
3Mission ControlA precision instrument: a timestamped rail, live telemetry above the prompt, readings on every turn.
4PrismPremium color: every file its own color, tool chips, and a fingerprint ribbon closing each turn.
5SumiInk and negative space: reads become a quiet trace, only edits get a mark.
6BlueprintEvery turn a technical drawing, with callouts and a title block. The -inator mode skin.

Each look also ships a matching Claude Code theme and terminal color schemes for Ghostty, kitty, WezTerm, Alacritty, Windows Terminal and iTerm2. See docs/looks.md for a GIF of each and docs/themes.md to install the themes.

Ingredients

Ingredients work in any look. Each look turns on its own favorites, and your choices always win.

KeyIngredientWhat it does
rRecency fadeOlder turns dim so the newest line stands out.
dMini diffsEdits show their key changed lines under the row.
fFile colorsEach file keeps one color everywhere.
qQuietHides reads, searches and commands, leaving a one-line trace.
hHeadlinesEach finished turn gets a title above its prompt.
nFootnotesTool calls become numbered notes on Claude's prose.
tTime stripEach run's time under its receipt: thinking, tools and waiting on you. (The working bar above the prompt is always on.)
aAttention ladderWhen Claude waits on your permission: a toast at 30 seconds, then an optional sound and desktop notification at 2 minutes.
i-inator modeA layer of personality. Curse you, flaky tests.

See docs/ingredients.md for a GIF of each.

Panes and commands

CommandWhat it does
/claudinatorOpen the picker: number keys pick a look, letter keys toggle ingredients and combos.
/look <name>Switch to a look (/look sumi) or a combo (/look zen). Works while Claude is busy.
/look share, /look use <code>Print your setup as a code, or apply someone else's.
/chaptersEvery turn by its headline; press Enter to jump to it.
/ledgerEvery file changed this session, with its line counts.
/pins, /pin [note]Notes worth keeping in view. /pin alone pins the last turn.

Combos apply a whole setup at once: Daily driver (w), Storyteller (s), Show-off (o), Doof mode (x) and Zen (z). See docs/panes.md and docs/sharing.md.

Privacy

Claudinator runs entirely on your machine. It makes no network requests, calls no model, and never reads Claude's history files. It reads the tool calls and prompts it draws, keeps only your choices and pins on disk, and runs a program only if you turn on the attention ladder's sound or desktop notification. A project can never use it to hide tool calls from you. The full list is in docs/privacy.md.

What it reads, runs and hooks

  • Reads: the conversation as Claude Code draws it (your prompts, Claude's replies, each tool call and its result) to restyle those rows; the theme row of /config, to choose light or dark colors; and .claude/claudinator.json in your project, if it exists. It never reads your settings file or any credential.
  • Sends: nothing leaves your machine. The only data that leaves Claudinator is the text of a desktop notification, handed to your own system's notifier, and only if you turn notifications on.
  • Runs: nothing by default. With the attention ladder's sound on, after Claude has waited two minutes on your permission it plays a system sound with the first of pw-play, paplay or afplay that is installed. With its notification on, it shows "Claude needs you" and the waiting call (for example Run npm test) with notify-send or osascript. Each runs with fixed arguments, no shell, and the text passed as plain arguments.
  • Hooks:
  • ui.render restyles rows, the working line, receipts, prompts and panes. It changes only how they look.
  • session.start and command.run register and answer /claudinator, /look, /chapters, /ledger, /pins and /pin.
  • prompt.submit, turn.start, turn.complete and tool.call observe your prompts, turns and tool calls to build headlines, receipts and Chapters. They pass every event on unchanged.
  • PostToolUse and PostToolUseFailure read how long each tool ran.
  • PermissionRequest notes when a permission dialog opens, so the attention ladder can start; it never answers it, and the decision is always yours.
  • SessionStart resets its in-memory view of the session after /clear, a resume or a fork.

Not affiliated with Anthropic

Claudinator is an independent, community-built plugin. It is not made, endorsed or supported by Anthropic. "Claude" and "Claude Code" are trademarks of Anthropic, PBC.

Contributing

Ideas, looks and bug reports are welcome. See CONTRIBUTING.md, docs/adding-a-look.md and docs/testing.md. For security issues, see SECURITY.md.

License

MIT

Source 48 files
hooks/register.ts 558 lines
1// SPDX-License-Identifier: MIT
2// Claudinator's only hooks module. Claude Code reads on(...) and $.noun.method(...)
3// from source, so every call is spelled literally, and helpers that take $ are
4// top-level functions in this file. Everything else lives in ../src as pure code.
5import { atom, read, update } from 'claude-code'
6import type { ElementTable, EngineInterface, Register, RenderElement, RenderSurface } from 'claude-code'
7
8import { isDarkTheme, ladderActions, notifyCommands, soundCommands } from '../src/engine/attention'
9import { comboById } from '../src/engine/combos'
10import { printable } from '../src/engine/format'
11import { fadeOf } from '../src/engine/palette'
12import * as Model from '../src/engine/session-model'
13import { encodeCells } from '../src/engine/raster'
14import { DEFAULT_SETTINGS, LOOK_LABELS, changedIngredient, changedLook, isLookId, layerOf, optionsLayer, projectLayerOf, resolveSettings } from '../src/engine/settings'
15import type { Settings, SettingsLayer } from '../src/engine/settings'
16import { decodeShareCode, encodeShareCode } from '../src/engine/share-code'
17import { factsOf, isFootnotable, isQuietable } from '../src/engine/tool-facts'
18import { LOOKS } from '../src/looks'
19import { stripColors, waitingBand, withMarks, workingBarCells, workingBarText } from '../src/looks/common'
20import type { StripColors, WorkData } from '../src/looks/common'
21import { liveStateOf } from '../src/looks/hairline/live'
22import type { Ctx, LiveState, Look, ReceiptData, ToolRow, UsageData } from '../src/looks/look'
23import { navigatorView } from '../src/panes/navigator'
24import type { NavigatorTab, Pin } from '../src/panes/navigator'
25import { pickerView } from '../src/panes/picker'
26
27const settingsAtom = atom({ plugin: 'claudinator', key: 'settings' } as const, DEFAULT_SETTINGS)
28
29let model = Model.createModel()
30let options: SettingsLayer = {}
31let project: SettingsLayer = {}
32let saved: SettingsLayer = {}
33let cwd = ''
34let frame = 0
35let isFullscreen = true
36let isDark = true
37let live: { requestId: string; state: LiveState; barWidth: number } | null = null
38let usage: UsageData | null = null
39let pins: Pin[] = []
40let navTab: NavigatorTab = 'chapters'
41let ladder = { toasted: false, alerted: false }
42let lastBandSecond = -1
43/** How long each call's tool itself ran, as Claude Code reports it after the call. */
44const execMs = new Map<string, number>()
45
46function ctxOf(e: { surface: RenderSurface; viewport?: { columns: number } }, els: ElementTable, settings: Settings, fade: 0 | 1 | 2): Ctx {
47  return { els, surface: e.surface, columns: e.viewport?.columns ?? 120, settings, fade, cwd, isDark }
48}
49
50function rowOf(id: string, p: { tool: string; input: unknown; isRunning: boolean; isErrored: boolean; isInterrupted: boolean }): ToolRow {
51  const row: ToolRow = { id, tool: p.tool, input: p.input, isRunning: p.isRunning, isErrored: p.isErrored, isInterrupted: p.isInterrupted }
52  const durationMs = Model.durationOf(model, id)
53  if (durationMs !== undefined) row.durationMs = durationMs
54  const start = model.toolStart.get(id)
55  const turn = model.toolTurn.get(id)
56  const record = model.turns.find(t => t.turn === turn)
57  if (start !== undefined && record) row.turnOffsetMs = Math.max(0, start - record.startedAt)
58  const changeIndex = Model.changeIndexOf(model, id)
59  if (changeIndex !== undefined) row.changeIndex = changeIndex
60  return row
61}
62
63function isHiddenByQuiet(settings: Settings, row: { tool: string; isErrored: boolean }): boolean {
64  return settings.ingredients.quiet && isQuietable(row.tool) && !row.isErrored
65}
66
67/** A drawn row with a blank line above it (and below, for a receipt), so each step reads on its own. */
68function spaced(els: ElementTable, row: RenderElement, below = false): RenderElement {
69  return els.Box({ flexDirection: 'column', marginTop: 1, ...(below ? { marginBottom: 1 } : {}), children: [row] })
70}
71
72/** A footnoted row hides only in the terminal, where the receipt that carries its note is drawn. */
73function isFootnoted(settings: Settings, row: { id: string; isErrored: boolean; isInterrupted?: boolean }, surface: string): boolean {
74  return settings.ingredients.footnotes && surface === 'terminal' && !row.isErrored && !row.isInterrupted && Model.isNoteShown(model, row.id)
75}
76
77/** Defaults, then /config options, then the project's file, then what the user chose; later wins. */
78function currentSettings(): Settings {
79  return resolveSettings([options, project, saved])
80}
81
82/** The time line under each run needs room; below this width the receipt goes without. */
83const TIME_STRIP_MIN_COLUMNS = 60
84
85function receiptData(settings: Settings, durationMs: number, stats: Model.TurnStats | null, columns: number): ReceiptData {
86  const data: ReceiptData = { durationMs, stats, notes: [], timeStrip: null }
87  if (stats?.title) data.title = stats.title
88  if (settings.ingredients.footnotes && stats?.notes) data.notes = stats.notes
89  if (settings.ingredients.timeStrip && columns >= TIME_STRIP_MIN_COLUMNS && stats && stats.toolsMs !== undefined) {
90    const toolsMs = stats.toolsMs
91    const waitingMs = stats.waitingMs ?? 0
92    // Claude Code's duration leaves out permission waits, so thinking is measured against the turn's own time.
93    const wallMs = stats.wallMs ?? durationMs
94    data.timeStrip = { thinkingMs: Math.max(0, wallMs - toolsMs - waitingMs), toolsMs, waitingMs, ...(stats.tasks ? { tasks: stats.tasks } : {}) }
95  }
96  return data
97}
98
99async function readProjectLayer($: EngineInterface): Promise<SettingsLayer> {
100  try {
101    return projectLayerOf(JSON.parse(await $.fs.read('.claude/claudinator.json')))
102  } catch {
103    return {}
104  }
105}
106
107async function readTheme($: EngineInterface): Promise<void> {
108  try {
109    // Only the theme row of /config: Claudinator never reads the settings file itself.
110    const theme = (await $.config.list()).find(row => row.key === 'theme')?.value
111    isDark = isDarkTheme(typeof theme === 'string' ? theme : undefined)
112  } catch {
113    isDark = true
114  }
115}
116
117async function loadSettings($: EngineInterface): Promise<void> {
118  saved = layerOf(await $.store.get('settings'))
119  project = await readProjectLayer($)
120  const storedPins = await $.store.get('pins')
121  pins = Array.isArray(storedPins) ? storedPins.filter((p): p is Pin => typeof p === 'object' && p !== null && typeof (p as Pin).text === 'string') : []
122  await update($, settingsAtom, () => currentSettings())
123}
124
125/**
126 * Saves one change the user made. It starts from what the store holds now, so
127 * only the touched field changes: project settings and /config options never
128 * leak into the user's choice for other repositories.
129 */
130async function saveChange($: EngineInterface, change: (layer: SettingsLayer) => SettingsLayer): Promise<void> {
131  saved = change(layerOf(await $.store.get('settings')))
132  await $.store.set('settings', saved)
133  await update($, settingsAtom, () => currentSettings())
134}
135
136async function savePins($: EngineInterface, next: Pin[]): Promise<void> {
137  pins = next.slice(-50)
138  await $.store.set('pins', pins)
139  $.ui.invalidate('ui.render')
140}
141
142async function openNavigator($: EngineInterface, tab: NavigatorTab): Promise<void> {
143  navTab = tab
144  await $.ui.open({ id: 'claudinator-navigator', title: 'Claudinator', focus: true, closeOnEscape: true })
145  $.ui.invalidate('ui.render')
146}
147
148async function runFirst($: EngineInterface, candidates: string[][]): Promise<void> {
149  for (const argv of candidates) {
150    try {
151      const result = await $.process.run(argv, { timeoutMs: 5_000 })
152      if (result.exitCode === 0) return
153    } catch {
154      // Not installed here; try the next program.
155    }
156  }
157}
158
159async function climbLadder($: EngineInterface, settings: Settings): Promise<void> {
160  const waiting = Model.waitingOf(model, await $.clock.now())
161  if (!waiting) return
162  const second = Math.floor(waiting.waitedMs / 1000)
163  if (second !== lastBandSecond) {
164    lastBandSecond = second
165    $.ui.invalidate('ui.render')
166  }
167  const facts = factsOf(waiting.tool, waiting.input)
168  const what = `${facts.verb} ${facts.target}`.trim()
169  for (const action of ladderActions(waiting.waitedMs, settings, ladder)) {
170    if (action === 'toast') {
171      ladder = { ...ladder, toasted: true }
172      $.ui.toast(`Claude needs you: approve ${printable(what, 80)}`, { timeoutMs: 8_000 })
173    } else {
174      ladder = { ...ladder, alerted: true }
175      if (action === 'sound') await runFirst($, soundCommands())
176      else await runFirst($, notifyCommands('Claude needs you', `Approve ${what}`))
177    }
178  }
179}
180
181async function tick($: EngineInterface): Promise<void> {
182  frame += 1
183  const settings = await read($, settingsAtom)
184  if (model.waiting && frame % 5 === 0) await climbLadder($, settings)
185  const target = live
186  if (!target || !model.isWorking) return
187  const look = LOOKS[settings.look]
188  if (!look) return
189  const state = { ...target.state, elapsedMs: (await $.clock.now()) - model.turnStartedAt }
190  const work = { key: 'cz-work', columns: target.barWidth, cells: encodeCells(workingBarCells(workOf(await $.clock.now()), barColors(settings), isDark, target.barWidth, frame)) }
191  for (const f of [...look.liveFrames(state, frame), work]) {
192    const result = await $.ui.blit({ requestId: target.requestId, key: f.key, columns: f.columns, rows: 1, cells: f.cells })
193    if (result && typeof result === 'object' && 'deny' in result) {
194      live = null
195      return
196    }
197  }
198}
199
200/** What the working bar shows now: the turn's time so far and Claude's task list, when it keeps one. */
201function workOf(now: number): WorkData {
202  return { ...Model.liveTimes(model, now), progress: Model.progressOf(model) }
203}
204
205function barColors(settings: Settings): StripColors {
206  return stripColors(settings.look === 'off' ? 'hairline' : settings.look, isDark)
207}
208
209async function refreshUsage($: EngineInterface): Promise<number | undefined> {
210  try {
211    const u = await $.session.usage()
212    usage = {
213      contextPercent: u.context.percent,
214      rateLimits: u.rateLimits.map(r => ({ kind: r.kind, percentUsed: r.percentUsed, ...(typeof r.resetsAt === 'number' ? { resetsAt: r.resetsAt } : {}) })),
215      ...(u.cost && typeof u.cost.usd === 'number' ? { costUsd: u.cost.usd } : {}),
216    }
217    return u.context.percent
218  } catch {
219    return undefined
220  }
221}
222
223async function runLook($: EngineInterface, args: string): Promise<void> {
224  const arg = args.trim()
225  const lower = arg.toLowerCase()
226  if (lower === '') {
227    $.ui.toast('Try /look hairline, /look zen, /look share or /look use HL-43H. /claudinator opens the picker.')
228    return
229  }
230  if (lower === 'share') {
231    const code = encodeShareCode(await read($, settingsAtom))
232    $.ui.toast(`Share this setup: ${code}. Others type /look use ${code}.`, { timeoutMs: 10_000 })
233    return
234  }
235  if (lower.startsWith('use ')) {
236    const decoded = decodeShareCode(arg.slice(4))
237    if (!decoded.ok) {
238      $.ui.toast(decoded.reason)
239      return
240    }
241    await saveChange($, () => decoded.layer)
242    $.ui.toast(`Applied ${arg.slice(4).trim().toUpperCase()}`)
243    return
244  }
245  const combo = comboById(lower)
246  if (combo) {
247    await saveChange($, () => combo.layer)
248    $.ui.toast(`Combo: ${combo.label}`)
249    return
250  }
251  if (isLookId(lower)) {
252    await saveChange($, layer => changedLook(layer, lower))
253    $.ui.toast(`Look: ${LOOK_LABELS[lower]}`)
254    return
255  }
256  $.ui.toast(`No look, combo or code called "${printable(arg, 40)}". Try /look hairline or /claudinator.`)
257}
258
259export const register: Register = (on, opts) => {
260  options = optionsLayer(opts)
261
262  on('session.start', async ($, e, next) => {
263    model = Model.createModel()
264    cwd = e.cwd ?? ''
265    await loadSettings($)
266    await readTheme($)
267    $.clock.every(100, () => {
268      void tick($)
269    })
270    await $.command.register({ name: 'claudinator', description: 'Pick a Claudinator look, ingredients or combo', immediate: true })
271    await $.command.register({ name: 'look', description: 'Switch look or combo, or share your setup', argumentHint: '<look|combo|share|use CODE>', immediate: true })
272    await $.command.register({ name: 'chapters', description: 'Open Claudinator Chapters: every turn, jump to any', immediate: true })
273    await $.command.register({ name: 'ledger', description: 'Open Claudinator Ledger: every file changed this session', immediate: true })
274    await $.command.register({ name: 'pins', description: 'Open Claudinator Pins', immediate: true })
275    await $.command.register({ name: 'pin', description: 'Pin a note (or the last turn) to Claudinator Pins', argumentHint: '[note]', immediate: true })
276    return next(e)
277  })
278
279  on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
280    model = Model.createModel()
281    await loadSettings($)
282    return next(e)
283  })
284
285  on('prompt.submit', async ($, e, next) => {
286    if (e.turnId === undefined) Model.promptSubmitted(model, await $.clock.now(), e.text)
287    else Model.promptQueued(model, e.text)
288    return next(e)
289  })
290
291  // turn.start fires for the main conversation's turns only; subagents do not raise it.
292  on('turn.start', async ($, e, next) => {
293    Model.startTurn(model, await $.clock.now())
294    ladder = { toasted: false, alerted: false }
295    $.ui.invalidate('ui.render')
296    return next(e)
297  })
298
299  on('turn.complete', async ($, e, next) => {
300    const result = await next(e)
301    if (!e.agentId) {
302      const now = await $.clock.now()
303      const percent = await refreshUsage($)
304      Model.completeTurn(model, now, percent, e.answer)
305      live = null
306      $.ui.invalidate('ui.render')
307    }
308    return result
309  })
310
311  // Observers only: how long each tool itself ran, for the time strip.
312  on('classic.PostToolUse', async ($, e, next) => {
313    if (typeof e.duration_ms === 'number') execMs.set(e.tool_use_id, e.duration_ms)
314    return next(e)
315  })
316
317  on('classic.PostToolUseFailure', async ($, e, next) => {
318    if (typeof e.duration_ms === 'number') execMs.set(e.tool_use_id, e.duration_ms)
319    return next(e)
320  })
321
322  // Observer only, the decision stays the user's: while Claude Code shows a permission dialog,
323  // the call it asks about waits on the user. That starts the attention ladder and keeps the
324  // wait out of the call's duration.
325  on('classic.PermissionRequest', async ($, e, next) => {
326    if (Model.permissionShown(model, e.tool_name, await $.clock.now())) {
327      ladder = { toasted: false, alerted: false }
328      $.ui.invalidate('ui.render')
329    }
330    return next(e)
331  })
332
333  on('tool.call', async ($, e, next) => {
334    const settings = await read($, settingsAtom)
335    const isSubagent = e.agentId !== undefined
336    const footnoted = settings.ingredients.footnotes && isFootnotable(e.tool) && !isSubagent
337    Model.toolStarted(model, e.tool_use_id, e.tool, e, await $.clock.now(), footnoted, isSubagent)
338    $.ui.invalidate('ui.render')
339    let isError = true
340    try {
341      const result = await next(e)
342      isError = Boolean(result && typeof result === 'object' && ('deny' in result || ('isError' in result && result.isError)))
343      if (!isError && !isSubagent && 'result' in result) Model.taskToolDone(model, e.tool, e, result.result)
344      return result
345    } finally {
346      Model.waitingEnded(model, e.tool_use_id)
347      Model.toolFinished(model, e.tool_use_id, e.tool, e, await $.clock.now(), isError, execMs.get(e.tool_use_id))
348      execMs.delete(e.tool_use_id)
349      $.ui.invalidate('ui.render')
350    }
351  })
352
353  on('command.run', { command: 'claudinator' }, async $ => {
354    await readTheme($)
355    await $.ui.open({ id: 'claudinator', title: 'Claudinator', focus: true, closeOnEscape: true })
356    return {}
357  })
358
359  on('command.run', { command: 'look' }, async ($, e) => {
360    await runLook($, e.args)
361    return {}
362  })
363
364  on('command.run', { command: 'chapters' }, async $ => {
365    await openNavigator($, 'chapters')
366    return {}
367  })
368
369  on('command.run', { command: 'ledger' }, async $ => {
370    await openNavigator($, 'ledger')
371    return {}
372  })
373
374  on('command.run', { command: 'pins' }, async $ => {
375    await openNavigator($, 'pins')
376    return {}
377  })
378
379  on('command.run', { command: 'pin' }, async ($, e) => {
380    const last = model.turns[model.turns.length - 1]
381    const text = printable(e.args.trim(), 300) || last?.title || ''
382    if (text === '') {
383      $.ui.toast('Nothing to pin yet. Type /pin and a note.')
384      return {}
385    }
386    const pin: Pin = e.args.trim() === '' && last ? { text, turn: last.turn } : { text }
387    await savePins($, [...pins, pin])
388    $.ui.toast(`Pinned: ${printable(text, 60)}`)
389    return {}
390  })
391
392  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
393    if (e.viewport && typeof e.viewport.isFullscreen === 'boolean') isFullscreen = e.viewport.isFullscreen
394    return next(e)
395  })
396
397  on('ui.render', { component: 'Pane', requestId: 'claudinator' }, async ($, e) => {
398    const settings = await read($, settingsAtom)
399    return pickerView(
400      $.ui.resolve(e),
401      settings,
402      { isFullscreen, hasProjectFile: Object.keys(project).some(k => k !== 'fromProject'), shareCode: encodeShareCode(settings), columns: e.props.bodyColumns, isDark },
403      {
404        setLook: id => {
405          void saveChange($, layer => changedLook(layer, id))
406        },
407        toggle: id => {
408          void saveChange($, layer => changedIngredient(layer, id, !settings.ingredients[id]))
409        },
410        applyCombo: id => {
411          const combo = comboById(id)
412          if (combo) void saveChange($, () => combo.layer)
413        },
414      },
415    )
416  })
417
418  on('ui.render', { component: 'Pane', requestId: 'claudinator-navigator' }, async ($, e) => {
419    const settings = await read($, settingsAtom)
420    const style = (LOOKS[settings.look] ?? LOOKS.hairline)?.paneStyle ?? { accent: 'suggestion', marker: '·', current: '›' }
421    return navigatorView(
422      $.ui.resolve(e),
423      style,
424      { tab: navTab, cwd, turns: model.turns, ledger: Model.ledgerOf(model), pins, columns: e.props.bodyColumns },
425      {
426        setTab: tab => {
427          navTab = tab
428          $.ui.invalidate('ui.render')
429        },
430        // A jump can be refused (classic layout, row not mounted); that is fine, so failures are dropped.
431        jumpToTurn: turn => {
432          const rowId = model.turns.find(t => t.turn === turn)?.userRowId
433          if (rowId) $.ui.scroll({ to: { requestId: rowId }, block: 'start' }).catch(() => undefined)
434        },
435        jumpToTool: toolId => {
436          $.ui.scroll({ to: { requestId: toolId }, block: 'center' }).catch(() => undefined)
437        },
438        unpin: index => {
439          void savePins($, pins.filter((_, i) => i !== index))
440        },
441      },
442    )
443  })
444
445  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
446    if (e.component !== 'AbovePrompt') return next(e)
447    const settings = await read($, settingsAtom)
448    const look = LOOKS[settings.look]
449    if (!look || e.props.hasSurvey) return next(e)
450    const waiting = settings.ingredients.attention ? Model.waitingOf(model, await $.clock.now()) : null
451    // The band is as wide as the body beside any docked pane, not the whole terminal.
452    const ctx = ctxOf({ surface: e.surface, viewport: { columns: e.props.bodyColumns } }, $.ui.resolve(e), settings, 0)
453    const drawn = look.band({ usage, waiting, isWorking: model.isWorking }, ctx) ?? (waiting ? waitingBand(ctx, waiting) : null)
454    if (!drawn) return next(e)
455    const others = await next(e)
456    return ctx.els.Box({ flexDirection: 'column', children: [drawn, others] as RenderElement[] })
457  }).catch(async ($, e, next) => next(e))
458
459  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
460    if (e.component !== 'AssistantMessage') return next(e)
461    Model.assistantSeen(model, e.requestId, await $.clock.now())
462    const settings = await read($, settingsAtom)
463    const marks = Model.marksOf(model, e.requestId)
464    if (!LOOKS[settings.look] || !settings.ingredients.footnotes || marks.length === 0) return next(e)
465    return next({ ...e, props: { ...e.props, text: withMarks(e.props.text, marks) } })
466  }).catch(async ($, e, next) => next(e))
467
468  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
469    if (e.component !== 'ToolUse') return next(e)
470    const settings = await read($, settingsAtom)
471    const look = LOOKS[settings.look]
472    if (!look) return next(e)
473    const row = rowOf(e.props.tool_use_id, e.props)
474    const fade = fadeOf(model.toolTurn.get(row.id), model.turn, settings.ingredients.recency)
475    const ctx = ctxOf(e, $.ui.resolve(e), settings, fade)
476    if (isFootnoted(settings, row, e.surface)) return ctx.els.Box({})
477    if (isHiddenByQuiet(settings, row)) return spaced(ctx.els, look.quietLine(1, ctx))
478    return spaced(ctx.els, look.toolRow(row, ctx))
479  }).catch(async ($, e, next) => next(e))
480
481  on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
482    if (e.component !== 'ToolGroup') return next(e)
483    const settings = await read($, settingsAtom)
484    const look = LOOKS[settings.look]
485    // An expanded group (ctrl+o, --verbose) unfolds into Claude Code's own rows.
486    if (!look || e.props.isExpanded) return next(e)
487    const rows = e.props.calls.map(c => rowOf(c.tool_use_id ?? '', c))
488    const first = rows[0]
489    const fade = fadeOf(first ? model.toolTurn.get(first.id) : undefined, model.turn, settings.ingredients.recency)
490    const ctx = ctxOf(e, $.ui.resolve(e), settings, fade)
491    const visible = rows.filter(r => !isFootnoted(settings, r, e.surface))
492    if (visible.length === 0) return ctx.els.Box({})
493    const hidden = visible.filter(r => isHiddenByQuiet(settings, r)).length
494    if (hidden > 0 && hidden === visible.length) return spaced(ctx.els, look.quietLine(hidden, ctx))
495    return spaced(ctx.els, look.toolGroup(visible, ctx))
496  }).catch(async ($, e, next) => next(e))
497
498  on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
499    if (e.component !== 'ToolResult') return next(e)
500    const settings = await read($, settingsAtom)
501    const look = LOOKS[settings.look]
502    if (!look) return next(e)
503    const els = $.ui.resolve(e)
504    const isFolded = isFootnoted(settings, { id: e.props.tool_use_id, isErrored: e.props.isErrored }, e.surface)
505    if (!e.props.isErrored && (isHiddenByQuiet(settings, e.props) || isFolded)) return els.Box({})
506    const fade = fadeOf(model.toolTurn.get(e.props.tool_use_id), model.turn, settings.ingredients.recency)
507    const drawn = look.toolResult({ tool: e.props.tool, output: e.props.output, isErrored: e.props.isErrored }, ctxOf(e, els, settings, fade))
508    return drawn ?? next(e)
509  }).catch(async ($, e, next) => next(e))
510
511  on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
512    if (e.component !== 'UserMessage') return next(e)
513    const settings = await read($, settingsAtom)
514    const look = LOOKS[settings.look]
515    if (!look || e.props.isExpanded || e.props.origin.kind !== 'composer') return next(e)
516    const turn = Model.turnOfRow(model, e.requestId, await $.clock.now())
517    const fade = fadeOf(turn, model.turn, settings.ingredients.recency)
518    const ctx = ctxOf(e, $.ui.resolve(e), settings, fade)
519    const prompt = look.userMessage(e.props.text, ctx)
520    const title = settings.ingredients.headlines ? model.turns.find(t => t.turn === turn)?.title : undefined
521    if (!title || turn === undefined) return prompt
522    return ctx.els.Box({ flexDirection: 'column', children: [look.headline({ turn, title }, ctx), prompt] })
523  }).catch(async ($, e, next) => next(e))
524
525  on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
526    if (e.component !== 'TurnDuration') return next(e)
527    const settings = await read($, settingsAtom)
528    const look: Look | null = LOOKS[settings.look]
529    if (!look) return next(e)
530    const stats = Model.receiptFor(model, e.requestId, await $.clock.now())
531    const fade = fadeOf(stats?.turn, model.turn, settings.ingredients.recency)
532    const ctx = ctxOf(e, $.ui.resolve(e), settings, fade)
533    return spaced(ctx.els, look.receipt(receiptData(settings, e.props.durationMs, stats, ctx.columns), ctx), true)
534  }).catch(async ($, e, next) => next(e))
535
536  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
537    if (e.component !== 'Spinner') return next(e)
538    const wasWaiting = model.waiting !== null
539    Model.workingLineDrawn(model, await $.clock.now())
540    if (wasWaiting && model.waiting === null) $.ui.invalidate('ui.render')
541    const settings = await read($, settingsAtom)
542    const look = LOOKS[settings.look]
543    if (!look) return next(e)
544    const elapsed = model.isWorking ? (await $.clock.now()) - model.turnStartedAt : 0
545    const state = { ...liveStateOf(e.props.mode, Model.latestStep(model), elapsed, cwd, e.props.message), inator: settings.ingredients.inator, isDark }
546    const ctx = ctxOf(e, $.ui.resolve(e), settings, 0)
547    const barWidth = Math.max(20, Math.min(120, ctx.columns - 4))
548    live = e.surface === 'terminal' ? { requestId: e.requestId, state, barWidth } : null
549    // The working bar sits under the live line, always, and both keep a blank line from the transcript.
550    const work = workOf(await $.clock.now())
551    const bar =
552      e.surface === 'terminal' && 'Raster' in ctx.els
553        ? ctx.els.Raster({ key: 'cz-work', columns: barWidth, rows: 1, cells: encodeCells(workingBarCells(work, barColors(settings), isDark, barWidth, frame)) })
554        : ctx.els.Text({ color: 'inactive', children: workingBarText(work) })
555    return ctx.els.Box({ flexDirection: 'column', marginTop: 1, children: [look.live(state, frame, ctx), bar] })
556  }).catch(async ($, e, next) => next(e))
557}
558
src/engine/attention.ts 52 lines
1import { printable } from './format'
2import type { Settings } from './settings'
3
4
5/** The ladder's steps: a glow in the band at once, a toast at 30 s, sound and a notification at 2 min. */
6export const TOAST_AFTER_MS = 30_000
7export const ALERT_AFTER_MS = 120_000
8
9export type LadderState = { toasted: boolean; alerted: boolean }
10export type LadderAction = 'toast' | 'sound' | 'notify'
11
12/** What the ladder should do now, given how long Claude has waited and what already happened. */
13export function ladderActions(waitedMs: number, settings: Settings, state: LadderState): LadderAction[] {
14  if (!settings.ingredients.attention) return []
15  const actions: LadderAction[] = []
16  if (!state.toasted && waitedMs >= TOAST_AFTER_MS) actions.push('toast')
17  if (!state.alerted && waitedMs >= ALERT_AFTER_MS) {
18    if (settings.attention.sound) actions.push('sound')
19    if (settings.attention.notify) actions.push('notify')
20  }
21  return actions
22}
23
24/**
25 * Programs to try, in order, for a desktop notification: Linux, then macOS.
26 * The text always travels as plain arguments, never inside a script or as an
27 * option: `--` ends notify-send's options, and AppleScript reads it from argv.
28 */
29export function notifyCommands(title: string, body: string): string[][] {
30  const t = printable(title, 80)
31  const b = printable(body, 160)
32  return [
33    // Some notification servers read markup in the body, so its text is escaped.
34    ['notify-send', '--app-name=Claudinator', '--', t, b.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')],
35    ['osascript', '-e', 'on run argv', '-e', 'display notification (item 2 of argv) with title (item 1 of argv)', '-e', 'end run', t, b],
36  ]
37}
38
39/** Programs to try, in order, for a short system sound: PipeWire, PulseAudio, then macOS. */
40export function soundCommands(): string[][] {
41  return [
42    ['pw-play', '/usr/share/sounds/freedesktop/stereo/message.oga'],
43    ['paplay', '/usr/share/sounds/freedesktop/stereo/message.oga'],
44    ['afplay', '/System/Library/Sounds/Glass.aiff'],
45  ]
46}
47
48/** True unless the Claude Code theme setting names a light theme. */
49export function isDarkTheme(theme: unknown): boolean {
50  return !(typeof theme === 'string' && /light/i.test(theme))
51}
52
src/engine/combos.ts 23 lines
1import { INGREDIENT_IDS, setupLayer } from './settings'
2import type { IngredientId, LookId, SettingsLayer } from './settings'
3
4export type Combo = { id: string; label: string; summary: string; layer: SettingsLayer }
5
6function combo(id: string, label: string, summary: string, look: LookId, on: IngredientId[]): Combo {
7  const ingredients = Object.fromEntries(INGREDIENT_IDS.map(i => [i, on.includes(i)])) as Record<IngredientId, boolean>
8  return { id, label, summary, layer: setupLayer(look, ingredients) }
9}
10
11/** Ready-made setups: a look plus a full set of ingredients. */
12export const COMBOS: readonly Combo[] = [
13  combo('daily', 'Daily driver', 'Hairline, headlines, file colors', 'hairline', ['recency', 'miniDiffs', 'headlines', 'fileColors', 'attention']),
14  combo('storyteller', 'Storyteller', 'Broadsheet with footnotes', 'broadsheet', ['headlines', 'footnotes', 'fileColors', 'attention']),
15  combo('showoff', 'Show-off', 'Prism with a time strip', 'prism', ['fileColors', 'miniDiffs', 'timeStrip', 'attention']),
16  combo('doof', 'Doof mode', 'Blueprint, fully -inator', 'blueprint', ['miniDiffs', 'headlines', 'timeStrip', 'inator', 'attention']),
17  combo('zen', 'Zen', 'Sumi, footnotes, fade', 'sumi', ['recency', 'footnotes', 'attention']),
18]
19
20export function comboById(id: string): Combo | undefined {
21  return COMBOS.find(c => c.id === id.toLowerCase())
22}
23
src/engine/format.ts 73 lines
1/** Calls at or above this many milliseconds count as slow. */
2export const SLOW_MS = 3000
3
4/** Splits a path into its folder and file name, relative to `cwd` when inside it. Always printable. */
5export function splitPath(path: string, cwd = ''): { dir: string; base: string } {
6  const root = cwd === '' ? '' : cwd.endsWith('/') ? cwd : cwd + '/'
7  const clean = printable(path, 1000)
8  // The project folder itself reads as ./, not as a file named after the folder.
9  if (root !== '' && (clean === root || clean + '/' === root)) return { dir: '', base: './' }
10  const p = root !== '' && clean.startsWith(root) ? clean.slice(root.length) : clean
11  const i = p.lastIndexOf('/')
12  return i < 0 ? { dir: '', base: p } : { dir: p.slice(0, i + 1), base: p.slice(i + 1) }
13}
14
15/** `0.2s`, `3.8s`, `42s`, `2m 14s`. Empty for a value that is not a duration. */
16export function formatDuration(ms: number): string {
17  if (!Number.isFinite(ms) || ms < 0) return ''
18  if (ms < 10_000) return (Math.round(ms / 100) / 10).toFixed(1) + 's'
19  const total = Math.round(ms / 1000)
20  if (total < 60) return `${total}s`
21  const m = Math.floor(total / 60)
22  const s = total % 60
23  return s === 0 ? `${m}m` : `${m}m ${s}s`
24}
25
26/** `m:ss`, for the live line's stopwatch. */
27export function clockLabel(ms: number): string {
28  const total = Math.max(0, Math.floor(ms / 1000))
29  return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`
30}
31
32/** Width of the duration hairline, 1 to 8 cells. */
33export function durationBarWidth(ms: number): number {
34  return Math.min(8, Math.max(1, Math.round(ms / 550)))
35}
36
37/** `1 file`, `3 files`. */
38export function plural(n: number, one: string, many = one + 's'): string {
39  return `${n} ${n === 1 ? one : many}`
40}
41
42function clip(text: string, max: number): string {
43  return text.length > max ? text.slice(0, Math.max(0, max - 1)) + '…' : text
44}
45
46// Terminal escape sequences, 7-bit and 8-bit: OSC (titles, hyperlinks) and CSI (colors, cursor moves).
47const OSC = /(?:\x1b\]|\x9d)[^\x07\x1b\x9c]*(?:\x07|\x1b\\|\x9c)/g
48const CSI = /(?:\x1b\[|\x9b)[0-?]*[ -/]*[@-~]/g
49const OTHER_ESC = /\x1b[@-_]?/g
50
51/**
52 * Text that is safe to draw: no escape sequences or control characters, tabs
53 * as two spaces, at most `max` characters. Claude Code refuses a drawing whose
54 * text holds a control character or runs past 10,000 characters.
55 */
56export function printable(text: string, max: number, options: { keepNewlines?: boolean } = {}): string {
57  let out = text.replace(OSC, '').replace(CSI, '').replace(OTHER_ESC, '').replace(/\t/g, '  ').replace(/\r\n?/g, '\n')
58  out = options.keepNewlines ? out.replace(/[\x00-\x09\x0b-\x1f\x7f-\x9f]/g, '') : out.replace(/\n/g, ' ').replace(/[\x00-\x1f\x7f-\x9f]/g, '')
59  return clip(out, max)
60}
61
62/** The first non-empty line, made printable and cut to `max` characters. */
63export function oneLine(text: string, max: number): string {
64  const line = text.split(/\r?\n/).map(l => printable(l, Number.MAX_SAFE_INTEGER).trim()).find(l => l !== '') ?? ''
65  return clip(line, max)
66}
67
68/** The last non-empty line, made printable and cut to `max` characters. */
69export function lastLine(text: string, max: number): string {
70  const lines = text.split(/\r?\n/).map(l => printable(l, Number.MAX_SAFE_INTEGER).trim()).filter(l => l !== '')
71  return clip(lines[lines.length - 1] ?? '', max)
72}
73
src/engine/palette.ts 40 lines
1/** Mid-tone colors that read on dark and light terminals alike. */
2export const FILE_COLORS: readonly string[] = ['#8b93ff', '#e2709b', '#d79a3f', '#3fb39b', '#a07be0', '#4c9fd9', '#7fa35a', '#d9725f']
3
4function hash(text: string): number {
5  let h = 0x811c9dc5
6  for (let i = 0; i < text.length; i++) {
7    h ^= text.charCodeAt(i)
8    h = Math.imul(h, 0x01000193) >>> 0
9  }
10  return h
11}
12
13/** The color a file keeps everywhere. */
14export function fileColor(path: string): string {
15  return FILE_COLORS[hash(path) % FILE_COLORS.length] ?? '#8b93ff'
16}
17
18export type Fade = 0 | 1 | 2
19
20/** 0 for this turn's rows, 1 for the previous turn's, 2 for anything older or unknown. */
21export function fadeOf(rowTurn: number | undefined, currentTurn: number, enabled: boolean): Fade {
22  if (!enabled) return 0
23  if (rowTurn === undefined) return currentTurn >= 1 ? 2 : 0
24  const age = currentTurn - rowTurn
25  return age <= 0 ? 0 : age === 1 ? 1 : 2
26}
27
28/** A color as it reads at a fade level, using Claude Code's dimmer theme tokens. */
29export function tone(color: string, fade: Fade): string {
30  return fade === 0 ? color : fade === 1 ? 'inactive' : 'subtle'
31}
32
33/** 24-bit colors for Raster cells, chosen to read on dark and light terminals. */
34export const LIVE = {
35  DEFAULT: 0x01000000,
36  GRAY: 0x8a8a8a,
37  ACCENT: 0x8b8ef0,
38  ACCENT_DIM: 0x4f5290,
39} as const
40
src/engine/session-model.ts 536 lines
1import { printable } from './format'
2import { changeOf, isChangeTool } from './tool-facts'
3
4export type FileChange = { file: string; add: number; del: number }
5
6export type TurnStats = {
7  turn: number
8  files: string[]
9  add: number
10  del: number
11  contextPercent?: number
12  /** Lines changed per file, in the order the files were first changed. */
13  byFile?: FileChange[]
14  /** Tool calls in the turn, and the time their tools ran (overlaps counted once; a call that asked counts only its run). */
15  toolCount?: number
16  toolsMs?: number
17  /** The turn's own wall-clock time, from its start to its end. */
18  wallMs?: number
19  /** Time spent on calls that waited for the user's permission. */
20  waitingMs?: number
21  /** The turn's footnotes, when Footnotes was on. */
22  notes?: Note[]
23  /** Claude's todo list at the end of the turn, when it wrote one during it. */
24  tasks?: { done: number; total: number }
25  /** A short title derived from the answer, for Headlines. */
26  title?: string
27}
28
29export type Note = { n: number; tool: string; input: unknown; durationMs?: number }
30
31/** One turn, for the Chapters pane. */
32export type TurnRecord = { turn: number; prompt: string; title?: string; userRowId?: string; startedAt: number; durationMs?: number; add: number; del: number; files: number }
33
34/** One file changed this session, for the Ledger pane. */
35export type LedgerEntry = { file: string; add: number; del: number; turns: number[]; lastToolId: string }
36export type Running = { tool: string; input: unknown; startedAt: number }
37
38/**
39 * What Claudinator knows about this session, built only from live events and
40 * kept in memory. Nothing here is read from history or written to disk.
41 */
42export type SessionModel = {
43  turn: number
44  isWorking: boolean
45  turnStartedAt: number
46  lastSubmitAt: number
47  completedAt: number
48  current: TurnStats
49  lastCompleted: TurnStats | null
50  toolTurn: Map<string, number>
51  toolStart: Map<string, number>
52  toolMs: Map<string, number>
53  running: Map<string, Running>
54  prompted: Set<string>
55  receipts: Map<string, TurnStats | null>
56  rowTurn: Map<string, number | undefined>
57  pendingPrompt: string
58  pendingRowId: string | undefined
59  turns: TurnRecord[]
60  ledger: Map<string, LedgerEntry>
61  changeIndex: Map<string, number>
62  lastAssistantId: string | null
63  marks: Map<string, number[]>
64  /** Reply blocks already drawn: an old one drawn again never takes new footnotes. */
65  seenReplies: Set<string>
66  /** Last start or end of a turn or tool call, to tell a reply that is streaming now from an old one. */
67  lastActivityAt: number
68  noteOfTool: Map<string, number>
69  waiting: { id: string; tool: string; input: unknown; since: number } | null
70  /** Calls made inside a subagent's loop: they reach the ledger, never the main turn's notes or time. */
71  subagentCalls: Set<string>
72  /** The turn each footnoted call belongs to. */
73  noteTurn: Map<string, number>
74  /** Turns whose receipt was drawn with their notes, so their footnoted rows may hide. */
75  shownTurns: Set<number>
76  /** The turn just finished and its receipt not drawn yet. */
77  isReceiptPending: boolean
78  /** Claude's latest todo list and the turn that wrote it, for live progress. */
79  todos: Todo[]
80  todosTurn: number
81  /** Claude Code's task list (TaskCreate, TaskUpdate), by task id, in creation order. */
82  tasks: Map<string, Todo>
83  /** The last call started this turn, for the live line between calls. */
84  lastStep: Running | null
85  /** The current turn's calls as spans of time, to measure tools and waiting without counting overlaps twice. */
86  spans: Span[]
87}
88
89type Span = { start: number; end: number; isWaiting: boolean }
90type Todo = { status: string; label: string }
91export type Progress = { done: number; total: number; active: string }
92
93/** The items of a TodoWrite call, or none when its input is not a list of todos. */
94function todosOf(input: unknown): Todo[] {
95  const list = typeof input === 'object' && input !== null ? (input as { todos?: unknown }).todos : undefined
96  if (!Array.isArray(list)) return []
97  return list.flatMap(item => {
98    if (typeof item !== 'object' || item === null) return []
99    const t = item as { status?: unknown; content?: unknown; activeForm?: unknown }
100    const label = typeof t.activeForm === 'string' && t.activeForm !== '' ? t.activeForm : typeof t.content === 'string' ? t.content : ''
101    return typeof t.status === 'string' ? [{ status: t.status, label: printable(label, 120) }] : []
102  })
103}
104
105/** How long after a permission prompt appears the working line's return means the user answered. */
106export const ANSWER_GAP_MS = 1000
107
108/** How soon after a submit or a turn's end a newly drawn row is taken to belong to it. */
109export const BIND_WINDOW_MS = 5000
110
111const emptyStats = (turn: number): TurnStats => ({ turn, files: [], add: 0, del: 0 })
112
113export function createModel(): SessionModel {
114  return {
115    turn: 0,
116    isWorking: false,
117    turnStartedAt: 0,
118    lastSubmitAt: Number.NEGATIVE_INFINITY,
119    completedAt: Number.NEGATIVE_INFINITY,
120    current: emptyStats(0),
121    lastCompleted: null,
122    toolTurn: new Map(),
123    toolStart: new Map(),
124    toolMs: new Map(),
125    running: new Map(),
126    prompted: new Set(),
127    receipts: new Map(),
128    rowTurn: new Map(),
129    pendingPrompt: '',
130    pendingRowId: undefined,
131    turns: [],
132    ledger: new Map(),
133    changeIndex: new Map(),
134    lastAssistantId: null,
135    marks: new Map(),
136    seenReplies: new Set(),
137    lastActivityAt: Number.NEGATIVE_INFINITY,
138    noteOfTool: new Map(),
139    waiting: null,
140    subagentCalls: new Set(),
141    noteTurn: new Map(),
142    shownTurns: new Set(),
143    isReceiptPending: false,
144    spans: [],
145    lastStep: null,
146    todos: [],
147    todosTurn: 0,
148    tasks: new Map(),
149  }
150}
151
152export function promptSubmitted(m: SessionModel, now: number, text = ''): void {
153  m.lastSubmitAt = now
154  m.pendingPrompt = printable(text, 300)
155  m.pendingRowId = undefined
156}
157
158export function startTurn(m: SessionModel, now: number): void {
159  m.turn += 1
160  m.isWorking = true
161  m.turnStartedAt = now
162  m.current = { ...emptyStats(m.turn), byFile: [], toolCount: 0, toolsMs: 0, waitingMs: 0, notes: [] }
163  m.lastAssistantId = null
164  m.marks.delete('')
165  m.lastActivityAt = now
166  m.spans = []
167  m.lastStep = null
168  const record: TurnRecord = { turn: m.turn, prompt: m.pendingPrompt, startedAt: now, add: 0, del: 0, files: 0 }
169  if (m.pendingRowId !== undefined) record.userRowId = m.pendingRowId
170  m.turns.push(record)
171  m.pendingPrompt = ''
172  m.pendingRowId = undefined
173}
174
175/** A reply block drawn while the turn runs; footnotes that follow attach to it. */
176export function assistantSeen(m: SessionModel, requestId: string, now: number): void {
177  if (m.seenReplies.has(requestId)) return
178  m.seenReplies.add(requestId)
179  // Only a block that appears during a turn, right after activity, is this turn's reply.
180  if (!m.isWorking || now - m.lastActivityAt > BIND_WINDOW_MS) return
181  m.lastAssistantId = requestId
182  // Calls made before any reply text this turn belong to the first block that follows.
183  const early = m.marks.get('') ?? []
184  m.marks.delete('')
185  m.marks.set(requestId, early)
186}
187
188/**
189 * A prompt sent while Claude works: its row never takes the running turn's place.
190 * If it goes on to start the next turn, that turn keeps its text.
191 */
192export function promptQueued(m: SessionModel, text = ''): void {
193  m.lastSubmitAt = Number.NEGATIVE_INFINITY
194  m.pendingPrompt = printable(text, 300)
195  m.pendingRowId = undefined
196}
197
198/**
199 * Claude Code drew its working line. That counts as activity, so a reply after
200 * long thinking still binds; and as it is hidden while a permission prompt is
201 * open, its return a moment later means the user has answered.
202 */
203export function workingLineDrawn(m: SessionModel, now: number): void {
204  if (!m.isWorking) return
205  m.lastActivityAt = now
206  if (m.waiting && now - m.waiting.since >= ANSWER_GAP_MS) m.waiting = null
207}
208
209export function toolStarted(m: SessionModel, id: string, tool: string, input: unknown, now: number, isFootnoted = false, isSubagent = false): void {
210  m.toolTurn.set(id, m.turn)
211  m.toolStart.set(id, now)
212  m.lastActivityAt = now
213  m.running.set(id, { tool, input, startedAt: now })
214  if (!isSubagent) m.lastStep = { tool, input, startedAt: now }
215  if (!isSubagent && tool === 'TodoWrite') {
216    const todos = todosOf(input)
217    if (todos.length > 0) {
218      m.todos = todos
219      m.todosTurn = m.turn
220    }
221  }
222  if (isSubagent) {
223    m.subagentCalls.add(id)
224    return
225  }
226  if (isChangeTool(tool)) {
227    let count = 0
228    for (const [otherId, turn] of m.toolTurn) if (turn === m.turn && m.changeIndex.has(otherId)) count += 1
229    m.changeIndex.set(id, count + 1)
230  }
231  if (isFootnoted && m.isWorking) {
232    const n = (m.current.notes?.length ?? 0) + 1
233    m.current.notes = [...(m.current.notes ?? []), { n, tool, input }]
234    m.noteOfTool.set(id, n)
235    m.noteTurn.set(id, m.turn)
236    const owner = m.lastAssistantId ?? ''
237    m.marks.set(owner, [...(m.marks.get(owner) ?? []), n])
238  }
239}
240
241/** A call that waited on a permission prompt: its time is the user's, so no duration is shown. */
242export function toolPrompted(m: SessionModel, id: string): void {
243  m.prompted.add(id)
244}
245
246/**
247 * A call ended. `execMs`, when Claude Code reports it, is how long the tool
248 * itself ran; for a call that asked, the rest of its time was the user's.
249 */
250export function toolFinished(m: SessionModel, id: string, tool: string, input: unknown, now: number, isError: boolean, execMs?: number): void {
251  const start = m.toolStart.get(id)
252  const ms = start === undefined ? undefined : Math.max(0, now - start)
253  const isPrompted = m.prompted.has(id)
254  const ranMs = ms === undefined ? undefined : isPrompted ? (execMs === undefined ? undefined : Math.min(ms, Math.max(0, execMs))) : ms
255  m.lastActivityAt = now
256  if (ranMs !== undefined) m.toolMs.set(id, ranMs)
257  m.running.delete(id)
258  if (m.waiting?.id === id) m.waiting = null
259  const isMain = !m.subagentCalls.has(id)
260  if (isMain && m.isWorking && m.toolTurn.get(id) === m.turn && start !== undefined && ms !== undefined) {
261    m.current.toolCount = (m.current.toolCount ?? 0) + 1
262    const ranFrom = now - (ranMs ?? 0)
263    if (isPrompted) m.spans.push({ start, end: ranFrom, isWaiting: true })
264    if (ranMs !== undefined && ranMs > 0) m.spans.push({ start: ranFrom, end: now, isWaiting: false })
265    const n = m.noteOfTool.get(id)
266    if (n !== undefined && ranMs !== undefined) m.current.notes = (m.current.notes ?? []).map(note => (note.n === n ? { ...note, durationMs: ranMs } : note))
267  }
268  if (isError || !isChangeTool(tool)) return
269  if (!isMain) {
270    const change = changeOf(tool, input)
271    if (change && change.file !== '') addToLedger(m, id, change)
272    return
273  }
274  const change = changeOf(tool, input)
275  if (!change || change.file === '') return
276  if (!m.current.files.includes(change.file)) m.current.files.push(change.file)
277  m.current.add += change.add
278  m.current.del += change.del
279  const byFile = m.current.byFile ?? []
280  const entry = byFile.find(f => f.file === change.file)
281  if (entry) {
282    entry.add += change.add
283    entry.del += change.del
284  } else byFile.push({ file: change.file, add: change.add, del: change.del })
285  m.current.byFile = byFile
286  addToLedger(m, id, change)
287}
288
289function addToLedger(m: SessionModel, id: string, change: { file: string; add: number; del: number }): void {
290  const ledger = m.ledger.get(change.file) ?? { file: change.file, add: 0, del: 0, turns: [], lastToolId: id }
291  ledger.add += change.add
292  ledger.del += change.del
293  if (!ledger.turns.includes(m.turn)) ledger.turns.push(m.turn)
294  ledger.lastToolId = id
295  m.ledger.set(change.file, ledger)
296}
297
298/** The length of time covered by a set of spans, overlaps counted once. */
299function coveredMs(spans: Span[]): number {
300  const sorted = spans.filter(s => s.end > s.start).sort((a, b) => a.start - b.start)
301  let total = 0
302  let from = Number.NEGATIVE_INFINITY
303  let to = Number.NEGATIVE_INFINITY
304  for (const s of sorted) {
305    if (s.start > to) {
306      if (to > from) total += to - from
307      from = s.start
308      to = s.end
309    } else to = Math.max(to, s.end)
310  }
311  if (to > from) total += to - from
312  return total
313}
314
315export function completeTurn(m: SessionModel, now: number, contextPercent?: number, answer?: string): void {
316  const sawStart = m.isWorking
317  m.isWorking = false
318  m.completedAt = now
319  m.running.clear()
320  // A turn that began before Claudinator loaded has partial stats; show none rather than wrong ones.
321  if (!sawStart) {
322    m.lastCompleted = null
323    return
324  }
325  const busyMs = coveredMs(m.spans)
326  const waitingMs = coveredMs(m.spans.filter(s => s.isWaiting))
327  m.current.waitingMs = waitingMs
328  m.current.toolsMs = busyMs - waitingMs
329  m.current.wallMs = Math.max(0, now - m.turnStartedAt)
330  if (m.todosTurn === m.turn && m.todos.length > 0) m.current.tasks = { done: m.todos.filter(t => t.status === 'completed').length, total: m.todos.length }
331  m.isReceiptPending = true
332  const record = m.turns[m.turns.length - 1]
333  const title = headlineOf(answer, record?.prompt ?? '')
334  m.lastCompleted = {
335    ...m.current,
336    files: [...m.current.files],
337    byFile: (m.current.byFile ?? []).map(f => ({ ...f })),
338    ...(title === '' ? {} : { title }),
339    ...(contextPercent === undefined ? {} : { contextPercent }),
340  }
341  if (record && record.turn === m.turn) {
342    record.durationMs = Math.max(0, now - record.startedAt)
343    record.add = m.current.add
344    record.del = m.current.del
345    record.files = m.current.files.length
346    if (title !== '') record.title = title
347  }
348}
349
350/** A short title: the answer's first sentence, else the prompt's, at most about 60 characters. */
351export function headlineOf(answer: string | undefined, prompt: string): string {
352  // The first sentence that says something: a bare opener ("Done.", "Perfect!") is skipped.
353  const firstSentence = (text: string): string => {
354    // Markdown marks go (**bold**, `code`, # headings, > quotes); a lone * between words, as in code, stays.
355    let rest = printable(text, 2000)
356      .replace(/\*\*|__|`|^#+\s*|^>\s*/gm, '')
357      // Emoji and check marks (✅ 🎉 ✔) read as noise in a title.
358      .replace(/[\p{Extended_Pictographic}\u2705\u2714\u2716\uFE0F]/gu, '')
359      .replace(/\s+/g, ' ')
360      .replace(/ ([,.;:!?])/g, '$1')
361      .trim()
362    while (rest !== '') {
363      const match = rest.match(/^(.+?)[.!?:](?:\s|$)/)
364      const sentence = (match?.[1] ?? rest).trim()
365      if (sentence.split(' ').length > 2 || !match) return sentence.split(' ').length > 2 ? sentence : ''
366      rest = rest.slice(match[0].length).trim()
367    }
368    return ''
369  }
370  let title = firstSentence(answer ?? '') || firstSentence(prompt)
371  if (title.length > 60) {
372    const cut = title.slice(0, 60)
373    const space = cut.lastIndexOf(' ')
374    title = (space > 20 ? cut.slice(0, space) : cut) + '…'
375  }
376  return title.charAt(0).toUpperCase() + title.slice(1)
377}
378
379export function changeIndexOf(m: SessionModel, id: string): number | undefined {
380  return m.changeIndex.get(id)
381}
382
383export function marksOf(m: SessionModel, assistantId: string): number[] {
384  return m.marks.get(assistantId) ?? []
385}
386
387export function noteOf(m: SessionModel, toolId: string): number | undefined {
388  return m.noteOfTool.get(toolId)
389}
390
391/**
392 * Whether a footnoted call is accounted for elsewhere, so its row may hide:
393 * while its turn runs or waits for its receipt, and once a receipt shows its note.
394 */
395export function isNoteShown(m: SessionModel, toolId: string): boolean {
396  const turn = m.noteTurn.get(toolId)
397  if (turn === undefined) return false
398  if (turn === m.turn && (m.isWorking || m.isReceiptPending)) return true
399  return m.shownTurns.has(turn)
400}
401
402export function ledgerOf(m: SessionModel): LedgerEntry[] {
403  return [...m.ledger.values()].map(e => ({ ...e, turns: [...e.turns] }))
404}
405
406/**
407 * Claude Code opened a permission dialog for a `tool` call: the newest such call still running in
408 * the main loop waits on the user from `now`. False when no running call matches.
409 */
410export function permissionShown(m: SessionModel, tool: string, now: number): boolean {
411  let match: { id: string; input: unknown; startedAt: number } | undefined
412  for (const [id, r] of m.running) {
413    if (r.tool !== tool || m.subagentCalls.has(id) || m.prompted.has(id)) continue
414    if (!match || r.startedAt >= match.startedAt) match = { id, input: r.input, startedAt: r.startedAt }
415  }
416  if (!match) return false
417  m.prompted.add(match.id)
418  waitingStarted(m, match.id, tool, match.input, now)
419  return true
420}
421
422export function waitingStarted(m: SessionModel, id: string, tool: string, input: unknown, now: number): void {
423  m.waiting = { id, tool, input, since: now }
424}
425
426export function waitingEnded(m: SessionModel, id: string): void {
427  if (m.waiting?.id === id) m.waiting = null
428}
429
430export function waitingOf(m: SessionModel, now: number): { tool: string; input: unknown; waitedMs: number } | null {
431  return m.waiting ? { tool: m.waiting.tool, input: m.waiting.input, waitedMs: Math.max(0, now - m.waiting.since) } : null
432}
433
434/** The stats a turn's closing line shows; bound the first time that line is drawn. */
435export function receiptFor(m: SessionModel, requestId: string, now: number): TurnStats | null {
436  if (!m.receipts.has(requestId)) {
437    const stats = now - m.completedAt <= BIND_WINDOW_MS ? m.lastCompleted : null
438    m.receipts.set(requestId, stats)
439    if (stats) m.shownTurns.add(stats.turn)
440    m.isReceiptPending = false
441  }
442  return m.receipts.get(requestId) ?? null
443}
444
445/** The turn a prompt row starts; undefined for rows drawn long after any submit. */
446export function turnOfRow(m: SessionModel, requestId: string, now: number): number | undefined {
447  if (!m.rowTurn.has(requestId)) {
448    const isFresh = now - m.lastSubmitAt <= BIND_WINDOW_MS
449    const turn = isFresh ? (m.isWorking ? m.turn : m.turn + 1) : undefined
450    m.rowTurn.set(requestId, turn)
451    if (turn !== undefined && requestId !== 'placeholder') {
452      const record = m.turns.find(t => t.turn === turn)
453      if (record) record.userRowId ??= requestId
454      else m.pendingRowId = requestId
455    }
456  }
457  return m.rowTurn.get(requestId)
458}
459
460export function durationOf(m: SessionModel, id: string): number | undefined {
461  return m.toolMs.get(id)
462}
463
464const rec = (v: unknown): Record<string, unknown> => (typeof v === 'object' && v !== null ? (v as Record<string, unknown>) : {})
465// Ids may come as numbers (`taskId: 2`); everything else that is not text reads as empty.
466const text = (v: unknown): string => (typeof v === 'string' ? v : typeof v === 'number' && Number.isFinite(v) ? String(v) : '')
467
468/**
469 * A main-loop call to one of Claude Code's task tools finished with `result`:
470 * keep the task list in step, and count it as this turn's progress.
471 */
472export function taskToolDone(m: SessionModel, tool: string, input: unknown, result: unknown): void {
473  const i = rec(input)
474  const r = rec(result)
475  if (tool === 'TaskCreate') {
476    const id = text(rec(r.task).id)
477    if (id === '') return
478    const label = text(i.activeForm) || text(i.subject) || text(rec(r.task).subject)
479    m.tasks.set(id, { status: 'pending', label: printable(label, 120) })
480  } else if (tool === 'TaskUpdate') {
481    const id = text(i.taskId)
482    const task = m.tasks.get(id)
483    if (!task) return
484    if (i.status === 'deleted') m.tasks.delete(id)
485    else {
486      if (typeof i.status === 'string') task.status = i.status
487      const label = text(i.activeForm) || text(i.subject)
488      if (label !== '') task.label = printable(label, 120)
489    }
490  } else if (tool === 'TaskList') {
491    if (!Array.isArray(r.tasks)) return
492    const known = m.tasks
493    m.tasks = new Map()
494    for (const t of r.tasks.map(rec)) {
495      const id = text(t.id)
496      if (id === '' || typeof t.status !== 'string') continue
497      m.tasks.set(id, { status: t.status, label: known.get(id)?.label ?? printable(text(t.subject), 120) })
498    }
499  } else return
500  m.todos = [...m.tasks.values()]
501  m.todosTurn = m.turn
502}
503
504/** Where the running turn's time has gone so far: calls still running and a wait still open count up to now. */
505export function liveTimes(m: SessionModel, now: number): { thinkingMs: number; toolsMs: number; waitingMs: number } {
506  const open: Span[] = []
507  for (const [id, r] of m.running) {
508    if (m.subagentCalls.has(id) || m.toolTurn.get(id) !== m.turn) continue
509    const isWaiting = m.waiting?.id === id
510    if (!isWaiting) open.push({ start: r.startedAt, end: now, isWaiting: false })
511  }
512  if (m.waiting) open.push({ start: m.waiting.since, end: now, isWaiting: true })
513  const spans = [...m.spans, ...open]
514  const busyMs = coveredMs(spans)
515  const waitingMs = coveredMs(spans.filter(s => s.isWaiting))
516  return { thinkingMs: Math.max(0, now - m.turnStartedAt - busyMs), toolsMs: busyMs - waitingMs, waitingMs }
517}
518
519/** How far through its todo list Claude is, while a turn runs that wrote one; otherwise null. */
520export function progressOf(m: SessionModel): Progress | null {
521  if (!m.isWorking || m.todosTurn !== m.turn || m.todos.length === 0) return null
522  const active = m.todos.find(t => t.status === 'in_progress') ?? m.todos.find(t => t.status !== 'completed')
523  return { done: m.todos.filter(t => t.status === 'completed').length, total: m.todos.length, active: active?.label ?? '' }
524}
525
526/** The step running now, else the last one started this turn: what the live line names between calls. */
527export function latestStep(m: SessionModel): Running | undefined {
528  return latestRunning(m) ?? m.lastStep ?? undefined
529}
530
531export function latestRunning(m: SessionModel): Running | undefined {
532  let last: Running | undefined
533  for (const r of m.running.values()) if (!last || r.startedAt >= last.startedAt) last = r
534  return last
535}
536
src/engine/raster.ts 34 lines
1import { LIVE } from './palette'
2
3const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
4
5/** Standard base64, written out because the module environment has no btoa. */
6export function toBase64(bytes: Uint8Array): string {
7  let out = ''
8  for (let i = 0; i < bytes.length; i += 3) {
9    const a = bytes[i] ?? 0
10    const b = bytes[i + 1] ?? 0
11    const c = bytes[i + 2] ?? 0
12    const n = (a << 16) | (b << 8) | c
13    out += ALPHABET[(n >> 18) & 63]
14    out += ALPHABET[(n >> 12) & 63]
15    out += i + 1 < bytes.length ? ALPHABET[(n >> 6) & 63] : '='
16    out += i + 2 < bytes.length ? ALPHABET[n & 63] : '='
17  }
18  return out
19}
20
21export type Cell = { char: string; fg: number; bg?: number }
22
23/** One row of cells packed as the `cells` a Raster takes. */
24export function encodeCells(cells: Cell[]): string {
25  const words = new Uint32Array(cells.length * 3)
26  cells.forEach((cell, i) => {
27    const cp = cell.char.codePointAt(0) ?? 32
28    words[i * 3] = cp > 0xffff ? 63 : cp
29    words[i * 3 + 1] = cell.fg
30    words[i * 3 + 2] = cell.bg ?? LIVE.DEFAULT
31  })
32  return toBase64(new Uint8Array(words.buffer))
33}
34
src/engine/settings.ts 191 lines
1import type { ClaudinatorSettings, IngredientId, LookId } from '../../types'
2
3export type { IngredientId, LookId } from '../../types'
4
5export type Settings = ClaudinatorSettings
6
7export const LOOK_IDS: readonly LookId[] = ['hairline', 'broadsheet', 'mission', 'prism', 'sumi', 'blueprint', 'off']
8
9export const INGREDIENT_IDS: readonly IngredientId[] = ['recency', 'miniDiffs', 'fileColors', 'quiet', 'headlines', 'footnotes', 'timeStrip', 'attention', 'inator']
10
11/** Part of a Settings, as one source sets it. */
12export type SettingsLayer = {
13  /** Set on a project file's layer: a look it picks never brings ingredients that hide rows. */
14  fromProject?: true
15  look?: LookId
16  ingredients?: Partial<Record<IngredientId, boolean>>
17  attention?: Partial<Settings['attention']>
18}
19
20const BASE_INGREDIENTS: Record<IngredientId, boolean> = {
21  recency: false,
22  miniDiffs: false,
23  fileColors: false,
24  quiet: false,
25  headlines: false,
26  footnotes: false,
27  timeStrip: false,
28  attention: true,
29  inator: false,
30}
31
32/** What each look turns on by default. The user's own choices always win. */
33export const LOOK_DEFAULTS: Record<LookId, Partial<Record<IngredientId, boolean>>> = {
34  hairline: { recency: true, miniDiffs: true },
35  broadsheet: { headlines: true, footnotes: true },
36  mission: { recency: true, timeStrip: true },
37  prism: { fileColors: true, miniDiffs: true },
38  sumi: { recency: true },
39  blueprint: { miniDiffs: true, headlines: true },
40  off: {},
41}
42
43export const LOOK_LABELS: Record<LookId, string> = {
44  hairline: 'Hairline',
45  broadsheet: 'Broadsheet',
46  mission: 'Mission Control',
47  prism: 'Prism',
48  sumi: 'Sumi',
49  blueprint: 'Blueprint',
50  off: 'Off',
51}
52
53export const INGREDIENT_LABELS: Record<IngredientId, string> = {
54  recency: 'Recency fade',
55  miniDiffs: 'Mini diffs',
56  fileColors: 'File colors',
57  quiet: 'Quiet',
58  headlines: 'Headlines',
59  footnotes: 'Footnotes',
60  timeStrip: 'Time strip',
61  attention: 'Attention ladder',
62  inator: '-inator mode',
63}
64
65export const INGREDIENT_HOTKEYS: Record<IngredientId, string> = {
66  recency: 'r',
67  miniDiffs: 'd',
68  fileColors: 'f',
69  quiet: 'q',
70  headlines: 'h',
71  footnotes: 'n',
72  timeStrip: 't',
73  attention: 'a',
74  inator: 'i',
75}
76
77export function isLookId(value: unknown): value is LookId {
78  return typeof value === 'string' && (LOOK_IDS as readonly string[]).includes(value)
79}
80
81function ingredientsOf(raw: unknown): Partial<Record<IngredientId, boolean>> {
82  if (raw === null || typeof raw !== 'object') return {}
83  const out: Partial<Record<IngredientId, boolean>> = {}
84  for (const id of INGREDIENT_IDS) {
85    const value = (raw as Record<string, unknown>)[id]
86    if (typeof value === 'boolean') out[id] = value
87  }
88  return out
89}
90
91function attentionOf(raw: unknown): Partial<Settings['attention']> {
92  if (raw === null || typeof raw !== 'object') return {}
93  const r = raw as Record<string, unknown>
94  const out: Partial<Settings['attention']> = {}
95  if (typeof r.sound === 'boolean') out.sound = r.sound
96  if (typeof r.notify === 'boolean') out.notify = r.notify
97  return out
98}
99
100function compact(look: unknown, ingredients: Partial<Record<IngredientId, boolean>>, attention: Partial<Settings['attention']> = {}): SettingsLayer {
101  const layer: SettingsLayer = {}
102  if (isLookId(look)) layer.look = look
103  if (Object.keys(ingredients).length > 0) layer.ingredients = ingredients
104  if (Object.keys(attention).length > 0) layer.attention = attention
105  return layer
106}
107
108/** A layer from stored or project JSON, keeping only valid fields. */
109export function layerOf(raw: unknown): SettingsLayer {
110  if (raw === null || typeof raw !== 'object') return {}
111  const r = raw as Record<string, unknown>
112  return compact(r.look, ingredientsOf(r.ingredients), attentionOf(r.attention))
113}
114
115/** Ingredients that hide rows. Only the user may turn these on, never a project file. */
116const HIDING_INGREDIENTS: readonly IngredientId[] = ['quiet', 'footnotes']
117
118/**
119 * A layer from a project's .claude/claudinator.json. A repository you clone
120 * must not be able to hide tool calls from you or make noise, so hiding
121 * ingredients and the attention options are dropped.
122 */
123export function projectLayerOf(raw: unknown): SettingsLayer {
124  const layer = layerOf(raw)
125  const ingredients = { ...(layer.ingredients ?? {}) }
126  for (const id of HIDING_INGREDIENTS) delete ingredients[id]
127  // Nor may it silence the call for your attention: only you turn that off.
128  if (ingredients.attention === false) delete ingredients.attention
129  return { ...compact(layer.look, ingredients), fromProject: true }
130}
131
132/** A layer from the plugin's userConfig options: `look`, `attentionSound`, `attentionNotify`. */
133export function optionsLayer(options: Readonly<Record<string, unknown>>): SettingsLayer {
134  return compact(options.look, {}, attentionOf({ sound: options.attentionSound, notify: options.attentionNotify }))
135}
136
137/**
138 * The look is the last one any layer sets. Ingredients start from the base,
139 * then that look's defaults, then each layer in order: later layers win.
140 */
141export function resolveSettings(layers: SettingsLayer[]): Settings {
142  let look: LookId = 'hairline'
143  let isProjectLook = false
144  for (const layer of layers) {
145    if (layer.look) {
146      look = layer.look
147      isProjectLook = layer.fromProject === true
148    }
149  }
150  const lookDefaults = { ...LOOK_DEFAULTS[look] }
151  // A project can pick a look, but not use the look to hide rows from you.
152  if (isProjectLook) for (const id of HIDING_INGREDIENTS) delete lookDefaults[id]
153  const ingredients = { ...BASE_INGREDIENTS, ...lookDefaults }
154  const attention = { sound: false, notify: false }
155  for (const layer of layers) {
156    Object.assign(ingredients, layer.ingredients ?? {})
157    Object.assign(attention, layer.attention ?? {})
158  }
159  return { version: 1, look, ingredients, attention }
160}
161
162export const DEFAULT_SETTINGS: Settings = resolveSettings([])
163
164/** The user's saved layer with only the look changed. */
165export function changedLook(layer: SettingsLayer, look: LookId): SettingsLayer {
166  return { ...layer, look }
167}
168
169/** The user's saved layer with only one ingredient changed. */
170export function changedIngredient(layer: SettingsLayer, id: IngredientId, value: boolean): SettingsLayer {
171  return { ...layer, ingredients: { ...(layer.ingredients ?? {}), [id]: value } }
172}
173
174/** A whole setup replacing the user's saved layer (a combo or a share code). */
175export function setupLayer(look: LookId, ingredients: Partial<Record<IngredientId, boolean>>): SettingsLayer {
176  return { look, ingredients: { ...ingredients } }
177}
178
179export function withLook(s: Settings, look: LookId): Settings {
180  return { ...s, look, ingredients: { ...s.ingredients } }
181}
182
183export function toggled(s: Settings, id: IngredientId): Settings {
184  return { ...s, ingredients: { ...s.ingredients, [id]: !s.ingredients[id] } }
185}
186
187/** The full choice, as a layer. */
188export function choiceOf(s: Settings): SettingsLayer {
189  return { look: s.look, ingredients: { ...s.ingredients } }
190}
191
src/engine/share-code.ts 54 lines
1import { INGREDIENT_IDS, LOOK_IDS, setupLayer } from './settings'
2import type { IngredientId, LookId, Settings, SettingsLayer } from './settings'
3
4// A share code is `<look>-<ingredients><check>`, such as `HL-43H`: two letters
5// for the look, two base-32 digits for the nine ingredient bits, and a check
6// character that catches typos. Codes ignore case.
7const LOOK_CODES: Record<LookId, string> = {
8  hairline: 'HL',
9  broadsheet: 'BS',
10  mission: 'MC',
11  prism: 'PR',
12  sumi: 'SU',
13  blueprint: 'BP',
14  off: 'OF',
15}
16const DIGITS = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'
17
18// Each character's value times an odd weight, so any single wrong character
19// changes the sum, and different weights catch most swapped neighbors.
20const WEIGHTS = [1, 3, 5, 7]
21
22function checkOf(lookCode: string, digits: string): string {
23  const values = [...lookCode].map(c => c.charCodeAt(0) - 65).concat([...digits].map(d => DIGITS.indexOf(d)))
24  const sum = values.reduce((acc, v, i) => acc + v * (WEIGHTS[i] ?? 1), 0)
25  return DIGITS[sum % DIGITS.length] ?? '0'
26}
27
28export function encodeShareCode(s: Settings): string {
29  let bits = 0
30  INGREDIENT_IDS.forEach((id, i) => {
31    if (s.ingredients[id]) bits |= 1 << i
32  })
33  const digits = `${DIGITS[(bits >> 5) & 31]}${DIGITS[bits & 31]}`
34  return `${LOOK_CODES[s.look]}-${digits}${checkOf(LOOK_CODES[s.look], digits)}`
35}
36
37export type Decoded = { ok: true; layer: SettingsLayer } | { ok: false; reason: string }
38
39export function decodeShareCode(raw: string): Decoded {
40  const code = raw.trim().toUpperCase()
41  const match = code.match(/^([A-Z]{2})-([0-9A-Z]{2})([0-9A-Z])$/)
42  if (!match) return { ok: false, reason: 'That is not a Claudinator code. Codes look like HL-43H.' }
43  const [, lookCode, digits, check] = match as unknown as [string, string, string, string]
44  const look = (Object.keys(LOOK_CODES) as LookId[]).find(id => LOOK_CODES[id] === lookCode)
45  if (!look || !LOOK_IDS.includes(look)) return { ok: false, reason: `No look has the code ${lookCode}.` }
46  if (checkOf(lookCode, digits) !== check) return { ok: false, reason: 'That code has a typo: its check character does not match.' }
47  const hi = DIGITS.indexOf(digits[0] ?? '')
48  const lo = DIGITS.indexOf(digits[1] ?? '')
49  if (hi < 0 || lo < 0) return { ok: false, reason: 'That code has a character Claudinator does not use.' }
50  const bits = (hi << 5) | lo
51  const ingredients = Object.fromEntries(INGREDIENT_IDS.map((id, i) => [id, (bits & (1 << i)) !== 0])) as Record<IngredientId, boolean>
52  return { ok: true, layer: setupLayer(look, ingredients) }
53}
54
src/engine/tool-facts.ts 204 lines
1import { lastLine, oneLine, plural, printable } from './format'
2
3export type Glyph = 'read' | 'search' | 'edit' | 'create' | 'run' | 'web' | 'agent' | 'plan' | 'other'
4
5/** One single-width character per kind of step. */
6export const GLYPHS: Record<Glyph, string> = {
7  read: '◇',
8  search: '⌕',
9  edit: '◆',
10  create: '◆',
11  run: '›',
12  web: '◎',
13  agent: '✦',
14  plan: '≡',
15  other: '·',
16}
17
18export type ToolFacts = { glyph: Glyph; verb: string; target: string; isPath: boolean }
19export type DiffLine = { kind: '+' | '-'; text: string }
20export type Change = { file: string; add: number; del: number; lines: DiffLine[] }
21
22const MAX_TARGET = 200
23
24function str(value: unknown): string {
25  return typeof value === 'string' ? value : ''
26}
27
28function rec(value: unknown): Record<string, unknown> {
29  return value !== null && typeof value === 'object' ? (value as Record<string, unknown>) : {}
30}
31
32function shortName(tool: string): string {
33  const parts = tool.split('__')
34  return parts[parts.length - 1] || tool
35}
36
37/** What a call is, from its tool name and input. Safe on partial input. */
38export function factsOf(tool: string, input: unknown): ToolFacts {
39  const i = rec(input)
40  const path = (target: string, glyph: Glyph, verb: string): ToolFacts => ({ glyph, verb, target: printable(target, MAX_TARGET * 2), isPath: true })
41  const text = (target: string, glyph: Glyph, verb: string): ToolFacts => ({ glyph, verb, target: oneLine(target, MAX_TARGET), isPath: false })
42  switch (tool) {
43    case 'Read':
44      return path(str(i.file_path), 'read', 'Read')
45    case 'Edit':
46    case 'MultiEdit':
47      return path(str(i.file_path), 'edit', 'Edit')
48    case 'Write':
49      return path(str(i.file_path), 'create', 'Write')
50    case 'NotebookEdit':
51      return path(str(i.notebook_path), 'edit', 'Edit')
52    case 'Bash':
53      return text(str(i.command), 'run', 'Run')
54    case 'Grep':
55      return text(str(i.pattern), 'search', 'Search')
56    case 'Glob':
57      return text(str(i.pattern), 'search', 'Find')
58    case 'WebFetch':
59      return text(str(i.url), 'web', 'Fetch')
60    case 'WebSearch':
61      return text(str(i.query), 'web', 'Search')
62    case 'Task':
63    case 'Agent':
64      return text(str(i.description), 'agent', 'Agent')
65    case 'TodoWrite': {
66      const todos = Array.isArray(i.todos) ? i.todos.map(rec) : []
67      const active = todos.find(t => t.status === 'in_progress') ?? todos[0]
68      return text(str(active?.content), 'plan', 'Todos')
69    }
70    case 'TaskCreate':
71      return text(str(i.subject), 'plan', 'Plan')
72    case 'TaskUpdate':
73      return text(`${str(i.status)} #${typeof i.taskId === 'number' ? String(i.taskId) : str(i.taskId)}`.trim(), 'plan', 'Task')
74    case 'TaskList':
75    case 'TaskGet':
76      return text('', 'plan', 'Tasks')
77    case 'ToolSearch':
78      return text(str(i.query), 'other', 'Load')
79    case 'ExitPlanMode':
80      return text('', 'other', 'Plan')
81    default: {
82      const first = Object.values(i).find(v => typeof v === 'string')
83      return text(str(first), 'other', printable(shortName(tool), MAX_TARGET))
84    }
85  }
86}
87
88/** Tools whose rows Quiet may hide: reads, searches, commands and fetches. Agents and other tools stay visible. */
89export function isQuietable(tool: string): boolean {
90  const glyph = factsOf(tool, {}).glyph
91  return glyph === 'read' || glyph === 'search' || glyph === 'run' || glyph === 'web'
92}
93
94/** Tools whose calls Footnotes may fold into notes: reads, searches and fetches. Commands keep their rows. */
95export function isFootnotable(tool: string): boolean {
96  const glyph = factsOf(tool, {}).glyph
97  return glyph === 'read' || glyph === 'search' || glyph === 'web'
98}
99
100/** Tools whose calls change files. */
101export function isChangeTool(tool: string): boolean {
102  return tool === 'Edit' || tool === 'MultiEdit' || tool === 'Write' || tool === 'NotebookEdit'
103}
104
105/** The lines that differ between two texts, after their shared first and last lines. */
106export function lineDelta(before: string, after: string): Omit<Change, 'file'> {
107  const a = before === '' ? [] : before.split('\n')
108  const b = after === '' ? [] : after.split('\n')
109  let start = 0
110  while (start < a.length && start < b.length && a[start] === b[start]) start++
111  let endA = a.length
112  let endB = b.length
113  while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {
114    endA--
115    endB--
116  }
117  const removed = a.slice(start, endA)
118  const added = b.slice(start, endB)
119  return {
120    add: added.length,
121    del: removed.length,
122    lines: [...removed.map(text => ({ kind: '-' as const, text })), ...added.map(text => ({ kind: '+' as const, text }))],
123  }
124}
125
126/** The change an Edit, MultiEdit or Write call makes, or null for other tools. */
127export function changeOf(tool: string, input: unknown): Change | null {
128  const i = rec(input)
129  const file = printable(str(i.file_path), 1000)
130  if (tool === 'Edit') return { file, ...lineDelta(str(i.old_string), str(i.new_string)) }
131  if (tool === 'Write') return { file, ...lineDelta('', str(i.content)) }
132  if (tool === 'MultiEdit') {
133    const edits = Array.isArray(i.edits) ? i.edits : []
134    const total: Change = { file, add: 0, del: 0, lines: [] }
135    for (const edit of edits) {
136      const d = lineDelta(str(rec(edit).old_string), str(rec(edit).new_string))
137      total.add += d.add
138      total.del += d.del
139      total.lines.push(...d.lines)
140    }
141    return total
142  }
143  return null
144}
145
146/** Up to `max` non-blank changed lines, in order. */
147export function pickDiffLines(lines: DiffLine[], max: number): DiffLine[] {
148  return lines.filter(l => l.text.trim() !== '').slice(0, max)
149}
150
151const PHRASES: Record<Glyph, [string, string]> = {
152  read: ['read', 'file'],
153  search: ['searched', 'pattern'],
154  run: ['ran', 'command'],
155  web: ['fetched', 'page'],
156  agent: ['started', 'agent'],
157  edit: ['edited', 'file'],
158  create: ['wrote', 'file'],
159  plan: ['updated', 'task'],
160  other: ['used', 'tool'],
161}
162
163/** `Read 2 files, ran 1 command`: a folded group in one phrase. */
164export function groupSummary(calls: ReadonlyArray<{ tool: string; input: unknown }>): string {
165  const order: Glyph[] = []
166  const byGlyph = new Map<Glyph, Array<{ tool: string; input: unknown }>>()
167  for (const call of calls) {
168    const glyph = factsOf(call.tool, call.input).glyph
169    if (!byGlyph.has(glyph)) order.push(glyph)
170    byGlyph.set(glyph, [...(byGlyph.get(glyph) ?? []), call])
171  }
172  const text = order
173    .map(g => {
174      const group = byGlyph.get(g) ?? []
175      return g === 'other' || g === 'plan' ? otherPhrase(group) : `${PHRASES[g][0]} ${plural(group.length, PHRASES[g][1])}`
176    })
177    .join(', ')
178  return text.charAt(0).toUpperCase() + text.slice(1)
179}
180
181const TASK_TOOLS = new Set(['TaskCreate', 'TaskUpdate', 'TaskList', 'TaskGet', 'TodoWrite'])
182
183/** Calls with no kind of their own, named by what they did when they share a purpose. */
184export function otherPhrase(calls: ReadonlyArray<{ tool: string; input: unknown }>): string {
185  const n = calls.length
186  if (n > 0 && calls.every(c => c.tool === 'TaskCreate')) return `planned ${plural(n, 'task')}`
187  if (n > 0 && calls.every(c => TASK_TOOLS.has(c.tool))) return 'updated the task list'
188  if (n > 0 && calls.every(c => c.tool === 'ToolSearch')) return n === 1 ? 'loaded a tool' : `loaded ${plural(n, 'tool')}`
189  return `used ${plural(n, 'tool')}`
190}
191
192/** The last meaningful line a Bash call printed, or empty. */
193export function bashSummary(output: unknown): string {
194  const o = rec(output)
195  return lastLine(str(o.stdout), 160) || lastLine(str(o.stderr), 160)
196}
197
198/** The first line of an error result. */
199export function errorSummary(output: unknown): string {
200  if (typeof output === 'string') return oneLine(output, 160)
201  const o = rec(output)
202  return oneLine(str(o.stderr) || str(o.error) || str(o.message), 160)
203}
204
src/looks/index.ts 20 lines
1import type { LookId } from '../engine/settings'
2import { BLUEPRINT } from './blueprint'
3import { BROADSHEET } from './broadsheet'
4import { HAIRLINE } from './hairline'
5import { MISSION } from './mission'
6import { PRISM } from './prism'
7import { SUMI } from './sumi'
8import type { Look } from './look'
9
10/** Every look by id; null means Claudinator draws nothing and Claude Code draws its own. */
11export const LOOKS: Record<LookId, Look | null> = {
12  hairline: HAIRLINE,
13  broadsheet: BROADSHEET,
14  mission: MISSION,
15  prism: PRISM,
16  sumi: SUMI,
17  blueprint: BLUEPRINT,
18  off: null,
19}
20
src/looks/common.ts 194 lines
1import type { RenderElement } from 'claude-code'
2
3import { clockLabel, formatDuration, splitPath } from '../engine/format'
4import { tone } from '../engine/palette'
5import type { Progress, TurnStats } from '../engine/session-model'
6import type { Cell } from '../engine/raster'
7import { factsOf } from '../engine/tool-facts'
8import type { Ctx, FootnoteData, TimeStripData, WaitingData } from './look'
9
10const SUPERSCRIPTS = ['⁰', '¹', '²', '³', '⁴', '⁵', '⁶', '⁷', '⁸', '⁹']
11
12/** `12` → `¹²`. */
13/** A reply with its footnote marks: after the last word, or on a paragraph of their own after a code block, table or list. */
14export function withMarks(text: string, marks: number[]): string {
15  if (marks.length === 0) return text
16  const sup = marks.map(superscript).join('')
17  const last = text.trimEnd().split('\n').pop()?.trim() ?? ''
18  const isBlock = /^(```|~~~|\||[-*+] |\d+[.)] )/.test(last)
19  return isBlock ? `${text.trimEnd()}\n\n${sup}` : `${text} ${sup}`
20}
21
22export function superscript(n: number): string {
23  return String(Math.max(0, Math.floor(n)))
24    .split('')
25    .map(d => SUPERSCRIPTS[Number(d)] ?? '')
26    .join('')
27}
28
29/** A footnote as a short sentence: `Read src/a.ts · 0.2s`. */
30export function noteText(note: FootnoteData, cwd: string): string {
31  const facts = factsOf(note.tool, note.input)
32  const target = facts.isPath ? (({ dir, base }) => dir + base)(splitPath(facts.target, cwd)) : facts.target
33  const head = `${facts.verb} ${target}`.trim()
34  return note.durationMs === undefined ? head : `${head} · ${formatDuration(note.durationMs)}`
35}
36
37/** How many cells of a strip `width` wide each part of the turn gets. Nonzero parts get at least one. */
38export function timeStripSegments(data: TimeStripData, width: number): { thinking: number; tools: number; waiting: number } {
39  const total = data.thinkingMs + data.toolsMs + data.waitingMs
40  if (total <= 0) return { thinking: width, tools: 0, waiting: 0 }
41  const share = (ms: number): number => (ms > 0 ? Math.max(1, Math.round((ms / total) * width)) : 0)
42  const tools = share(data.toolsMs)
43  const waiting = share(data.waitingMs)
44  return { thinking: Math.max(0, width - tools - waiting), tools, waiting }
45}
46
47export type StripColors = { thinking: string; tools: string; waiting: string }
48
49/** Each look's strip colors, dark and light: three that never blur together. */
50const STRIP_COLORS: Record<'hairline' | 'broadsheet' | 'mission' | 'prism' | 'sumi' | 'blueprint', { dark: StripColors; light: StripColors }> = {
51  hairline: { dark: { thinking: '#a0a3ff', tools: '#6fd0d6', waiting: '#ff7a85' }, light: { thinking: '#5559de', tools: '#137f86', waiting: '#cf3747' } },
52  broadsheet: { dark: { thinking: '#e3b65c', tools: '#8fbfb4', waiting: '#ef8b73' }, light: { thinking: '#94680f', tools: '#2f6f68', waiting: '#b2432c' } },
53  mission: { dark: { thinking: '#6fd6c3', tools: '#ffb347', waiting: '#ff6b57' }, light: { thinking: '#11796a', tools: '#b26a00', waiting: '#c23a28' } },
54  prism: { dark: { thinking: '#ff8ab8', tools: '#b9a8ff', waiting: '#ffc27a' }, light: { thinking: '#d63f79', tools: '#5b4bff', waiting: '#b5651d' } },
55  sumi: { dark: { thinking: '#8aa4e0', tools: '#a9b8a0', waiting: '#e0907a' }, light: { thinking: '#2d4f9e', tools: '#56704a', waiting: '#b2432c' } },
56  blueprint: { dark: { thinking: '#7fd4ff', tools: '#e6f0ff', waiting: '#ff8a7a' }, light: { thinking: '#0e7fa8', tools: '#0f2d5c', waiting: '#c2334a' } },
57}
58
59export function stripColors(look: keyof typeof STRIP_COLORS, isDark: boolean): StripColors {
60  return STRIP_COLORS[look][isDark ? 'dark' : 'light']
61}
62
63/** `1:18` from a minute up, `0.6s` under a second. */
64function legendTime(ms: number): string {
65  return ms < 1000 ? formatDuration(ms) : clockLabel(ms)
66}
67
68/**
69 * The line under each run: where its time went, each part in its look's color,
70 * and how far through its todo list Claude got when it kept one.
71 */
72export function runStatsLine(ctx: Ctx, data: TimeStripData, style: StripColors & { indent?: number }): RenderElement {
73  const { Box, Text } = ctx.els
74  const part = (color: string, label: string, ms: number): RenderElement[] => [
75    Text({ color: tone(color, ctx.fade), children: '■' }),
76    Text({ color: tone('inactive', ctx.fade), children: `${label} ${legendTime(ms)}` }),
77  ]
78  const children: RenderElement[] = [...part(style.thinking, 'thinking', data.thinkingMs), Text({ children: ' ' }), ...part(style.tools, 'tools', data.toolsMs)]
79  if (data.waitingMs > 0) children.push(Text({ children: ' ' }), ...part(style.waiting, 'waiting on you', data.waitingMs))
80  if (data.tasks) children.push(Text({ color: tone('subtle', ctx.fade), children: '·' }), Text({ color: tone('inactive', ctx.fade), children: `${data.tasks.done} of ${data.tasks.total} tasks` }))
81  return Box({ flexDirection: 'row', columnGap: 1, paddingLeft: style.indent ?? 3, height: 1, overflow: 'hidden', children })
82}
83
84
85/** The notes block a look puts above its receipt: `¹ Read src/a.ts · 0.2s`. */
86export function notesBlock(ctx: Ctx, notes: FootnoteData[], style: { indent?: number; accent?: string; italic?: boolean } = {}): RenderElement | null {
87  if (notes.length === 0) return null
88  const { Box, Text } = ctx.els
89  return Box({
90    flexDirection: 'column',
91    paddingLeft: style.indent ?? 3,
92    children: notes.map(note =>
93      Box({
94        flexDirection: 'row',
95        columnGap: 1,
96        children: [
97          Text({ color: tone(style.accent ?? 'suggestion', ctx.fade), children: superscript(note.n) }),
98          Text({ color: tone('inactive', ctx.fade), italic: style.italic === true, wrap: 'truncate-end', children: noteText(note, ctx.cwd) }),
99        ],
100      }),
101    ),
102  })
103}
104
105export const INATOR_WORDS = ['Scheming', 'Monologuing', 'Inator-ing', 'Plotting', 'Calibrating the -inator']
106
107/** A live-line word for -inator mode, changing every few seconds. */
108export function inatorWord(frame: number): string {
109  return INATOR_WORDS[Math.floor(frame / 30) % INATOR_WORDS.length] ?? 'Scheming'
110}
111
112/** A closing quip for a receipt in -inator mode. */
113export function inatorQuip(stats: TurnStats | null): string {
114  if (!stats || stats.files.length === 0) return 'the Thinkinator rests'
115  if (stats.del > stats.add) return 'the Deletinator strikes again'
116  return 'another -inator completed'
117}
118
119/** The attention ladder's first step: a calm line in the band while Claude waits for you. */
120export function waitingBand(ctx: Ctx, waiting: WaitingData): RenderElement {
121  const { Box, Text } = ctx.els
122  const facts = factsOf(waiting.tool, waiting.input)
123  const { dir, base } = facts.isPath ? splitPath(facts.target, ctx.cwd) : { dir: '', base: facts.target }
124  const what = `${facts.verb} ${dir}${base}`.trim()
125  return Box({
126    flexDirection: 'row',
127    columnGap: 1,
128    children: [
129      Text({ color: 'warning', bold: true, children: '●' }),
130      Text({ bold: true, children: ctx.settings.ingredients.inator ? 'Your move, evil genius' : 'Needs you' }),
131      Text({ color: 'inactive', wrap: 'truncate-end', children: `· approve ${what}` }),
132      Text({ color: 'inactive', children: `· ${formatDuration(waiting.waitedMs)}` }),
133    ],
134  })
135}
136
137/** What the working bar shows: the turn's time so far, and Claude's task list when it keeps one. */
138export type WorkData = { thinkingMs: number; toolsMs: number; waitingMs: number; progress: Progress | null }
139
140/** The bar's words: `2 of 4 · Fixing total`, or `0:42 · thinking 0:30 · tools 0:12`. */
141export function workingBarText(work: WorkData): string {
142  if (work.progress) return [`${work.progress.done} of ${work.progress.total}`, work.progress.active].filter(Boolean).join(' · ')
143  // The clock is already at the end of the live line above; the bar says where the time went.
144  const parts = [`thinking ${legendTime(work.thinkingMs)}`, `tools ${legendTime(work.toolsMs)}`]
145  if (work.waitingMs > 0) parts.push(`waiting on you ${legendTime(work.waitingMs)}`)
146  return parts.join(' · ')
147}
148
149const hexToInt = (hex: string): number => (/^#[0-9a-f]{6}$/i.test(hex) ? parseInt(hex.slice(1), 16) : 0x8a8a8a)
150const TEXT = 0x01000000
151const BAR_FAINT = { dark: 0x4a4d5a, light: 0xc9ccd6 }
152const BAR_DIM = 0x8a8a8a
153
154/**
155 * The working bar as one row of cells, `width` wide, for the host to blit ten
156 * times a second. With a task list: a block per task (done, the one in hand
157 * pulsing, then those to come). Without: the turn so far split into thinking,
158 * tools and waiting, with a highlight sweeping across. Then the words.
159 */
160export function workingBarCells(work: WorkData, colors: StripColors, isDark: boolean, width: number, frame: number): Cell[] {
161  const faint = isDark ? BAR_FAINT.dark : BAR_FAINT.light
162  const barWidth = Math.max(8, Math.min(32, width - 34))
163  const bar: Cell[] = []
164  if (work.progress && work.progress.total > 0) {
165    const total = work.progress.total
166    const per = Math.max(1, Math.min(6, Math.floor((barWidth - (total - 1)) / total)))
167    const pulse = frame % 10 < 6
168    for (let i = 0; i < total && bar.length < barWidth; i++) {
169      if (i > 0) bar.push({ char: ' ', fg: faint })
170      const isDone = i < work.progress.done
171      const isActive = i === work.progress.done
172      const fg = isDone ? hexToInt(colors.thinking) : isActive ? (pulse ? hexToInt(colors.tools) : faint) : faint
173      for (let c = 0; c < per; c++) bar.push({ char: isDone || isActive ? '■' : '□', fg })
174    }
175  } else {
176    const seg = timeStripSegments(work, barWidth)
177    const fills = [
178      ...Array<number>(seg.thinking).fill(hexToInt(colors.thinking)),
179      ...Array<number>(seg.tools).fill(hexToInt(colors.tools)),
180      ...Array<number>(seg.waiting).fill(hexToInt(colors.waiting)),
181    ]
182    const sweep = frame % (barWidth + 8)
183    fills.forEach((fg, i) => bar.push({ char: '■', fg: Math.abs(i - sweep) <= 1 ? TEXT : fg }))
184  }
185  const words = `  ${workingBarText(work)}`
186  const cells: Cell[] = [...bar]
187  for (const ch of words) {
188    if (cells.length >= width) break
189    cells.push({ char: ch, fg: ch === '·' ? faint : BAR_DIM })
190  }
191  while (cells.length < width) cells.push({ char: ' ', fg: BAR_DIM })
192  return cells.slice(0, width)
193}
194