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…

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.

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.
/claudinator and press 1 to 6. Your whole session redraws in each look./look storyteller: Broadsheet with headlines and footnotes, so Claude's prose reads cleanly and the tool calls sit in numbered notes./chapters to see every turn by its headline, and press Enter on one to jump to it./look share and send the code. Your friend types /look use and the code.| Look | What it is | |
|---|---|---|
| 1 | Hairline | Swiss-quiet: an icon column, aligned metadata, color only where something changed. |
| 2 | Broadsheet | The session reads like a story: sentences for tool calls, chapter headlines, a typewriter while Claude thinks. |
| 3 | Mission Control | A precision instrument: a timestamped rail, live telemetry above the prompt, readings on every turn. |
| 4 | Prism | Premium color: every file its own color, tool chips, and a fingerprint ribbon closing each turn. |
| 5 | Sumi | Ink and negative space: reads become a quiet trace, only edits get a mark. |
| 6 | Blueprint | Every 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 work in any look. Each look turns on its own favorites, and your choices always win.
| Key | Ingredient | What it does |
|---|---|---|
r | Recency fade | Older turns dim so the newest line stands out. |
d | Mini diffs | Edits show their key changed lines under the row. |
f | File colors | Each file keeps one color everywhere. |
q | Quiet | Hides reads, searches and commands, leaving a one-line trace. |
h | Headlines | Each finished turn gets a title above its prompt. |
n | Footnotes | Tool calls become numbered notes on Claude's prose. |
t | Time strip | Each run's time under its receipt: thinking, tools and waiting on you. (The working bar above the prompt is always on.) |
a | Attention ladder | When Claude waits on your permission: a toast at 30 seconds, then an optional sound and desktop notification at 2 minutes. |
i | -inator mode | A layer of personality. Curse you, flaky tests. |
See docs/ingredients.md for a GIF of each.
| Command | What it does |
|---|---|
/claudinator | Open 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. |
/chapters | Every turn by its headline; press Enter to jump to it. |
/ledger | Every 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.
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.
/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.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.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.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.
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.
hooks/register.ts 558 lines1// 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}
558src/engine/attention.ts 52 lines1import { 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, '&').replace(/</g, '<').replace(/>/g, '>')],
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}
52src/engine/combos.ts 23 lines1import { 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}
23src/engine/format.ts 73 lines1/** 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}
73src/engine/palette.ts 40 lines1/** 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
40src/engine/session-model.ts 536 lines1import { 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}
536src/engine/raster.ts 34 lines1import { 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}
34src/engine/settings.ts 191 lines1import 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}
191src/engine/share-code.ts 54 lines1import { 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}
54src/engine/tool-facts.ts 204 lines1import { 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}
204src/looks/index.ts 20 lines1import 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}
20src/looks/common.ts 194 lines1import 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