SLOPSHOPPER

visualizar

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

paneguardcommandprompttimer
★ 1v0.7.0MITupdated 2026-10-17Aryan-sk6/Visualizar/mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · visualizar
│ ┃ visualizar ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ ◆ VISUALIZAR ○ IDLE 1: Cards 2: Flow 3: ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ╭────────────────────────────────────────── ⏺ Update(src/auth.ts) │ ┃ │ ⎿ Added 2 lines, removed 1 line │ ┃ │ ╭──────────────────────────────────╮ ID ⏺ Bash(bun test) │ ┃ │ │ ◼ │ Do ⎿ 3 pass, 1 fail │ ┃ │ ╰──────────────────────────────────╯ │ ┃ │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ╰────────────────────────────────────────── │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ ▣ client module ./carousel.js │ ┃ › /visualizar │ ┃ ┃┃▮▮▮▮▮▮▮▮█ ⎿ visualizar: visualizar open. 1/2/3 layout · s/v/e detail · c/x t │ ┃ │ ┃ b: ◀ n: ▶ l: Following live │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · visualizar
◆ VISUALIZAR ○ IDLE 1: Cards 2: Flow 3: Timeline s: S ╭────────────────────────────────────────────────────────── │ │ ╭──────────────────────────────────────────────────╮ ID │ │ ◼ │ Do │ ╰──────────────────────────────────────────────────╯ │ ╰────────────────────────────────────────────────────────── ▣ client module ./carousel.js ┃┃▮▮▮▮▮▮▮▮█ b: ◀ n: ▶ l: Following live
README

visualizar

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:

LevelExample
SimpleBuilding the top card
VibeChanged src/components/TopCard.tsx — swapped 3 lines for 5
EngineerEdit 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.

Quickstart

  1. Install it (Node 18+, macOS, Linux, or WSL2). Run this from a clone of this repo; the package isn't on npm yet:
   npm install -g .
  1. In the folder where you run Claude Code, start it:
   visualizar
  1. A browser tab opens on 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.

Views

Switch at any time from the top bar. Your choices are remembered.

  • Cards: one card per step. The current step is big and centred; the steps before and after sit beside it. A strip of coloured marks along the bottom is the whole session, with your fixes as red flags. Use ← / → to step through, Enter to open details, and Jump to now to follow live again.
  • Flow: a flowchart of every step, grouped by the prompts you sent. Your fixes branch in, in red, at the point they reached Claude.
  • Timeline: lanes for You, Claude, Tools, Helpers (subagents) and Fixes on a time axis. Bars show how long each tool call took; long pauses are squeezed. Every fix is a dashed red line across all lanes.

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.

Where the data comes from

  • Claude Code transcripts: ~/.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.
  • inject's fixes: <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.

Optional: AI descriptions for unusual steps

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.

Options

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-openDon't open a browser tab; just print the URL.
--aiAI descriptions for unusual steps (above).

Inside Claude Code

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.

Things to know

  • The transcript format is internal to Claude Code and, per its docs, "changes between versions". visualizar reads it defensively and skips anything it doesn't recognise, but a Claude Code update can still change what it can show. Tested with Claude Code 2.1.299.
  • Local only. The server listens on 127.0.0.1, answers only requests addressed to 127.0.0.1 or localhost (against DNS rebinding), and requires a per-run token for the one write endpoint.
  • Subagents appear as a single "Sending a helper to …" step. Their inner steps are only shown when they're in the main transcript (the Helpers lane); subagent transcripts stored in separate files aren't read yet.
  • Codex: the Codex theme is a colour scheme only. Reading Codex sessions isn't supported yet.

Development

npm install
npm test

No build step and no runtime dependencies. @anthropic-ai/sdk is optional and only loaded with --ai.

License

MIT

Source 6 files
hooks/register.js 490 lines
1// 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}
490
hooks/hud.js 116 lines
1// 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}
116
hooks/carousel.js 161 lines
1// 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}
161
hooks/eng.js 168 lines
1// 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}
168
hooks/translate.js 363 lines
1// 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 };
363
hooks/pathshim.js 22 lines
1// 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