A sci-fi HUD pane showing what Claude is doing right now, in plain English, with your steering fixes flagged in red

See what your coding agent is doing, live, in a small local browser tab.
visualizar reads Claude Code's own session transcript, so there's no wrapper and no setup inside Claude Code. It shows the step Claude is on right now, big and in the middle, and the steps around it, at the level of detail you pick:
| Level | Example |
|---|---|
| Simple | Building the top card |
| Vibe | Changed src/components/TopCard.tsx — swapped 3 lines for 5 |
| Engineer | Edit src/components/TopCard.tsx with the diff, or $ npm test with its output |
Fixes you send with inject show up exactly where they landed, and a Mistakes panel keeps a list of them per project.
npm install -g .
visualizar
http://127.0.0.1:4777. Keep it beside your terminal. It follows the newest Claude Code session in that folder and switches to a new one when you start it.Switch at any time from the top bar. Your choices are remembered.
The NOW strip at the top always says what's happening: the tool running and for how long, "Thinking…", or "Done — waiting for you".
Themes: Claude (warm) and Codex (cool). Both use the same HUD look.
~/.claude/projects/<project>/<session-id>.jsonl, where <project> is the working directory with every non-alphanumeric character replaced by - (docs). CLAUDE_CONFIG_DIR is respected. The file is read incrementally, a few times a second, so updates appear within about a third of a second.<project>/.steer/fixes.jsonl. Each fix is placed at the time it was delivered (or queued, if it never was), and a tool call that an urgent fix blocked is marked stopped by your fix.Read-only. visualizar never writes to transcripts or to fixes.jsonl. The only thing it writes is your edits from the Mistakes panel, to <project>/.steer/mistakes.json, keyed by the original fix. Reword a fix, add a note about what went wrong, or hide it; the original line stays in fixes.jsonl.
The translation is rule-based. It knows Claude Code's built-in tools (Read, Write, Edit, Bash, Grep, Glob, WebFetch, WebSearch, Task/Agent, TodoWrite, and more) and classifies common shell commands (tests, installs, builds, git). Anything else, such as an MCP tool, gets a generic description like "Using linear: create issue".
To describe those with a cheap model instead:
visualizar --ai
This uses claude-haiku-5-5 with your own Anthropic credentials (ANTHROPIC_API_KEY or ant auth login). Only the tool's name and a clipped copy of its input are sent, never its output or your files. Results are cached for the run. Without --ai, or without credentials, nothing is sent anywhere and the generic text stays.
visualizar [--session <id>] [--port <n>] [--no-open] [--ai]
| Option | |
|---|---|
--session <id> | Follow one session (an id prefix is enough) instead of the newest in this folder. You can also pick any recent session from the menu in the page. |
--port <n> | Port to use (default 4777; the next free one is taken if busy). |
--no-open | Don't open a browser tab; just print the URL. |
--ai | AI descriptions for unusual steps (above). |
The same idea as a Claude Code mod: a sci-fi HUD pane drawn inside Claude Code itself, in the terminal, the Desktop app and VS Code. It lives in mod/ and needs no server or browser tab.
claude --plugin-dir ./mod
Then type /visualizar (it also works mid-turn). The pane is a sidebar beside the transcript in a wide window, or a framed region above the prompt otherwise.
The pane is built from Claude Code's own interface elements, so it fills the pane, updates in place, and looks native in the Desktop app, VS Code and the terminal. It shows a NOW panel with the current step in large type and a running clock, the current step as a big bordered card with the steps before and after it beside it (stacked when the pane is narrow), and a coloured track of the whole session. At Engineer level each card is a quick technical summary instead of raw output: the call ($ npm test, Edit src/TopCard.tsx), chips such as exit ok, 1.2s, +5 −3 lines or 12 matches, and the few lines worth seeing (the changed lines in +/−, a test run's pass/fail line, or the error). In the Cards view, drag the cards with the mouse, click a side card to bring it to the centre, or scroll with the wheel or trackpad to step through the session (VS Code has no drag; use ◀ / ▶ there). ◀ / ▶ also step back and forward; Back to live follows Claude again. Flow and Timeline are long lists that scroll normally. When the pane opens, it loads the session's earlier steps from the conversation (shown as "earlier", since the conversation has no timestamps), and live steps get real times and durations.
It also shows your prompts as cards and this session's fixes from <project>/.steer/fixes.jsonl where they landed. Layouts match the browser tool: Cards (the default), Flow and Timeline (the current turn on lanes). Keys while the pane has focus: 1 Cards, 2 Flow, 3 Timeline, s Simple, v Vibe, e Engineer, c Claude theme, x Codex theme, b / n step back and forward, l back to live, Esc returns to the prompt. (VS Code buttons have no hotkeys; click them.) Run /visualizar again to close.
It is fed by Claude Code's mod events (turn.start, tool.call), not by parsing the transcript, and it is read-only: it never writes to transcripts or fixes.jsonl. The phrases come from src/translate.js; the mod runs in a sandbox with no Node, so mod/hooks/translate.js is generated from it. After changing src/translate.js, run node mod/build.mjs (--check verifies it is current).
Install it from this repository instead of a local folder (terminal):
/plugin install visualizar --marketplace sk82santhosh-cpu/Visualizar
From a shell: claude plugin install visualizar --marketplace aryan-sk6/Visualizar. In the Desktop app, click + next to the prompt box, then Plugins, Add marketplace with aryan-sk6/Visualizar, then Add plugin. In VS Code, the terminal command works from Claude Code's prompt.
Check it with claude plugin validate ./mod and claude plugin test ./mod. Claude Code asks you to trust the folder before it loads a mod's hooks.
127.0.0.1 or localhost (against DNS rebinding), and requires a per-run token for the one write endpoint.npm install
npm test
No build step and no runtime dependencies. @anthropic-ai/sdk is optional and only loaded with --ai.
MIT
hooks/register.js 490 lines1// visualizar, inside Claude Code: a HUD pane showing what the agent is doing.
2// Read-only: it listens to events and reads .steer/fixes.jsonl, and writes nothing.
3//
4// The pane is built from Claude Code's own elements (boxes, borders, text
5// sizes, buttons, code), so it fills the pane, updates in place and looks
6// native in the Desktop app, VS Code and the terminal alike.
7import {
8 LAYOUT_NAMES, LEVEL_NAMES, THEME_NAMES, PALETTES, catColor, startStep, pushStep, finishStep, viewOf, textFor,
9 elapsed, stepTime, parseFixes, MAX_STEPS,
10} from './hud.js'
11import { layoutDeck } from './carousel.js'
12import { engSummary, slimResult } from './eng.js'
13
14const PANE = 'visualizar'
15
16// Module variables: a hot reload resets them, which only costs the step history.
17let steps = []
18let level = 'simple'
19let layout = 'cards'
20let theme = 'claude'
21let fixes = []
22let cwd = ''
23let sessionId = null
24let turnStart = null
25let lastActivity = null
26let busy = false
27let isOpen = false
28let tick = 0
29// Which step the cards view centres on: null follows the live step.
30let pinned = null
31// Whether the session's earlier steps have been loaded into the pane.
32let backfilled = false
33let lastWheel = 0
34
35const CAT_LABEL = {
36 you: 'YOU', say: 'CLAUDE', read: 'READ', write: 'WRITE', run: 'RUN', test: 'TEST', search: 'SEARCH',
37 web: 'WEB', plan: 'PLAN', agent: 'HELPER', ask: 'QUESTION', tool: 'TOOL', fix: 'YOUR FIX',
38}
39// Terminal stand-ins for the icons.
40const GLYPH = {
41 user: '●', slash: '/', chat: '❝', read: '◉', create: '✚', edit: '✎', terminal: '❯', test: '⚗', package: '⬡',
42 git: '⎇', globe: '◍', search: '⌕', folder: '▤', agent: '◈', list: '☰', question: '?', spark: '✦', plug: '⏻',
43 flag: '⚑', think: '◌', pause: '◼',
44}
45
46function redraw($) {
47 $.ui.invalidate('ui.render')
48}
49
50// Loads the steps from before the pane opened (or before a reload) from the
51// conversation. They have no timestamps, so they sort before every live step
52// and show as "earlier".
53async function backfill($) {
54 backfilled = true // once per load, even if reading fails
55 let msgs = []
56 try {
57 msgs = await $.session.messages()
58 } catch {
59 return
60 }
61 const have = new Set(steps.map((s) => s.id))
62 const hist = []
63 msgs.forEach((m, k) => {
64 if (m.role === 'user') {
65 const text = String(m.text || '').trim()
66 if (text && !text.startsWith('<') && !(m.toolResults && m.toolResults.length)) {
67 hist.push({ id: 'h' + k, kind: 'prompt', text: text.slice(0, 500), status: 'done', startedAt: k, endedAt: k, historic: true })
68 }
69 return
70 }
71 for (const u of m.toolUses || []) {
72 if (have.has(u.tool_use_id) || u.text === undefined) continue // in flight: live events cover it
73 hist.push({ id: u.tool_use_id, kind: 'tool', name: String(u.tool), input: u.input || {}, status: u.isError ? 'error' : 'done', startedAt: k, endedAt: k, side: false, historic: true, result: { output: String(u.text || '').slice(-800), ...(u.isError ? { isError: true } : {}) } })
74 }
75 })
76 steps = [...hist, ...steps].slice(-MAX_STEPS)
77}
78
79async function loadFixes($) {
80 if (!cwd) return
81 try {
82 fixes = parseFixes(await $.fs.read(cwd + '/.steer/fixes.jsonl'))
83 } catch {
84 fixes = []
85 }
86}
87
88const clockText = (ms) => {
89 const s = Math.max(0, Math.floor(ms / 1000))
90 const m = String(Math.floor(s / 60) % 60).padStart(2, '0')
91 const h = Math.floor(s / 3600)
92 return `T+${h ? h + ':' : ''}${m}:${String(s % 60).padStart(2, '0')}`
93}
94const hhmmss = (ms) => {
95 const d = new Date(ms)
96 return [d.getHours(), d.getMinutes(), d.getSeconds()].map((n) => String(n).padStart(2, '0')).join(':')
97}
98
99/** Steps plus this session's fixes, in time order, each with its view. */
100function sequence() {
101 // Fixes have real times; the backfilled steps don't, so fixes are placed
102 // only among the live steps.
103 const first = steps.find((s) => !s.historic)?.startedAt ?? Infinity
104 const mine = fixes
105 .filter((f) => f.at !== null && (f.sessionId ? f.sessionId === sessionId : f.at >= first))
106 .map((f, i) => ({
107 id: 'fix' + i, kind: 'fix', text: f.text, urgent: f.urgent, startedAt: f.at, deliveredAt: f.deliveredAt,
108 view: {
109 icon: 'flag', cat: 'fix',
110 simple: `You steered: ${f.text}`,
111 vibe: `${f.urgent ? 'Urgent fix' : 'Fix'}: ${f.text}${f.doing ? ` (while it ${f.doing.replace(/^about to run/, 'was about to run')})` : ''}`,
112 eng: { title: `fix · ${f.urgent ? 'urgent' : 'normal'} · ${f.deliveredAt ? 'delivered' : 'logged'}` },
113 },
114 }))
115 const all = [...steps.map((s) => ({ ...s, view: viewOf(s, cwd) })), ...mine]
116 all.sort((a, b) => a.startedAt - b.startedAt)
117 for (const s of all) s.time = s.historic ? 'earlier' : hhmmss(s.startedAt)
118 return all
119}
120
121/** A step's line at the current level; Engineer uses the technical summary. */
122function say(s) {
123 return level === 'eng' ? engSummary(s, cwd).head : textFor(s.view, level)
124}
125
126/** What the NOW strip says. */
127function nowState(seq, now) {
128 const running = [...seq].reverse().find((s) => s.kind === 'tool' && s.status === 'running' && !s.side)
129 if (running) {
130 return {
131 live: true, nowIcon: running.view.icon, nowLabel: `NOW · ${CAT_LABEL[running.view.cat] || 'TOOL'}`,
132 nowText: say(running) + '…', clock: clockText(now - running.startedAt), cur: seq.indexOf(running),
133 }
134 }
135 const cur = seq.length - 1
136 if (busy) {
137 return { live: true, nowIcon: 'think', nowLabel: 'NOW', nowText: level === 'simple' ? 'Thinking…' : 'Thinking about the next step…', clock: clockText(now - (lastActivity ?? turnStart ?? now)), cur }
138 }
139 return { live: false, nowIcon: 'pause', nowLabel: 'IDLE', nowText: 'Done — waiting for you', clock: turnStart ? `${elapsed(now - turnStart)} ago` : '', cur }
140}
141
142export function register(on) {
143 on('session.start', async ($, e, next) => {
144 await $.command.register({
145 name: 'visualizar',
146 description: 'Open the live HUD pane of what Claude is doing',
147 immediate: true,
148 })
149 try {
150 cwd = await $.session.cwd()
151 } catch {
152 cwd = ''
153 }
154 try {
155 sessionId = await $.session.id()
156 } catch {
157 sessionId = null
158 }
159 // While the pane is open: tick the clock once a second during a turn,
160 // and pick up new fixes every few seconds. Nothing redraws when idle.
161 $.clock.every(1000, () => {
162 if (!isOpen) return
163 tick++
164 if (tick % 3 === 0) {
165 const before = fixes.length
166 void loadFixes($).then(() => {
167 if (busy || fixes.length !== before) redraw($)
168 })
169 } else if (busy) redraw($)
170 })
171 return next(e)
172 })
173
174 on('command.run', { command: 'visualizar' }, async ($) => {
175 if (isOpen) {
176 await $.ui.close({ id: PANE })
177 isOpen = false
178 return { text: 'visualizar closed.' }
179 }
180 await loadFixes($)
181 if (!backfilled) await backfill($)
182 const opened = await $.ui.open({ id: PANE, title: 'visualizar', focus: true })
183 isOpen = opened.isPlaced
184 return { text: opened.isPlaced ? 'visualizar open. 1/2/3 layout · s/v/e detail · c/x theme · b/n step · l live.' : 'visualizar is waiting for a wider window.' }
185 })
186
187 on('ui.close', async ($, e, next) => {
188 if (e.id === PANE) isOpen = false
189 return next(e)
190 }).catch(($, e, next) => next(e))
191
192 on('prompt.submit', async ($, e, next) => {
193 const text = typeof e.text === 'string' ? e.text.trim() : ''
194 if (text && !text.startsWith('/')) {
195 const now = await $.clock.now()
196 steps = pushStep(steps, { id: 'p' + now, kind: 'prompt', text, status: 'done', startedAt: now, endedAt: now })
197 redraw($)
198 }
199 return next(e)
200 }).catch(($, e, next) => next(e))
201
202 on('turn.start', async ($, e, next) => {
203 if (e.agentId === undefined) {
204 turnStart = await $.clock.now()
205 lastActivity = turnStart
206 busy = true
207 redraw($)
208 }
209 return next(e)
210 })
211
212 on('turn.complete', async ($, e, next) => {
213 if (e.agentId === undefined) {
214 busy = false
215 turnStart = await $.clock.now()
216 redraw($)
217 }
218 return next(e)
219 })
220
221 on('tool.call', async ($, e, next) => {
222 const step = startStep(e, await $.clock.now())
223 steps = pushStep(steps, step)
224 redraw($)
225 let ran
226 try {
227 ran = await next(e)
228 return ran
229 } finally {
230 lastActivity = await $.clock.now()
231 steps = finishStep(steps, step.id, { isError: Boolean(ran && (ran.deny || ran.isError)), now: lastActivity, result: slimResult(ran) })
232 redraw($)
233 }
234 }).catch(($, e, next) => next(e))
235
236 // The carousel centres the card you dragged to or clicked.
237 on('ui.message', async ($, e, next) => {
238 if (e.requestId !== PANE || e.element !== 'carousel' || typeof e.data?.select !== 'number') return next(e)
239 const n = sequence().length
240 pinned = e.data.select >= n - 1 ? null : Math.max(0, e.data.select)
241 redraw($)
242 return {}
243 }).catch(($, e, next) => next(e))
244
245 // In the Cards view, the wheel or trackpad steps through cards instead of
246 // scrolling the pane. One step per 120 ms, so a trackpad flick doesn't race.
247 on('ui.scroll', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
248 if (layout !== 'cards' || e.by === 0) return next(e)
249 const now = await $.clock.now()
250 if (now - lastWheel < 120) return { deny: 'visualizar: stepping through cards' }
251 lastWheel = now
252 const n = sequence().length
253 if (!n) return next(e)
254 const at = pinned ?? n - 1
255 const to = Math.max(0, Math.min(n - 1, at + Math.sign(e.by)))
256 pinned = to >= n - 1 ? null : to
257 redraw($)
258 return { deny: 'visualizar: stepping through cards' }
259 }).catch(($, e, next) => next(e))
260
261 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
262 const { Box, Text, Button, Code, Client } = $.ui.resolve(e)
263 // Being drawn means the pane is open, including after a reload.
264 isOpen = true
265 if (!backfilled) await backfill($)
266 const now = await $.clock.now()
267 const P = PALETTES[theme]
268 const cols = Math.max(24, e.props?.bodyColumns ?? 60)
269 const seq = sequence()
270 const st = nowState(seq, now)
271 const curIndex = pinned !== null && seq[pinned] ? pinned : st.cur
272 const follow = pinned === null || !seq[pinned]
273
274 const T = (children, props = {}) => Text({ ...props, children: [children] })
275 const label = (s, color = P.dim) => T(s, { color, size: 'caption', mono: true })
276
277 // ---- top bar: brand, live state, controls
278 const seg = (prefix, names, value, set, keys) =>
279 Box({
280 flexDirection: 'row',
281 columnGap: 1,
282 children: Object.keys(names).map((name, i) =>
283 Button({
284 key: prefix + name,
285 label: names[name],
286 hotkey: keys[i],
287 plain: true,
288 dimColor: value !== name,
289 onPress: () => {
290 set(name)
291 redraw($)
292 },
293 }),
294 ),
295 })
296 const top = Box({
297 flexDirection: 'row',
298 flexWrap: 'wrap',
299 justifyContent: 'space-between',
300 alignItems: 'center',
301 rowGap: 1,
302 columnGap: 2,
303 children: [
304 Box({ flexDirection: 'row', columnGap: 2, alignItems: 'center', children: [
305 T('◆ VISUALIZAR', { color: P.accent, weight: 'bold', mono: true }),
306 T(st.live ? ' ● LIVE ' : ' ○ IDLE ', { color: st.live ? P.bg : P.dim, backgroundColor: st.live ? P.accent : undefined, size: 'caption', mono: true, weight: 'semibold' }),
307 ] }),
308 Box({ flexDirection: 'row', flexWrap: 'wrap', columnGap: 3, children: [
309 seg('ly-', LAYOUT_NAMES, layout, (v) => (layout = v), ['1', '2', '3']),
310 seg('lv-', LEVEL_NAMES, level, (v) => (level = v), ['s', 'v', 'e']),
311 seg('th-', THEME_NAMES, theme, (v) => (theme = v), ['c', 'x']),
312 ] }),
313 ],
314 })
315
316 // ---- NOW panel
317 const nowPanel = Box({
318 flexDirection: 'row',
319 alignItems: 'center',
320 columnGap: 2,
321 paddingX: 2,
322 paddingY: 1,
323 borderStyle: 'round',
324 borderColor: st.live ? P.accent : P.line2,
325 backgroundColor: P.panel,
326 children: [
327 Box({ borderStyle: 'round', borderColor: st.live ? P.accent : P.line2, paddingX: 1, children: [T(GLYPH[st.nowIcon] || '◆', { color: st.live ? P.accent2 : P.dim, size: 'heading' })] }),
328 Box({ flexDirection: 'column', flexGrow: 1, flexShrink: 1, children: [
329 label(st.nowLabel),
330 T(st.nowText, { color: st.live ? P.text : P.dim, size: 'title', weight: 'bold', wrap: 'truncate-end' }),
331 ] }),
332 Box({ flexDirection: 'column', alignItems: 'flex-end', children: [
333 T(st.clock, { color: P.accent2, size: 'heading', mono: true, tabular: true }),
334 label(`${seq.length} steps`),
335 ] }),
336 ],
337 })
338
339 const statusOf = (s) => {
340 if (s.kind === 'fix') return [P.cats.fix, s.deliveredAt ? 'delivered' : 'logged']
341 if (s.kind !== 'tool') return [P.dim, s.time]
342 if (s.historic) return s.status === 'error' ? [P.error, 'snag'] : [P.ok, 'done']
343 const d = elapsed((s.endedAt ?? now) - s.startedAt)
344 if (s.status === 'running') return [P.accent, `working · ${d}`]
345 if (s.status === 'error') return [P.error, `snag · ${d}`]
346 return [P.ok, `done · ${d}`]
347 }
348 const kindLabel = (s) => (CAT_LABEL[s.view.cat] || 'STEP') + (s.side ? ' · HELPER' : '')
349
350 // One step as plain card data: what the carousel (a Client, which only
351 // takes plain data) and the static deck both draw.
352 const cardData = (s, i) => {
353 const color = catColor(P, s.view.cat)
354 const [statusColor, status] = statusOf(s)
355 const c = { title: say(s), kind: kindLabel(s), glyph: GLYPH[s.view.icon] || '◆', color, time: s.time, status, statusColor }
356 if (level === 'eng') {
357 const sum = engSummary(s, cwd)
358 c.mono = true
359 c.facts = sum.facts
360 if (i === curIndex) c.lines = sum.lines.slice(0, 7)
361 } else if (i === curIndex && level === 'vibe' && s.kind === 'tool') {
362 c.sub = s.view.simple
363 }
364 return c
365 }
366
367 // The same deck without dragging, for surfaces with no Client (VS Code).
368 const staticDeck = () => {
369 const { slots, sz } = layoutDeck(cols, curIndex, seq.length)
370 return Box({ flexDirection: 'row', columnGap: 2, justifyContent: 'center', alignItems: 'center', children: slots.map(({ i, d }) => {
371 const c = cardData(seq[i], i)
372 const big = d === 0
373 return Box({
374 key: 'sd-' + i, flexDirection: 'column', rowGap: big ? 1 : 0, width: sz[d][0], height: sz[d][1], flexShrink: 0, overflow: 'hidden',
375 paddingX: big ? 2 : 1, paddingY: big ? 1 : 0, borderStyle: big ? 'bold' : 'round', borderColor: big ? c.color : P.line2, backgroundColor: big ? P.panel2 : P.panel,
376 children: [
377 Box({ flexDirection: 'row', columnGap: 1, alignItems: 'center', children: [
378 Box({ borderStyle: 'round', borderColor: c.color, paddingX: 1, children: [T(c.glyph, { color: c.color, size: big ? 'heading' : 'body' })] }),
379 Box({ flexDirection: 'column', flexShrink: 1, children: [label(c.kind, c.color), label(c.time, P.faint)] }),
380 ] }),
381 T(c.title, { color: big ? P.text : P.dim, size: c.mono ? (big ? 'heading' : 'caption') : big ? 'title' : 'body', mono: Boolean(c.mono), weight: big ? 'bold' : 'medium', wrap: 'wrap' }),
382 ...(big && c.sub ? [T(c.sub, { color: P.dim, wrap: 'wrap' })] : []),
383 ...(c.facts && c.facts.length ? [Box({ flexDirection: 'row', flexWrap: 'wrap', columnGap: 1, children: c.facts.map((f, k) => T(` ${f} `, { key: 'f' + k, color: P.text, backgroundColor: P.line, size: 'caption', mono: true })) })] : []),
384 ...(big && c.lines ? c.lines.map((l, k) => T(l.t, { key: 'l' + k, color: l.k === 'add' ? P.ok : l.k === 'del' || l.k === 'err' ? P.error : P.dim, size: 'caption', mono: true, wrap: 'truncate-end' })) : []),
385 Box({ flexGrow: 1 }),
386 Box({ flexDirection: 'row', justifyContent: 'space-between', children: [
387 T(`● ${c.status}`, { color: c.statusColor, size: 'caption', wrap: 'truncate-end' }),
388 ...(big ? [label(`${i + 1} / ${seq.length}`, P.faint)] : []),
389 ] }),
390 ],
391 })
392 }) })
393 }
394
395 // ---- the main view
396 let main
397 if (!seq.length) {
398 main = Box({ paddingY: 2, justifyContent: 'center', children: [T('No steps yet. They appear here as Claude works.', { color: P.dim })] })
399 } else if (layout === 'flow') {
400 // The pane scrolls, so show a long stretch of the session.
401 const recent = seq.slice(Math.max(0, curIndex - 40), curIndex + 1)
402 const nodes = []
403 recent.forEach((s, i) => {
404 const isCur = i === recent.length - 1
405 if (i > 0) nodes.push(Box({ key: 'ar-' + s.id, paddingLeft: 2, children: [T('↓', { color: P.line2 })] }))
406 if (s.kind === 'fix') {
407 nodes.push(Box({ key: 'fx-' + s.id, marginLeft: 4, flexDirection: 'column', paddingX: 1, borderStyle: 'dashed', borderColor: P.cats.fix, children: [
408 label(s.urgent ? '⚑ URGENT FIX LANDED HERE' : '⚑ FIX LANDED HERE', P.cats.fix),
409 T(s.text, { color: P.text, wrap: 'wrap' }),
410 ] }))
411 return
412 }
413 const color = catColor(P, s.view.cat)
414 const [sc, sl] = statusOf(s)
415 nodes.push(Box({
416 key: 'nd-' + s.id, flexDirection: 'row', columnGap: 2, alignItems: 'center', paddingX: 1,
417 borderStyle: isCur ? 'bold' : 'round', borderColor: isCur ? color : P.line2, backgroundColor: s.kind === 'prompt' ? P.panel2 : P.panel,
418 children: [
419 T(GLYPH[s.view.icon] || '◆', { color }),
420 Box({ flexDirection: 'column', flexGrow: 1, flexShrink: 1, children: [
421 T(say(s), { color: isCur ? P.text : P.dim, weight: isCur ? 'semibold' : 'regular', wrap: 'truncate-end' }),
422 T(`● ${sl}`, { color: sc, size: 'caption' }),
423 ] }),
424 ],
425 }))
426 })
427 main = Box({ flexDirection: 'column', children: nodes })
428 } else if (layout === 'timeline') {
429 const lastPrompt = [...seq].reverse().findIndex((s) => s.kind === 'prompt')
430 const turn = lastPrompt >= 0 ? seq.slice(seq.length - 1 - lastPrompt) : seq.slice(-12)
431 const t0 = turn[0].startedAt
432 const span = Math.max(10_000, now - t0)
433 const barRoom = Math.max(8, Math.min(40, cols - 40))
434 main = Box({ flexDirection: 'column', rowGap: 0, children: turn.slice(-60).map((s) => {
435 const color = s.kind === 'fix' ? P.cats.fix : catColor(P, s.view.cat)
436 const start = Math.round(((s.startedAt - t0) / span) * barRoom)
437 const len = s.kind === 'tool' ? Math.max(1, Math.round((((s.endedAt ?? now) - s.startedAt) / span) * barRoom)) : 1
438 const mark = s.kind === 'fix' ? '⚑' : s.kind === 'prompt' ? '┃' : '█'.repeat(len)
439 return Box({ key: 'tl-' + s.id, flexDirection: 'row', columnGap: 1, children: [
440 label(stepTime(s, t0), P.faint),
441 T(' '.repeat(start) + mark + ' '.repeat(Math.max(0, barRoom - start - len)), { color, mono: true }),
442 T(say(s), { color: s === seq[curIndex] ? P.text : P.dim, wrap: 'truncate-end' }),
443 ] })
444 }) })
445 } else if (e.surface === 'terminal' || e.surface === 'desktop') {
446 // Drag the cards with the mouse; wheel over the pane steps them (ui.scroll).
447 main = Client({
448 key: 'carousel',
449 module: './carousel.js',
450 width: '100%',
451 props: { cards: seq.map(cardData), cur: curIndex, P },
452 })
453 } else {
454 main = staticDeck()
455 }
456
457 // ---- session track: one mark per step, fixes as flags
458 const shown = seq.slice(-Math.max(10, Math.min(80, cols - 4)))
459 const track = Box({ flexDirection: 'row', flexWrap: 'wrap', children: shown.map((s) => {
460 const isCur = s === seq[curIndex]
461 const ch = s.kind === 'fix' ? '⚑' : s.kind === 'prompt' ? '┃' : isCur ? '█' : '▮'
462 return T(ch, { key: 'tk-' + s.id, color: s.kind === 'fix' ? P.cats.fix : isCur ? catColor(P, s.view.cat) : P.line2, mono: true })
463 }) })
464
465 // ---- stepping through the session
466 const nav = Box({ flexDirection: 'row', columnGap: 2, alignItems: 'center', children: [
467 Button({ key: 'prev', label: '◀', hotkey: 'b', plain: true, dimColor: curIndex <= 0, onPress: () => { pinned = Math.max(0, curIndex - 1); redraw($) } }),
468 Button({ key: 'next', label: '▶', hotkey: 'n', plain: true, dimColor: follow, onPress: () => { pinned = curIndex + 1 >= seq.length - 1 ? null : curIndex + 1; redraw($) } }),
469 Button({ key: 'live', label: follow ? 'Following live' : 'Back to live', hotkey: 'l', plain: true, dimColor: follow, onPress: () => { pinned = null; redraw($) } }),
470 ] })
471
472 // Fill the pane's height, with the main view taking the room in between.
473 const rows = e.props?.scroll?.bodyRows
474 const body = Box({
475 flexDirection: 'column',
476 flexGrow: 1,
477 justifyContent: layout === 'cards' || !seq.length ? 'center' : 'flex-start',
478 children: [main],
479 })
480 return Box({
481 flexDirection: 'column',
482 rowGap: 1,
483 padding: 1,
484 backgroundColor: P.bg,
485 ...(rows ? { minHeight: rows } : {}),
486 children: [top, nowPanel, body, ...(seq.length ? [track, nav] : [])],
487 })
488 })
489}
490hooks/hud.js 116 lines1// Pure helpers for the HUD: step bookkeeping, text, and the scanline strip.
2// No engine calls here, so they can be tested on their own.
3import { translate } from './translate.js';
4
5export const LEVELS = ['simple', 'vibe', 'eng'];
6export const LEVEL_NAMES = { simple: 'Simple', vibe: 'Vibe', eng: 'Engineer' };
7export const LAYOUTS = ['cards', 'flow', 'timeline'];
8export const LAYOUT_NAMES = { cards: 'Cards', flow: 'Flow', timeline: 'Timeline' };
9export const MAX_STEPS = 150;
10export const THEMES = ['claude', 'codex'];
11export const THEME_NAMES = { claude: 'Claude', codex: 'Codex' };
12
13// Same palettes as the browser UI (public/styles.css). Solid hex only, so the
14// terminal can use them too.
15export const PALETTES = {
16 claude: {
17 bg: '#11100f', panel: '#1c1a17', panel2: '#241f1b', line: '#2b2925', line2: '#45413b',
18 text: '#ece9e0', dim: '#9a958a', faint: '#6b665d', accent: '#d97757', accent2: '#f0b48c',
19 grid: '#1a1612', ok: '#9ccb8a', error: '#ff7a6b',
20 cats: { you: '#f0b48c', say: '#ece9e0', read: '#8fb8de', write: '#e0875f', run: '#c7a0dc', test: '#9ccb8a', search: '#7fc4c0', web: '#86a9e8', plan: '#e0c26a', agent: '#b39ddb', ask: '#f5a3b5', tool: '#b8b2a6', fix: '#ff5f7e' },
21 },
22 codex: {
23 bg: '#090b0e', panel: '#10151b', panel2: '#151c24', line: '#1e252d', line2: '#36414c',
24 text: '#e6edf3', dim: '#8b98a5', faint: '#5b6773', accent: '#7dd3fc', accent2: '#e6edf3',
25 grid: '#0f151a', ok: '#86efac', error: '#f87171',
26 cats: { you: '#e6edf3', say: '#c9d6e2', read: '#7dd3fc', write: '#a5b4fc', run: '#c4b5fd', test: '#86efac', search: '#5eead4', web: '#93c5fd', plan: '#fde68a', agent: '#d8b4fe', ask: '#f9a8d4', tool: '#94a3b8', fix: '#fb7185' },
27 },
28};
29
30export const catColor = (P, cat) => P.cats[cat] || P.cats.tool;
31
32/** A new running step from a tool.call event. */
33export function startStep(e, now) {
34 const { tool, tool_use_id, agentId, ...input } = e;
35 return {
36 id: tool_use_id,
37 kind: 'tool',
38 name: String(tool),
39 input: stripReserved(input),
40 status: 'running',
41 startedAt: now,
42 endedAt: null,
43 side: Boolean(agentId),
44 };
45}
46
47function stripReserved(input) {
48 const out = {};
49 for (const [k, v] of Object.entries(input)) if (k !== 'requestMeta') out[k] = v;
50 return out;
51}
52
53/** Pushes a step, keeping the newest MAX_STEPS. */
54export function pushStep(steps, step) {
55 return [...steps, step].slice(-MAX_STEPS);
56}
57
58export function finishStep(steps, id, { isError, now, result = null }) {
59 return steps.map((s) => (s.id === id ? { ...s, status: isError ? 'error' : 'done', endedAt: now, result } : s));
60}
61
62/** translate() from visualizar, guarded: an unexpected step never breaks the pane. */
63export function viewOf(step, cwd) {
64 try {
65 return translate(step, { cwd }).view;
66 } catch {
67 return { simple: `Using ${step.name}`, vibe: `Using ${step.name}`, eng: { title: step.name } };
68 }
69}
70
71const plain = (s) => String(s ?? '').replace(/`/g, '').replace(/\s+/g, ' ').trim();
72
73export function textFor(view, level) {
74 if (level === 'eng') return plain(view.eng?.title || view.simple);
75 return plain(level === 'vibe' ? view.vibe : view.simple);
76}
77
78export function clipTo(s, n) {
79 return s.length > n ? s.slice(0, Math.max(0, n - 1)) + '…' : s;
80}
81
82/** 65000 ms -> "1:05" */
83export function elapsed(ms) {
84 const s = Math.max(0, Math.floor(ms / 1000));
85 return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
86}
87
88/** When a step began, relative to the turn: "0:07". */
89export function stepTime(step, turnStart) {
90 return elapsed(turnStart === null ? 0 : step.startedAt - turnStart);
91}
92
93/** The last `n` fixes from fixes.jsonl text, newest last. Unreadable lines are skipped. */
94export function parseFixes(raw, n = 50) {
95 const out = [];
96 for (const line of String(raw || '').split('\n')) {
97 if (!line.trim()) continue;
98 try {
99 const f = JSON.parse(line);
100 if (typeof f.text !== 'string') continue;
101 const at = Date.parse(f.delivered_at || f.ts || '');
102 out.push({
103 text: f.text,
104 urgent: f.urgency === 'urgent',
105 at: Number.isFinite(at) ? at : null,
106 sessionId: f.session_id ?? null,
107 deliveredAt: f.delivered_at ?? null,
108 doing: f.doing ?? null,
109 });
110 } catch {
111 /* skip */
112 }
113 }
114 return out.slice(-n);
115}
116hooks/carousel.js 161 lines1// The Cards view as a Client region: a deck of step cards you can drag
2// sideways with the mouse, click to bring a card to the centre, or step with
3// the arrow keys once it has focus. Runs on the drawing surface (terminal,
4// Desktop) with no mods API; it tells the hooks module which card to centre
5// by posting { select: index }.
6//
7// props: { cards: Card[], cur: number, P: palette }
8// Card: { title, sub?, kind, glyph, color, time, status, statusColor,
9// mono?, facts?: string[], lines?: { t, k: 'add'|'del'|'out'|'err' }[] }
10
11const GAP = 2
12
13// Card sizes in cells by distance from the centre: [width, height].
14export function sizes(columns) {
15 const w0 = Math.max(24, Math.min(40, Math.floor(columns * 0.36)))
16 return [
17 [w0, 14],
18 [Math.round(w0 * 0.7), 10],
19 [Math.round(w0 * 0.52), 8],
20 ]
21}
22
23// Which cards fit around `cur`, and the column span of each.
24export function layoutDeck(columns, cur, count) {
25 const sz = sizes(columns)
26 const slots = [{ i: cur, d: 0 }]
27 let used = sz[0][0]
28 for (const d of [1, 2]) {
29 const w = sz[d][0] + GAP
30 if (used + 2 * w > columns) break
31 if (cur - d >= 0) slots.unshift({ i: cur - d, d })
32 if (cur + d < count) slots.push({ i: cur + d, d })
33 used += 2 * w
34 }
35 const total = slots.reduce((a, { d }) => a + sz[d][0], 0) + GAP * (slots.length - 1)
36 let x = Math.max(0, Math.floor((columns - total) / 2))
37 const spans = slots.map(({ i, d }) => {
38 const span = { i, from: x, to: x + sz[d][0] }
39 x += sz[d][0] + GAP
40 return span
41 })
42 return { slots, sz, spans }
43}
44
45// The latest props and layout per instance, for the pointer and key
46// listeners, which are registered once.
47const live = new WeakMap()
48
49export default function Carousel(props, surface) {
50 const { Box, Text } = surface.elements
51 const { cards = [], cur: propCur = 0, P } = props || {}
52 const st = surface.state || {}
53 const columns = surface.columns || 80
54 const step = sizes(columns)[1][0] + GAP
55 const clamp = (n) => Math.max(0, Math.min(cards.length - 1, n))
56
57 if (surface.state === undefined) {
58 const select = (target) => {
59 const ref = live.get(surface)
60 surface.setState({ ...(surface.state || {}), drag: null, pending: target, pendingFrom: ref.cur })
61 surface.post({ select: target })
62 }
63 surface.onPointer((ev) => {
64 const s = surface.state || {}
65 const ref = live.get(surface)
66 if (ev.type === 'down' && ev.button === 'left') {
67 surface.setState({ ...s, drag: { x0: ev.x, from: ref.shown, dx: 0 } })
68 } else if (ev.type === 'move' && s.drag) {
69 surface.setState({ ...s, drag: { ...s.drag, dx: ev.x - s.drag.x0 } })
70 } else if (ev.type === 'up' && s.drag) {
71 const { from, dx } = s.drag
72 const hit = ref.spans.find((sp) => ev.x >= sp.from && ev.x < sp.to)
73 const target = Math.abs(dx) < 2 ? (hit ? hit.i : from) : from - Math.round(dx / ref.step)
74 select(Math.max(0, Math.min(ref.count - 1, target)))
75 }
76 })
77 surface.onKey((ev) => {
78 const ref = live.get(surface)
79 if (ev.key !== 'left' && ev.key !== 'right') return
80 select(Math.max(0, Math.min(ref.count - 1, ref.shown + (ev.key === 'left' ? -1 : 1))))
81 })
82 surface.setState({})
83 }
84
85 // Which card is centred: a drag in progress, then a choice the hooks
86 // module hasn't confirmed yet, then the props.
87 let cur = propCur
88 if (st.pending !== undefined && st.pendingFrom === propCur) cur = st.pending
89 if (st.drag) cur = clamp(st.drag.from - Math.round(st.drag.dx / step))
90 cur = clamp(cur)
91
92 const { slots, sz, spans } = layoutDeck(columns, cur, cards.length)
93 live.set(surface, { cur: propCur, shown: cur, spans, step, count: cards.length })
94 if (!cards.length) return Text({ color: P && P.dim, children: ['No steps yet.'] })
95
96 const card = ({ i, d }) => {
97 const c = cards[i]
98 const [w, h] = sz[d]
99 const big = d === 0
100 const children = [
101 Box({ flexDirection: 'row', columnGap: 1, alignItems: 'center', children: [
102 Box({ borderStyle: 'round', borderColor: c.color, paddingX: 1, children: [Text({ color: c.color, size: big ? 'heading' : 'body', children: [c.glyph] })] }),
103 Box({ flexDirection: 'column', flexShrink: 1, children: [
104 Text({ color: c.color, size: 'caption', mono: true, weight: 'semibold', wrap: 'truncate-end', children: [c.kind] }),
105 Text({ color: P.faint, size: 'caption', mono: true, children: [c.time] }),
106 ] }),
107 ] }),
108 Text({
109 color: big ? P.text : P.dim,
110 size: c.mono ? (big ? 'heading' : 'caption') : big ? 'title' : d === 1 ? 'body' : 'caption',
111 mono: Boolean(c.mono),
112 weight: big ? 'bold' : 'medium',
113 wrap: 'wrap',
114 children: [c.title],
115 }),
116 ]
117 if (big && c.sub) children.push(Text({ color: P.dim, wrap: 'wrap', children: [c.sub] }))
118 // Engineer: facts as chips, then the few lines worth seeing.
119 if (c.facts && c.facts.length && d < 2) {
120 children.push(Box({ flexDirection: 'row', flexWrap: 'wrap', columnGap: 1, rowGap: 0, children: c.facts.map((f, k) =>
121 Text({ key: 'f' + k, color: P.text, backgroundColor: P.line, size: 'caption', mono: true, children: [` ${f} `] })) }))
122 }
123 if (big && c.lines) {
124 for (const [k, l] of c.lines.entries()) {
125 const color = l.k === 'add' ? P.ok : l.k === 'del' || l.k === 'err' ? P.error : P.dim
126 const mark = l.k === 'add' ? '+ ' : l.k === 'del' ? '− ' : ''
127 children.push(Text({ key: 'l' + k, color, size: 'caption', mono: true, wrap: 'truncate-end', children: [mark + l.t] }))
128 }
129 }
130 children.push(Box({ flexGrow: 1 }))
131 children.push(Box({ flexDirection: 'row', justifyContent: 'space-between', children: [
132 Text({ color: c.statusColor, size: 'caption', wrap: 'truncate-end', children: [`● ${c.status}`] }),
133 ...(big ? [Text({ color: P.faint, size: 'caption', mono: true, children: [`${i + 1} / ${cards.length}`] })] : []),
134 ] }))
135 return Box({
136 key: 'c' + i,
137 flexDirection: 'column',
138 rowGap: big ? 1 : 0,
139 width: w,
140 height: h,
141 flexShrink: 0,
142 overflow: 'hidden',
143 paddingX: big ? 2 : 1,
144 paddingY: big ? 1 : 0,
145 borderStyle: big ? 'bold' : 'round',
146 borderColor: big ? c.color : P.line2,
147 backgroundColor: big ? P.panel2 : P.panel,
148 children,
149 })
150 }
151
152 return Box({
153 flexDirection: 'row',
154 columnGap: GAP,
155 justifyContent: 'center',
156 alignItems: 'center',
157 height: sz[0][1] + 1,
158 children: slots.map(card),
159 })
160}
161hooks/eng.js 168 lines1// Engineer level: a quick-look technical summary of a step, not a raw dump.
2// head the call in one line: "Edit src/TopCard.tsx", "$ npm test"
3// facts short chips: "+5 −3 lines", "exit ok", "1.2s", "12 matches"
4// lines a few lines worth seeing: changed lines (add/del) or key output
5// Pure: works from the step's input and the small result kept by slimResult().
6
7const MAX_LINE = 110
8
9const clipLine = (s) => {
10 const t = String(s ?? '').replace(/\t/g, ' ').replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '')
11 return t.length > MAX_LINE ? t.slice(0, MAX_LINE - 1) + '…' : t
12}
13const lineCount = (s) => (typeof s === 'string' && s ? s.split('\n').length : 0)
14const nonEmpty = (s) => String(s || '').split('\n').map((l) => l.trimEnd()).filter((l) => l.trim())
15
16function rel(file, cwd) {
17 if (!file) return ''
18 const f = String(file)
19 if (cwd && f.startsWith(cwd + '/')) return f.slice(cwd.length + 1)
20 const home = f.match(/^\/Users\/[^/]+\/|^\/home\/[^/]+\//)
21 return home ? '~/' + f.slice(home[0].length) : f
22}
23
24function dur(ms) {
25 if (!Number.isFinite(ms) || ms <= 0) return null
26 return ms < 1000 ? `${Math.round(ms)}ms` : ms < 60000 ? `${(ms / 1000).toFixed(1)}s` : `${Math.floor(ms / 60000)}m ${Math.round((ms % 60000) / 1000)}s`
27}
28
29/** Keeps the parts of a tool.call result the summary uses. */
30export function slimResult(ran) {
31 if (!ran) return null
32 if (typeof ran.deny === 'string') return { isError: true, output: ran.deny.slice(-800) }
33 const r = ran.result
34 const out = {}
35 let text = ''
36 if (typeof r === 'string') text = r
37 else if (r && typeof r === 'object') {
38 if (typeof r.stdout === 'string') text = r.stdout
39 if (typeof r.stderr === 'string' && r.stderr.trim()) out.stderr = r.stderr.slice(-400)
40 if (!text && typeof r.content === 'string') text = r.content
41 if (!text && typeof r.output === 'string') text = r.output
42 if (!text && Array.isArray(r.filenames)) text = r.filenames.join('\n')
43 for (const k of ['numFiles', 'numLines', 'numMatches', 'durationMs', 'type', 'interrupted']) {
44 if (typeof r[k] === 'number' || typeof r[k] === 'string' || typeof r[k] === 'boolean') out[k] = r[k]
45 }
46 if (r.file && typeof r.file === 'object' && typeof r.file.numLines === 'number') out.numLines = r.file.numLines
47 if (Array.isArray(r.structuredPatch)) {
48 let added = 0
49 let removed = 0
50 for (const h of r.structuredPatch) for (const l of h?.lines || []) {
51 if (l.startsWith('+')) added++
52 else if (l.startsWith('-')) removed++
53 }
54 out.added = added
55 out.removed = removed
56 }
57 }
58 if (text) out.output = text.slice(-800)
59 if (ran.isError) out.isError = true
60 return out
61}
62
63// A test runner's summary line, if the output has one.
64function testSummary(text) {
65 const lines = nonEmpty(text)
66 const re = /(\d+\s+(passed|passing|failed|failing|pass|fail|tests?|errors?)\b)|(Tests?:\s+\d)|(ℹ (pass|fail) \d+)|(\bok\b.*\d+ tests?)/i
67 const hits = lines.filter((l) => re.test(l))
68 return hits.slice(-2)
69}
70
71/** @returns {{head:string, facts:string[], lines:{t:string,k:'add'|'del'|'out'|'err'}[]}} */
72export function engSummary(step, cwd) {
73 const i = step.input || {}
74 const r = step.result || {}
75 const name = step.name
76 const facts = []
77 const lines = []
78 const ok = step.status !== 'error'
79 const d = dur(r.durationMs ?? (step.endedAt && step.startedAt > 1e12 ? step.endedAt - step.startedAt : NaN))
80 const out = r.output || ''
81 const tail = (n) => nonEmpty(out).slice(-n).forEach((t) => lines.push({ t: clipLine(t), k: ok ? 'out' : 'err' }))
82
83 if (step.kind === 'prompt') return { head: 'prompt', facts: [`${lineCount(step.text)} lines`], lines: nonEmpty(step.text).slice(0, 3).map((t) => ({ t: clipLine(t), k: 'out' })) }
84 if (step.kind === 'fix') return { head: `fix · ${step.urgent ? 'urgent' : 'note'}`, facts: [step.deliveredAt ? 'delivered' : 'logged'], lines: [{ t: clipLine(step.text), k: 'out' }] }
85
86 switch (name) {
87 case 'Edit':
88 case 'MultiEdit': {
89 const edits = name === 'MultiEdit' && Array.isArray(i.edits) ? i.edits : [i]
90 const removed = r.removed ?? edits.reduce((a, e) => a + lineCount(e.old_string), 0)
91 const added = r.added ?? edits.reduce((a, e) => a + lineCount(e.new_string), 0)
92 facts.push(`+${added} −${removed} lines`)
93 if (edits.length > 1) facts.push(`${edits.length} edits`)
94 if (i.replace_all) facts.push('replace all')
95 const e0 = edits[0] || {}
96 nonEmpty(e0.old_string).slice(0, 3).forEach((t) => lines.push({ t: clipLine(t), k: 'del' }))
97 nonEmpty(e0.new_string).slice(0, 4).forEach((t) => lines.push({ t: clipLine(t), k: 'add' }))
98 return { head: `${name} ${rel(i.file_path, cwd)}`, facts, lines }
99 }
100 case 'Write': {
101 facts.push(`${lineCount(i.content)} lines`)
102 if (r.type) facts.push(r.type === 'create' ? 'new file' : 'overwrote')
103 nonEmpty(i.content).slice(0, 4).forEach((t) => lines.push({ t: clipLine(t), k: 'add' }))
104 return { head: `Write ${rel(i.file_path, cwd)}`, facts, lines }
105 }
106 case 'Read': {
107 if (i.offset || i.limit) facts.push(`lines ${i.offset || 1}–${(i.offset || 1) + (i.limit || 0) - 1 || '…'}`)
108 if (r.numLines) facts.push(`${r.numLines} lines read`)
109 return { head: `Read ${rel(i.file_path, cwd)}`, facts, lines }
110 }
111 case 'Bash': {
112 const cmd = String(i.command || '').split('\n')[0]
113 facts.push(step.status === 'running' ? 'running' : ok ? 'exit ok' : 'failed')
114 if (d) facts.push(d)
115 if (i.run_in_background) facts.push('background')
116 if (r.interrupted) facts.push('interrupted')
117 const summary = testSummary(out)
118 if (summary.length) summary.forEach((t) => lines.push({ t: clipLine(t.trim()), k: ok ? 'out' : 'err' }))
119 else tail(3)
120 if (r.stderr && !ok) nonEmpty(r.stderr).slice(-2).forEach((t) => lines.push({ t: clipLine(t), k: 'err' }))
121 return { head: `$ ${clipLine(cmd)}`, facts, lines }
122 }
123 case 'Grep':
124 case 'Glob': {
125 if (i.path) facts.push(`in ${rel(i.path, cwd)}`)
126 if (i.glob || i.type) facts.push(String(i.glob || i.type))
127 const n = r.numFiles ?? r.numMatches ?? (out ? nonEmpty(out).length : null)
128 if (n != null) facts.push(`${n} ${name === 'Glob' ? 'files' : 'matches'}`)
129 nonEmpty(out).slice(0, 3).forEach((t) => lines.push({ t: clipLine(rel(t, cwd)), k: 'out' }))
130 return { head: `${name} ${clipLine(i.pattern)}`, facts, lines }
131 }
132 case 'WebFetch': {
133 let host = String(i.url || '')
134 try {
135 const u = new URL(host)
136 host = u.host + u.pathname
137 } catch {}
138 return { head: `fetch ${clipLine(host)}`, facts: d ? [d] : [], lines: i.prompt ? [{ t: clipLine(i.prompt), k: 'out' }] : [] }
139 }
140 case 'WebSearch':
141 return { head: `search “${clipLine(i.query)}”`, facts, lines: [] }
142 case 'Task':
143 case 'Agent':
144 return { head: `agent ${i.subagent_type || 'general'}`, facts: d ? [d] : [], lines: [{ t: clipLine(i.description || i.prompt), k: 'out' }] }
145 case 'TodoWrite':
146 case 'mcp__ccd_turn__update_plan': {
147 const items = i.todos || i.items || []
148 if (Array.isArray(items) && items.length) {
149 facts.push(`${items.filter((t) => t.status === 'completed').length}/${items.length} done`)
150 const now = items.find((t) => t.status === 'in_progress')
151 if (now) lines.push({ t: clipLine('▶ ' + (now.content || now.activeForm)), k: 'out' })
152 }
153 return { head: 'plan', facts, lines }
154 }
155 }
156
157 const m = /^mcp__(.+?)__(.+)$/.exec(name)
158 const args = Object.entries(i)
159 .filter(([, v]) => typeof v !== 'object')
160 .slice(0, 3)
161 .map(([k, v]) => `${k}=${clipLine(String(v)).slice(0, 40)}`)
162 if (d) facts.push(d)
163 if (!ok) facts.push('failed')
164 args.forEach((a) => lines.push({ t: a, k: 'out' }))
165 if (!ok) tail(2)
166 return { head: m ? `${m[1]} · ${m[2]}` : name, facts, lines }
167}
168hooks/translate.js 363 lines1// GENERATED by mod/build.mjs from src/translate.js. Do not edit.
2// Rule-based translation of steps into three levels of detail:
3// simple plain English, a few words ("Building the top card")
4// vibe friendlier summary with file names and what changed
5// engineer the actual call: tool, command or diff, and output
6//
7// Unknown tools get a generic description. If AI fallback is on, ai.js may
8// later replace simple/vibe for those (step.ai).
9
10import path from './pathshim.js';
11import { clip } from './pathshim.js';
12
13const ENG_LIMIT = 2500;
14
15// ---------------------------------------------------------------------------
16// Friendly names for files
17
18const SPECIAL_FILES = {
19 'package.json': 'package settings',
20 'package-lock.json': 'package lockfile',
21 'tsconfig.json': 'TypeScript settings',
22 '.gitignore': 'git ignore list',
23 'readme.md': 'README',
24 'dockerfile': 'Docker setup',
25 'claude.md': 'Claude instructions',
26 '.env': 'environment settings',
27};
28const GENERIC_BASENAMES = new Set(['index', 'main', 'mod', '__init__', 'page', 'layout', 'route', 'app', 'default']);
29
30function words(s) {
31 return s
32 .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
33 .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
34 .replace(/[-_.]+/g, ' ')
35 .trim()
36 .toLowerCase();
37}
38
39/** "src/components/TopCard.tsx" -> "top card" */
40export function friendlyName(file) {
41 if (!file) return 'a file';
42 const base = path.basename(file);
43 const special = SPECIAL_FILES[base.toLowerCase()];
44 if (special) return special;
45 let stem = base.replace(/\.[^.]+$/, '');
46 const testMatch = /^(.*?)[._-](test|spec)$/i.exec(stem);
47 if (testMatch) return `tests for the ${words(testMatch[1])}`;
48 if (GENERIC_BASENAMES.has(stem.toLowerCase())) {
49 const parent = path.basename(path.dirname(file));
50 if (parent && parent !== '.' && parent !== '/') stem = `${parent} ${stem.toLowerCase() === 'index' ? '' : stem}`;
51 }
52 return words(stem) || base;
53}
54
55function fileKind(file) {
56 const ext = path.extname(file || '').toLowerCase();
57 const base = path.basename(file || '').toLowerCase();
58 if (/[._-](test|spec)\.[^.]+$/.test(base) || /(^|\/)(__tests__|tests?)\//.test(file || '')) return 'test';
59 if (['.tsx', '.jsx', '.vue', '.svelte', '.astro'].includes(ext)) return 'ui';
60 if (['.css', '.scss', '.sass', '.less'].includes(ext)) return 'style';
61 if (['.md', '.mdx', '.txt', '.rst'].includes(ext)) return 'doc';
62 if (['.json', '.yaml', '.yml', '.toml', '.ini', '.env'].includes(ext) || base.startsWith('.')) return 'config';
63 return 'code';
64}
65
66function short(file, cwd) {
67 if (!file) return '';
68 if (cwd && file.startsWith(cwd + '/')) return file.slice(cwd.length + 1);
69 const home = (typeof process === 'undefined' ? undefined : process.env.HOME);
70 if (home && file.startsWith(home + '/')) return '~/' + file.slice(home.length + 1);
71 return file;
72}
73
74const q = (s, n = 40) => {
75 const t = String(s ?? '').replace(/\s+/g, ' ').trim();
76 return t.length > n ? t.slice(0, n - 1) + '…' : t;
77};
78const lineCount = (s) => (typeof s === 'string' && s ? s.split('\n').length : 0);
79const plural = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}`;
80
81// ---------------------------------------------------------------------------
82// Shell commands
83
84const SHELL_RULES = [
85 [/\b(npm|pnpm|yarn|bun)\s+(run\s+)?test\b|\b(jest|vitest|pytest|mocha|go test|cargo test|rspec|phpunit)\b|node\s+--test/, 'test', 'Running the tests'],
86 [/\b(npm|pnpm|yarn|bun)\s+(i|install|add|ci)\b|\bpip3?\s+install\b|\bbrew\s+install\b|\bcargo\s+add\b|\bgo\s+get\b/, 'install', 'Installing packages'],
87 [/\b(npm|pnpm|yarn|bun)\s+run\s+(build|compile)\b|\b(tsc|webpack|vite\s+build|cargo\s+build|go\s+build|make)\b/, 'build', 'Building the project'],
88 [/\b(npm|pnpm|yarn|bun)\s+run\s+(dev|start|serve)\b|\b(vite|next\s+dev)\b/, 'run', 'Starting the app'],
89 [/\b(eslint|prettier|ruff|black|biome|npm run lint)\b/, 'lint', 'Tidying up the code'],
90 [/\bgit\s+commit\b/, 'git', 'Saving a checkpoint'],
91 [/\bgit\s+push\b/, 'git', 'Uploading the changes'],
92 [/\bgit\s+(pull|fetch|merge|rebase)\b/, 'git', 'Syncing with the repository'],
93 [/\bgit\s+(checkout|switch|branch)\b/, 'git', 'Switching branches'],
94 [/\bgit\s+(status|diff|log|show)\b/, 'git', 'Checking what changed'],
95 [/\bgh\s+pr\b/, 'git', 'Working on the pull request'],
96 [/\b(curl|wget|http)\b/, 'web', 'Fetching from the web'],
97 [/^\s*(mkdir)\b/, 'files', 'Making a folder'],
98 [/^\s*(rm|rmdir)\b/, 'files', 'Removing files'],
99 [/^\s*(mv|cp)\b/, 'files', 'Moving files around'],
100 [/^\s*(ls|find|tree|pwd|du)\b/, 'look', 'Looking around the project'],
101 [/^\s*(cat|head|tail|less|sed\s+-n|wc)\b/, 'read', 'Reading a file'],
102 [/^\s*(grep|rg|ag)\b/, 'search', 'Searching the code'],
103 [/\b(docker|kubectl|terraform)\b/, 'build', 'Working with infrastructure'],
104 [/^\s*(node|python3?|deno|ruby|go\s+run|bash|sh)\b/, 'run', 'Running a script'],
105];
106
107function classifyShell(cmd) {
108 const c = String(cmd || '').replace(/^\s*cd\s+[^&;]+(&&|;)\s*/, '');
109 for (const [re, kind, phrase] of SHELL_RULES) if (re.test(c)) return { kind, phrase };
110 return { kind: 'shell', phrase: 'Running a command' };
111}
112
113// ---------------------------------------------------------------------------
114// Tools
115
116// "session" -> "session code" for plain source files, so the sentence reads.
117function nounFor(file) {
118 const noun = friendlyName(file);
119 const special = SPECIAL_FILES[path.basename(file || '').toLowerCase()];
120 return fileKind(file) === 'code' && !special ? `${noun} code` : noun;
121}
122
123function verbForEdit(file) {
124 switch (fileKind(file)) {
125 case 'ui': return 'Building';
126 case 'style': return 'Styling';
127 case 'test': return 'Writing';
128 case 'doc': return 'Writing';
129 case 'config': return 'Adjusting';
130 default: return 'Updating';
131 }
132}
133
134function outcome(step) {
135 if (step.status === 'running') return '';
136 if (step.blockedByFix) return ' — stopped by your fix';
137 if (step.status === 'error') return ' — hit a snag';
138 return '';
139}
140
141function mcpParts(name) {
142 const m = /^mcp__(.+?)__(.+)$/.exec(name);
143 return m ? { server: m[1].replace(/^plugin_[^_]+_/, ''), tool: m[2] } : null;
144}
145
146/**
147 * @returns {{icon:string, cat:string, simple:string, vibe:string, known:boolean}}
148 */
149function describeTool(step, cwd) {
150 const i = step.input || {};
151 const r = step.result || {};
152 const name = step.name;
153 const file = i.file_path || i.notebook_path || i.path;
154 const rel = short(file, cwd);
155 const noun = friendlyName(file);
156 const done = step.status !== 'running';
157
158 switch (name) {
159 case 'Read':
160 return {
161 icon: 'read', cat: 'read', known: true,
162 simple: `Reading the ${noun}`,
163 vibe: `Read \`${rel}\`` + (i.offset ? ` from line ${i.offset}` : '') + (r.numLines ? ` (${plural(r.numLines, 'line')})` : ''),
164 };
165 case 'Write': {
166 const created = r.type ? r.type === 'create' : null;
167 const n = lineCount(i.content);
168 return {
169 icon: 'create', cat: 'write', known: true,
170 simple: `${created === false ? 'Rewriting' : 'Creating'} the ${nounFor(file)}`,
171 vibe: `${created === false ? 'Rewrote' : 'Created'} \`${rel}\` — ${plural(n, 'line')}`,
172 };
173 }
174 case 'Edit':
175 case 'MultiEdit': {
176 const edits = name === 'MultiEdit' && Array.isArray(i.edits) ? i.edits : [i];
177 const removed = r.removed ?? edits.reduce((a, e) => a + lineCount(e.old_string), 0);
178 const added = r.added ?? edits.reduce((a, e) => a + lineCount(e.new_string), 0);
179 return {
180 icon: 'edit', cat: 'write', known: true,
181 simple: `${verbForEdit(file)} the ${nounFor(file)}`,
182 vibe: `Changed \`${rel}\` — ${!added ? `removed ${plural(removed, 'line')}` : !removed ? `added ${plural(added, 'line')}` : `swapped ${plural(removed, 'line')} for ${added}`}${i.replace_all ? ' (everywhere)' : ''}`,
183 };
184 }
185 case 'NotebookEdit':
186 return { icon: 'edit', cat: 'write', known: true, simple: `Editing the ${noun} notebook`, vibe: `Edited a cell in \`${rel}\`` };
187 case 'Bash': {
188 const { kind, phrase } = classifyShell(i.command);
189 const exit = step.status === 'error' ? ' → failed' : done ? ' → ok' : '';
190 const bg = i.run_in_background ? ' (in the background)' : '';
191 return {
192 icon: kind === 'test' ? 'test' : kind === 'install' ? 'package' : kind === 'git' ? 'git' : kind === 'web' ? 'globe' : 'terminal',
193 cat: kind === 'test' ? 'test' : 'run', known: true,
194 simple: phrase + bg,
195 vibe: (i.description ? i.description : `Ran \`${q(i.command, 60)}\``) + bg + exit,
196 };
197 }
198 case 'BashOutput':
199 return { icon: 'terminal', cat: 'run', known: true, simple: 'Checking on a running command', vibe: 'Read output from a background command' };
200 case 'KillShell':
201 case 'KillBash':
202 return { icon: 'terminal', cat: 'run', known: true, simple: 'Stopping a running command', vibe: 'Stopped a background command' };
203 case 'Grep':
204 return {
205 icon: 'search', cat: 'search', known: true,
206 simple: `Searching the code for “${q(i.pattern, 30)}”`,
207 vibe: `Searched for “${q(i.pattern, 50)}”${i.path ? ` in \`${short(i.path, cwd)}\`` : ''}${r.numFiles != null ? ` — ${plural(r.numFiles, 'file')}` : ''}`,
208 };
209 case 'Glob':
210 return {
211 icon: 'search', cat: 'search', known: true,
212 simple: 'Finding files',
213 vibe: `Looked for files matching \`${q(i.pattern, 50)}\`${r.numFiles != null ? ` — ${plural(r.numFiles, 'match')}` : ''}`,
214 };
215 case 'LS':
216 return { icon: 'folder', cat: 'search', known: true, simple: 'Looking around the project', vibe: `Listed \`${rel || '.'}\`` };
217 case 'WebSearch':
218 return { icon: 'globe', cat: 'web', known: true, simple: `Searching the web for “${q(i.query, 30)}”`, vibe: `Searched the web: “${q(i.query, 70)}”` };
219 case 'WebFetch': {
220 let host = '';
221 try {
222 host = new URL(i.url).host;
223 } catch {}
224 return { icon: 'globe', cat: 'web', known: true, simple: `Reading ${host || 'a web page'}`, vibe: `Read ${q(i.url, 70)}` };
225 }
226 case 'Task':
227 case 'Agent':
228 return {
229 icon: 'agent', cat: 'agent', known: true,
230 simple: `Sending a helper to ${lowerFirst(q(i.description || 'help', 40))}`,
231 vibe: `Started a ${i.subagent_type || 'helper'} agent: ${q(i.description || i.prompt, 80)}`,
232 };
233 case 'TodoWrite':
234 case 'TaskCreate':
235 case 'TaskUpdate':
236 case 'mcp__ccd_turn__update_plan': {
237 const items = i.todos || i.items || [];
238 const active = Array.isArray(items) ? items.find((t) => t.status === 'in_progress') : null;
239 return {
240 icon: 'list', cat: 'plan', known: true,
241 simple: active ? `Next up: ${lowerFirst(q(active.content || active.activeForm, 40))}` : 'Updating the to-do list',
242 vibe: Array.isArray(items) && items.length
243 ? `Plan: ${items.filter((t) => t.status === 'completed').length}/${items.length} done${active ? ` · now: ${q(active.content || active.activeForm, 60)}` : ''}`
244 : 'Updated the to-do list',
245 };
246 }
247 case 'AskUserQuestion':
248 return { icon: 'question', cat: 'ask', known: true, simple: 'Asking you a question', vibe: `Asked: ${q(i.questions?.[0]?.question, 90)}` };
249 case 'ExitPlanMode':
250 return { icon: 'list', cat: 'plan', known: true, simple: 'Showing you the plan', vibe: 'Proposed a plan for your approval' };
251 case 'EnterPlanMode':
252 return { icon: 'list', cat: 'plan', known: true, simple: 'Planning', vibe: 'Switched to planning' };
253 case 'Skill':
254 return { icon: 'spark', cat: 'tool', known: true, simple: `Using the ${q(i.skill || i.name, 30)} skill`, vibe: `Loaded skill “${q(i.skill || i.name, 40)}”` };
255 case 'ToolSearch':
256 return { icon: 'plug', cat: 'tool', known: true, simple: 'Looking up tools', vibe: `Loaded tools: ${q(i.query, 60)}` };
257 }
258
259 const mcp = mcpParts(name);
260 if (mcp) {
261 return {
262 icon: 'plug', cat: 'tool', known: false,
263 simple: `Using ${words(mcp.server)}: ${words(mcp.tool)}`,
264 vibe: `Called ${words(mcp.tool)} on ${mcp.server}`,
265 };
266 }
267 return { icon: 'plug', cat: 'tool', known: false, simple: `Using ${words(name)}`, vibe: `Used the ${name} tool` };
268}
269
270function lowerFirst(s) {
271 return s ? s[0].toLowerCase() + s.slice(1) : s;
272}
273
274function firstSentence(text, n) {
275 const t = text.replace(/[#*_`>]/g, '').replace(/\s+/g, ' ').trim();
276 const m = /^(.{12,}?[.!?])(\s|$)/.exec(t);
277 return q(m ? m[1] : t, n);
278}
279
280// ---------------------------------------------------------------------------
281// Engineer detail
282
283function engineer(step, cwd) {
284 if (step.kind !== 'tool') return { title: step.kind, body: clip(step.text || '', ENG_LIMIT), lang: 'text' };
285 const i = step.input || {};
286 let title = step.name;
287 let body = '';
288 let lang = 'text';
289 const file = i.file_path || i.notebook_path;
290 if (step.name === 'Bash') {
291 title = `$ ${q(i.command, 120)}`;
292 body = `$ ${i.command}`;
293 lang = 'shell';
294 } else if (step.name === 'Edit') {
295 title = `Edit ${short(file, cwd)}`;
296 body = diff(i.old_string, i.new_string);
297 lang = 'diff';
298 } else if (step.name === 'MultiEdit' && Array.isArray(i.edits)) {
299 title = `MultiEdit ${short(file, cwd)} (${i.edits.length})`;
300 body = i.edits.map((e) => diff(e.old_string, e.new_string)).join('\n@@\n');
301 lang = 'diff';
302 } else if (step.name === 'Write') {
303 title = `Write ${short(file, cwd)}`;
304 body = i.content || '';
305 lang = 'code';
306 } else if (step.name === 'Read') {
307 title = `Read ${short(file, cwd)}${i.offset ? ` @${i.offset}` : ''}${i.limit ? `+${i.limit}` : ''}`;
308 } else {
309 const args = Object.entries(i)
310 .map(([k, v]) => `${k}: ${typeof v === 'string' ? v : JSON.stringify(v)}`)
311 .join('\n');
312 title = `${step.name}(${q(Object.values(i).filter((v) => typeof v === 'string')[0] || '', 60)})`;
313 body = args;
314 }
315 return {
316 title,
317 body: clip(body, ENG_LIMIT),
318 lang,
319 output: step.output ? clip(step.output, ENG_LIMIT) : step.status === 'running' ? '' : '(no output)',
320 stderr: step.result?.stderr || '',
321 };
322}
323
324function diff(oldS = '', newS = '') {
325 const minus = String(oldS).split('\n').map((l) => '- ' + l);
326 const plus = String(newS).split('\n').map((l) => '+ ' + l);
327 return [...(oldS ? minus : []), ...plus].join('\n');
328}
329
330// ---------------------------------------------------------------------------
331
332/** Adds .view = {icon, cat, simple, vibe, eng, known} to a copy of the step. */
333export function translate(step, { cwd, ai } = {}) {
334 let v;
335 if (step.kind === 'prompt') {
336 v = { icon: 'user', cat: 'you', known: true, simple: `You asked: ${firstSentence(step.text, 70)}`, vibe: q(step.text, 220) };
337 } else if (step.kind === 'command') {
338 v = { icon: 'slash', cat: 'you', known: true, simple: `You ran ${q(step.text, 40)}`, vibe: `Ran the ${q(step.text, 60)} command` };
339 } else if (step.kind === 'message') {
340 v = { icon: 'chat', cat: 'say', known: true, simple: firstSentence(step.text, 90), vibe: q(step.text, 320) };
341 } else if (step.kind === 'fix') {
342 v = {
343 icon: 'flag', cat: 'fix', known: true,
344 simple: `You steered: ${q(step.text, 60)}`,
345 vibe: `${step.urgency === 'urgent' ? 'Urgent fix' : 'Fix'}: ${q(step.text, 200)}${step.doing ? ` (while it ${step.doing.replace(/^about to run/, 'was about to run')})` : ''}`,
346 };
347 } else {
348 v = describeTool(step, cwd);
349 const extra = outcome(step);
350 if (!v.known && ai) {
351 v.simple = ai.simple || v.simple;
352 v.vibe = ai.vibe || v.vibe;
353 v.ai = true;
354 }
355 v.vibe += extra;
356 }
357 v.eng = engineer(step, cwd);
358 const { input, ...rest } = step;
359 return { ...rest, view: v };
360}
361
362export const _test = { classifyShell, words };
363hooks/pathshim.js 22 lines1// Stand-ins for the bits of node:path and model.js that translate.js uses.
2const basename = (p) => {
3 const s = String(p).replace(/\/+$/, '');
4 return s.slice(s.lastIndexOf('/') + 1);
5};
6const dirname = (p) => {
7 const s = String(p).replace(/\/+$/, '');
8 const i = s.lastIndexOf('/');
9 return i < 0 ? '.' : i === 0 ? '/' : s.slice(0, i);
10};
11const extname = (p) => {
12 const b = basename(p);
13 const i = b.lastIndexOf('.');
14 return i <= 0 ? '' : b.slice(i);
15};
16export default { basename, dirname, extname };
17
18export function clip(s, n) {
19 if (typeof s !== 'string') return '';
20 return s.length > n ? s.slice(0, n) + `\n… (${s.length - n} more characters)` : s;
21}
22