SLOPSHOPPER

Glass

A desktop-app look for Claude Code's terminal: colored shell commands in prose, a Bottom line card, content-fit code cards, borderless tables, gutter marks…

newbandspinnerrowsguardcommand
★ 11v0.4.25MITupdated 2026-10-09rashedInt32/glass
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · glass
◆ You · 8:53 AM ───────────────────────────────────────────────────────────────────────────────────────────────────… fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines │ ├─ ✓ Edit src/auth.ts ⏺ Bash(bun test) ⎿ 3 pass, 1 fail Done. I made refresh reject expired claims and added an audit call. › /fold ⎿ glass: Folded 1 turn. /unfold opens them again. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Your message
◆ You · 8:53 AM ───────────────────────────────────────────────────────────────────────────────────… fix the failing auth test and add an audit log call
Claude's reply
Done. I made refresh reject expired claims and added an audit call.
Tool row
│ ├─ ✓ Edit src/auth.ts
README

glass

A desktop-app look for Claude Code's terminal transcript, as a mod.

https://github.com/user-attachments/assets/365252bf-4b60-49c3-902b-b8e127592d28

Claude Code draws replies in one markdown style: inline code in a fixed lavender, no color for commands, tables in boxes, tool rows in gray. glass replaces the drawing of assistant replies, tool rows and turn headers with its own, inside the same TUI. Hooks, skills, MCP, permissions and Remote Control are untouched: it changes what is drawn, never what is stored or what the model reads.

What it draws

  • Commands in color. Inside backticks and in plain prose: command, subcommand, flag, string, path, operator, number, variable, each in its own color. In prose a command paints only with evidence (git push --follow-tags paints, make sure the build passes does not).
  • Paths and links anywhere. src/main.rs:42:7 with its line number in the number color, README.md, .gitignore, ~/.config/app.toml. URLs underlined; https links are real OSC 8 links.
  • A Bottom line card. A reply opening with Bottom line (or Bottom line:) and Verified: / Issue: / Fix: rows becomes a tinted card with a thin bar and colored labels. Forms while streaming, never flickers.
  • Gutter marks. A ▎ beside any paragraph that asks something of you: a question, an instruction, a risk, or a thing Claude did not do. Off for turns whose prompt asked for writing.
  • Private notes. A paragraph opening with Private or Privately draws italic and quiet.
  • Code cards. Fences on a tinted card sized to the code, a quiet language header, a line count and gutter past eight lines. glass's own highlighter does the coloring, in the palette's code keys; a shell fence takes the same colors as a command in prose.
  • Diff cards. An Edit, Write or file-changing Bash result as a rounded card with the file in its top border, ╭─ SPEC.md +1 -1 ───╮: one number column, + and - rows on a quiet tint, one context line each side, the code painted the same way, the frame on the tree trunk.
  • Headings, lists, quotes, tables. Bold colored headings with no rules, • ◦ ☐ ☑ markers, a quote bar, borderless tables with aligned columns and right-aligned numbers. A table a little too wide squeezes its widest column instead of falling back to boxes.
  • Turn headers. ◆ You · 1:11 PM ──── as one titled rule over your prompt, then ◉ Claude · 1:14 PM once the turn starts, with a dots line under it: one dot per tool call, grouped by tool, green, a red ✗ for a failure, hollow while running, then Bash ×3 · Read ×2. Once done the line reads 5 actions · 2 edits · 1 failed. /fold tucks finished trees away and /unfold opens them.
  • A tool tree. While a turn runs, every call is one row: ├─ ✓ Bash git status · 3 lines with its wall time at the right, ○ while it runs, ✗ in red when it fails. A Bash command keeps its colors once done; other arguments step down to a quiet color, and paths draw relative to the project. Rows light under the pointer. The engine's folded runs name what they read: ├─ ▸ Read ×2 SPEC.md · render.ts. Bash output is painted, three lines then a count. A finished agent is one row.
  • Background band. While subagents run, a rounded frame above the prompt lists them with a face, model and effort, current tool, tokens and the file they last touched, five at a time. The chevron folds it to one strip of faces.
  • Event rows. A background task's notification draws as one tree row with its duration; a message from another agent draws as one muted row.

Every width is measured the way the engine measures cells, so emoji and CJK in tables stay aligned.

Install

claude plugin marketplace add rashedInt32/glass
claude plugin install glass@glass

Restart Claude Code. Pick a palette with /config under Glass: tidepool (default), water (Empryo's own proxysoul-water tokens), undertow (sampled from Empryo's desktop video), codex or rose. The palettes assume a dark terminal.

To try it from a clone without installing:

claude --plugin-dir /path/to/glass

Design

SPEC.md is the contract: every element, its exact tree and colors, and the directions that were tried and rejected, with reasons. Settled through a design panel and an investigation against how Claude Desktop, the Codex app and T3 Code render chat.

The tidepool palette maps a neovim theme of the same name by role: strings olive, numbers and inline code pine, commands iris, builtins foam, paths gold, links mint.

Development

claude plugin validate .
npx -p typescript tsc -p .   # after the engine has laid .claude-plugin/types
claude plugin test .         # tests/*.test.ts, run in the engine's own sandbox
LC_ALL=C grep -rn '[^ -~]' hooks/   # must print nothing

scripts/check.sh runs all four and checks that both manifests carry the same version. CI runs it on every push and pull request, without tsc: the types only exist once the engine has loaded the plugin.

Every non-ASCII glyph is built from a code point in hooks/glyphs.ts. Literals outside ASCII are forbidden in hooks/: the tool path that writes files has dropped private-use glyphs before.

Credits

The shell tokenizer, the prose rules, the gutter-mark phrase tables and the output painter are ported from claude-hl, a PTY wrapper that did the same job by reading rendered ANSI. glass reads the markdown instead, which is why it can know a fence's language and never guesses where a code span ends.

License

MIT

Source 13 files
hooks/register.ts 579 lines
1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, RenderElement, Register } from 'claude-code'
3
4import type { GlassAgent, GlassCall, GlassPrompt, GlassTurn } from '../types'
5import { cleanHint, renderHint, clockCells, isEditTool, toolText, setCwd, renderAgentLaunch, renderBand, renderBashResult, renderDiff, renderEventRow, renderGroupRow, renderMessageRow, renderNoOutput, renderSkillLoad, renderToolError, renderToolOutput, renderTreeRow, renderUserRow } from './chrome'
6import type { ChangedFile, Hunk } from './chrome'
7import { G } from './glyphs'
8import { parseMarkdown } from './markdown'
9import type { Block } from './markdown'
10import { paletteNamed } from './palette'
11import type { Palette } from './palette'
12import { writeIntent } from './prose'
13import { renderReply } from './render'
14
15// The turns this session, newest last. A user row finds its own turn in
16// here: a `read` while a render hook runs subscribes that row, so every
17// `update` draws every user row again, and each must still find its data.
18const turns = atom({ plugin: 'glass', key: 'turns' } as const, [] as GlassTurn[])
19// the prompts the person submitted, newest last: a user row finds its turn
20// by text, and the assistant header appears once the turn starts
21const prompts = atom({ plugin: 'glass', key: 'prompts' } as const, [] as GlassPrompt[])
22// every main-loop call by turn: the dots line, the tree's times, and which
23// rows a folded turn hides
24const calls = atom({ plugin: 'glass', key: 'calls' } as const, {} as Record<string, GlassCall[]>)
25// subagents still running, for the band above the prompt
26const agents = atom({ plugin: 'glass', key: 'agents' } as const, [] as GlassAgent[])
27const band = atom({ plugin: 'glass', key: 'band' } as const, 'open' as 'open' | 'closed')
28
29const HISTORY = 48
30// finished replies whose parse is kept for their redraws
31const PARSED_MAX = 64
32const ANSWER_MAX = 20000
33
34const ESC = String.fromCodePoint(0x1b)
35// the engine shows this many output lines before folding the rest behind ctrl+o
36const MAX_PAINTED_LINES = 3
37// tools whose row never carries a body: a run of them packs without air rows
38const COMPACT = new Set(['Read'])
39
40// Live things, ticked once a second: main-loop calls still running (their
41// row's clock is a Raster the ticker blits, no redraw) and subagents still
42// running (the status line under the prompt). Module state, read by no
43// render hook, so a tick redraws nothing in the transcript (2026-10-04).
44const liveCalls = new Map<string, { tool: string; t0: number }>()
45let ticker: { cancel: () => void } | null = null
46
47async function tick($: EngineInterface, p: Palette): Promise<void> {
48  const now = await $.clock.now()
49  for (const [id, c] of liveCalls) {
50    void $.ui.blit({ requestId: id, key: 'clock', cells: clockCells(now - c.t0, p.meta) }).catch(() => undefined)
51  }
52  // nothing runs: the ticker stops (no status line: the owner found it noise
53  // under the prompt, 2026-10-04)
54  if (liveCalls.size === 0) {
55    ticker?.cancel()
56    ticker = null
57  }
58}
59
60// A Bash call's body as glass draws it, or null where the engine's stays.
61// Drawn under the ToolUse row from its own `output` (2026-10-04): calls run
62// in parallel drew their row with no ToolResult under it, so the body went
63// missing; the ToolResult row then draws nothing where this is non-null.
64function bashBody(t: Elements['terminal'], p: Palette, output: unknown, isErrored: boolean, cols: number): RenderElement | null {
65  if (isErrored) {
66    if (typeof output !== 'string' || output.trim() === '') return null
67    return renderToolOutput(t, p, output.replace(/\s+$/, '').split('\n'), { columns: cols, maxLines: MAX_PAINTED_LINES })
68  }
69  const out = output as { stdout?: unknown; stderr?: unknown; interrupted?: unknown; bashEditDiff?: unknown } | undefined
70  if (!out || typeof out !== 'object' || typeof out.stdout !== 'string' || out.interrupted === true) return null
71  const stderr = typeof out.stderr === 'string' ? out.stderr : ''
72  const text = [out.stdout, stderr].filter(s => s.trim() !== '').join('\n').replace(/\s+$/, '')
73  const lines = text === '' ? [] : text.split('\n')
74  const diff = out.bashEditDiff as { files?: unknown; moreFiles?: unknown } | undefined
75  const files = Array.isArray(diff?.files)
76    ? (diff!.files as ChangedFile[]).filter(f => f && typeof f.filePath === 'string' && Array.isArray(f.hunks)).map(f => ({ ...f, hunks: f.hunks.filter(h => h && typeof h.oldStart === 'number' && typeof h.newStart === 'number' && Array.isArray(h.lines)) }))
77    : []
78  if (files.length > 0) {
79    const moreFiles = typeof diff?.moreFiles === 'number' ? diff.moreFiles : 0
80    return renderBashResult(t, p, lines, files, { maxLines: MAX_PAINTED_LINES, moreFiles, columns: cols })
81  }
82  if (text === '') return renderNoOutput(t, p)
83  if (text.includes(ESC)) return null
84  return renderToolOutput(t, p, lines, { columns: cols, maxLines: MAX_PAINTED_LINES })
85}
86
87// one main-loop call recorded under its turn, replacing an earlier record
88// of the same id
89async function setCall($: EngineInterface, turnId: string, call: GlassCall) {
90  await update($, calls, r => {
91    const record = { ...(r ?? {}) }
92    const list = record[turnId] ?? []
93    const i = list.findIndex(c => c.id === call.id)
94    record[turnId] = i < 0 ? [...list, call] : [...list.slice(0, i), call, ...list.slice(i + 1)]
95    return record
96  })
97}
98
99export const register: Register = (on, options) => {
100  const opts = (options ?? {}) as { palette?: unknown }
101  // the palette follows /config live: every hook reads this binding at
102  // draw time, and the config.set hook below swaps it (rows already drawn
103  // keep their colors until something redraws them)
104  let palette = paletteNamed(opts.palette)
105  // gutter marks are off for a turn whose prompt asked for writing: the
106  // reply is then the thing itself and asks nothing of the reader
107  let marks = true
108  // the slash commands this session has, for the band's `/tasks` hint
109  let commands = new Set<string>()
110  // the names and types of subagents this session spawned: their folded
111  // `Message from` rows hide, since the finished row says the same
112  const spawned = new Set<string>()
113  // Tree rows read these module records, never an atom: a read in a render
114  // hook subscribes the row, and every write then redraws every row of the
115  // turn, which strands duplicate rows above the viewport (2026-10-03).
116  const callTurn = new Map<string, string>()
117  const callMs = new Map<string, number>()
118  // rows of a group the engine unfolded: no ToolResult of their own, so the
119  // ToolUse hook draws an Edit's or a Write's card under the row
120  const grouped = new Set<string>()
121  // each turn's main-loop calls in order, and whether Claude wrote text
122  // since the call before: the air rule below reads both
123  const turnOrder = new Map<string, { id: string; tool: string; afterText: boolean }[]>()
124  let textSinceCall = false
125  // /fold and /expand: module state like the records above, so a render
126  // hook reads them without subscribing; a hot reload opens everything again
127  let foldedTurns = new Set<string>()
128  let expandAllNow = false
129  // the module records keep the turns the atoms keep: past HISTORY turns
130  // the oldest turn's calls go, so a long session does not grow them forever
131  const forgetOldTurns = () => {
132    for (const [old, list] of turnOrder) {
133      if (turnOrder.size <= HISTORY) break
134      turnOrder.delete(old)
135      foldedTurns.delete(old)
136      for (const c of list) {
137        callTurn.delete(c.id)
138        callMs.delete(c.id)
139        grouped.delete(c.id)
140      }
141    }
142  }
143  // a finished message redraws on every /fold, resize and palette change:
144  // its parse is kept, the newest few by text
145  const parsed = new Map<string, Block[]>()
146  const parse = (text: string): Block[] => {
147    const hit = parsed.get(text)
148    if (hit) return hit
149    const blocks = parseMarkdown(text)
150    parsed.set(text, blocks)
151    if (parsed.size > PARSED_MAX) parsed.delete(parsed.keys().next().value!)
152    return blocks
153  }
154
155  // the live main-loop turn; a hot reload resets it, which only affects the
156  // totals of the turn that reloaded
157  const live = { turnId: '', startedAt: 0, tools: 0, edits: 0, failed: 0, lastToolId: null as string | null, costStart: null as number | null }
158
159  on('config.set', { key: 'glass.palette' }, async ($, e, next) => {
160    const result = await next(e)
161    palette = paletteNamed(e.value)
162    return result
163  })
164
165  on('session.start', async ($, e, next) => {
166    // 0.4.2 to 0.4.5 pinned a status line; clear one a reload left behind
167    $.ui.status(undefined)
168    try {
169      setCwd(await $.session.cwd())
170    } catch {
171      setCwd('')
172    }
173    try {
174      commands = new Set((await $.command.list()).map(c => c.name))
175    } catch {
176      commands = new Set()
177    }
178    await $.command.register({ name: 'fold', description: 'glass: fold the tool trees of every finished turn' })
179    await $.command.register({ name: 'unfold', description: 'glass: open the tool trees of every turn' })
180    await $.command.register({ name: 'expand', description: 'glass: open every folded run of reads and searches' })
181    await $.command.register({ name: 'collapse', description: 'glass: fold the runs of reads and searches again' })
182    return next(e)
183  })
184  on('command.run', { command: 'expand' }, async $ => {
185    expandAllNow = true
186    $.ui.invalidate('ui.render')
187    return { text: 'Every folded run is open. /collapse folds them again.' }
188  })
189  on('command.run', { command: 'collapse' }, async $ => {
190    expandAllNow = false
191    $.ui.invalidate('ui.render')
192    return { text: 'Runs of reads and searches fold again.' }
193  })
194
195  // ---- folding on demand -------------------------------------------------
196  on('command.run', { command: 'fold' }, async $ => {
197    const ids = ((await read($, turns)) ?? []).map(h => h.turnId)
198    foldedTurns = new Set(ids.slice(-HISTORY))
199    $.ui.invalidate('ui.render')
200    return { text: ids.length ? `Folded ${ids.length} turn${ids.length === 1 ? '' : 's'}. /unfold opens them again.` : 'Nothing to fold yet.' }
201  })
202  on('command.run', { command: 'unfold' }, async $ => {
203    foldedTurns = new Set()
204    $.ui.invalidate('ui.render')
205    return { text: 'Every turn is open.' }
206  })
207
208  // ---- reply renderer ----------------------------------------------------
209  on('ui.render', { component: 'AssistantMessage' }, ($, e, next) => {
210    if (e.surface !== 'terminal') return next(e)
211    const text = e.props.text
212    if (text.trim() === '') return next(e)
213    const t = $.ui.resolve(e)
214    // no bullet: the assistant header under the user row carries it
215    return renderReply(t, parse(text), palette, {
216      first: e.props.isFirstOfReply,
217      columns: e.viewport?.columns ?? 80,
218      marks,
219    })
220  })
221
222  // ---- user row, assistant header, dots line -----------------------------
223  on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
224    if (e.surface !== 'terminal') return next(e)
225    const t = $.ui.resolve(e)
226    const kind = e.props.origin.kind
227    // a background task's notification: one tree row, the body under it
228    // when the view is expanded (a short one is expanded from the start)
229    if (kind === 'task-notification') return renderEventRow(t, palette, e.requestId, e.props.text, e.props.task, e.props.isExpanded)
230    // any other row (another agent's message, a scheduled trigger, a
231    // bridge): one muted row; ctrl+o keeps the engine's body
232    if (kind !== 'composer') {
233      if (e.props.isExpanded) return next(e)
234      if (spawned.has(e.props.from?.name ?? '')) return t.Box({ display: 'none', children: [] })
235      return renderMessageRow(t, palette, e.props.from?.name ?? kind)
236    }
237    const text = e.props.text
238    if (text.trim() === '') return next(e)
239    const known = [...((await read($, prompts)) ?? [])].reverse().find(p => p.text === text) ?? null
240    const turnId = known?.turnId ?? null
241    let turn = null
242    if (turnId) {
243      const list = ((await read($, calls)) ?? {})[turnId] ?? []
244      const done = ((await read($, turns)) ?? []).find(h => h.turnId === turnId) ?? null
245      turn = {
246        turnId,
247        calls: list,
248        done,
249      }
250    }
251    return renderUserRow(t, palette, {
252      text,
253      submittedAt: known?.submittedAt ?? null,
254      startedAt: known?.startedAt ?? null,
255      turn,
256      columns: e.viewport?.columns ?? 80,
257    })
258  })
259
260  // ---- tool tree ---------------------------------------------------------
261  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
262    if (e.surface !== 'terminal') return next(e)
263    const id = e.props.tool_use_id
264    const turnId = callTurn.get(id)
265    // a folded turn hides its rows; a row of a turn glass never saw stays
266    if (turnId && foldedTurns.has(turnId)) return $.ui.resolve(e).Box({ display: 'none', children: [] })
267    const t = $.ui.resolve(e)
268    const order = turnId ? turnOrder.get(turnId) : undefined
269    const at = order ? order.findIndex(c => c.id === id) : -1
270    // Air above every row, except a bare row that continues a run of the
271    // same bodiless tool with no prose between (Read, Read, Read). A row
272    // after a body, a card, prose or another tool keeps its air (2026-10-04).
273    const air = !(order && at > 0 && COMPACT.has(e.props.tool) && order[at - 1]!.tool === e.props.tool && !order[at]!.afterText)
274    const started = liveCalls.get(id)
275    const clock = e.props.isRunning && started ? clockCells((await $.clock.now()) - started.t0, palette.meta) : undefined
276    const row = renderTreeRow(t, palette, e.props, { durationMs: callMs.get(id) ?? null, air, ...(clock ? { clock } : {}) })
277    if (grouped.has(id) && (e.props.tool === 'Edit' || e.props.tool === 'Write') && !e.props.isRunning && !e.props.isErrored) {
278      const card = editCard(t, palette, e.props.output, e.props.input, e.viewport?.columns ?? 80)
279      if (card) return t.Box({ flexDirection: 'column', children: [row, card] })
280    }
281    if (e.props.tool !== 'Bash' || e.props.isRunning || e.props.output === undefined) return row
282    const body = bashBody(t, palette, e.props.output, e.props.isErrored, e.viewport?.columns ?? 80)
283    return body ? t.Box({ flexDirection: 'column', children: [row, body] }) : row
284  })
285
286  // the engine's folded run of reads and searches: one tree row; /expand
287  // opens every group where it is
288  on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
289    if (e.surface !== 'terminal') return next(e)
290    if (e.props.isExpanded) {
291      remember(grouped, e.props.calls)
292      return next(e)
293    }
294    const first = e.props.calls.find((c: { tool_use_id?: string }) => typeof c.tool_use_id === 'string')
295    const turnId = first?.tool_use_id ? callTurn.get(first.tool_use_id) ?? null : null
296    if (turnId && foldedTurns.has(turnId)) return $.ui.resolve(e).Box({ display: 'none', children: [] })
297    const id = e.requestId
298    // open after /expand, or on its own when a call in it failed: a red
299    // mark must never hide behind a count
300    const failed = e.props.calls.some((c: { isErrored?: boolean; isInterrupted?: boolean }) => c.isErrored || c.isInterrupted)
301    if (expandAllNow || failed) {
302      remember(grouped, e.props.calls)
303      return next({ ...e, props: { ...e.props, isExpanded: true } })
304    }
305    const t = $.ui.resolve(e)
306    return renderGroupRow(t, palette, e.props.calls, {
307      isActive: e.props.isActive,
308      key: `expand:${id}`,
309    })
310  })
311
312  // Bash output body, painted the way claude-hl painted it. The engine
313  // strips escape codes from a rewritten result, so the body is drawn as
314  // Text, and only when it is short enough that the engine would show it
315  // whole: longer output keeps the engine's collapsed body and ctrl+o.
316  on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
317    if (e.surface !== 'terminal') return next(e)
318    const turnId = callTurn.get(e.props.tool_use_id)
319    if (turnId && foldedTurns.has(turnId)) return $.ui.resolve(e).Box({ display: 'none', children: [] })
320    // a failed call's reason on the trunk; a failed Bash's body is the ToolUse row's
321    if (e.props.isErrored && e.props.tool !== 'Bash' && typeof e.props.output === 'string') {
322      const reason = e.props.output.replace(/<\/?tool_use_error>/g, '')
323      if (reason.trim() !== '') return renderToolError($.ui.resolve(e), palette, reason)
324    }
325    // an Edit's or a Write's diff, drawn as glass draws it (owner's request, 2026-10-03)
326    if ((e.props.tool === 'Edit' || e.props.tool === 'Write') && !e.props.isErrored) {
327      return editCard($.ui.resolve(e), palette, e.props.output, (e.props as { input?: unknown }).input, e.viewport?.columns ?? 80) ?? next(e)
328    }
329    // a backgrounded agent: one trunked line in place of the engine's body
330    if (e.props.tool === 'Agent' || e.props.tool === 'Task') {
331      const status = (e.props.output as { status?: unknown } | undefined)?.status
332      if (status === 'async_launched' || status === 'remote_launched') return renderAgentLaunch($.ui.resolve(e), palette, status === 'remote_launched')
333      return next(e)
334    }
335    // a loaded skill: one trunked line in place of the engine's body, whose
336    // corner bracket sat off the trunk (2026-10-09)
337    if (e.props.tool === 'Skill' && !e.props.isErrored) return renderSkillLoad($.ui.resolve(e), palette, e.props.output)
338    if (e.props.tool !== 'Bash') return next(e)
339    // a backgrounded command: the same trunked line, not the engine's corner
340    // bracket, which sat off the trunk (2026-10-05)
341    if (typeof (e.props.output as { backgroundTaskId?: unknown } | undefined)?.backgroundTaskId === 'string' && !e.props.isErrored) return renderAgentLaunch($.ui.resolve(e), palette, false)
342    // the ToolUse row draws the body glass owns; this row draws nothing then
343    const body = bashBody($.ui.resolve(e), palette, e.props.output, e.props.isErrored, e.viewport?.columns ?? 80)
344    return body ? $.ui.resolve(e).Box({ display: 'none', children: [] }) : next(e)
345  })
346
347  // ---- the turn's bookkeeping --------------------------------------------
348  on('prompt.submit', async ($, e, next) => {
349    marks = !writeIntent(e.text)
350    const now = await $.clock.now()
351    await update($, prompts, ps => [...(ps ?? []), { text: e.text, submittedAt: now, startedAt: null, turnId: null }].slice(-HISTORY))
352    return next(e)
353  })
354
355  on('turn.start', async ($, e, next) => {
356    live.turnId = e.turnId
357    live.startedAt = await $.clock.now()
358    live.tools = 0
359    live.edits = 0
360    live.failed = 0
361    live.lastToolId = null
362    live.costStart = null
363    try {
364      live.costStart = (await $.session.usage()).cost?.usd ?? null
365    } catch {
366      live.costStart = null
367    }
368    const { turnId, startedAt } = live
369    // the newest prompt with this text gets its turn, which draws the
370    // assistant header and the dots line under the user row
371    await update($, prompts, ps => {
372      const list = [...(ps ?? [])]
373      for (let i = list.length - 1; i >= 0; i--) {
374        const p = list[i]!
375        if (p.text === e.text && p.turnId === null) {
376          list[i] = { ...p, startedAt, turnId }
377          break
378        }
379      }
380      return list
381    })
382    await update($, calls, r => {
383      const entries = Object.entries(r ?? {}).filter(([k]) => k !== turnId).slice(-(HISTORY - 1))
384      return Object.fromEntries([...entries, [turnId, []]])
385    })
386    // a new turn always shows its tree
387    foldedTurns.delete(turnId)
388    forgetOldTurns()
389    return next(e)
390  })
391
392  on('tool.call', async ($, e, next) => {
393    const main = !e.agentId
394    const id = e.tool_use_id ?? ''
395    const turnId = live.turnId
396    if (main) {
397      live.tools += 1
398      if (id) live.lastToolId = id
399      if (id && turnId) callTurn.set(id, turnId)
400      if (id && turnId) turnOrder.set(turnId, [...(turnOrder.get(turnId) ?? []).filter(c => c.id !== id), { id, tool: e.tool, afterText: textSinceCall }])
401      textSinceCall = false
402      if (id && turnId) await setCall($, turnId, { id, tool: e.tool, status: 'running', ms: null })
403      if (id) liveCalls.set(id, { tool: e.tool, t0: await $.clock.now() })
404      if (!ticker) ticker = $.clock.every(1000, () => void tick($, palette))
405    } else if (e.agentId) {
406      const agentId = e.agentId
407      const stage = toolText(e.tool)
408      const args = e as unknown as Record<string, unknown>
409      const file = typeof args.file_path === 'string' ? args.file_path : typeof args.path === 'string' ? args.path : null
410      void update($, agents, as => (as ?? []).map(a => (a.agentId === agentId ? { ...a, stage, file: file ?? a.file } : a)))
411    }
412    if (isEditTool(e.tool)) live.edits += 1
413    const t0 = await $.clock.now()
414    let r
415    try {
416      r = await next(e)
417    } finally {
418      if (main && id) liveCalls.delete(id)
419    }
420    if (main) {
421      const ms = (await $.clock.now()) - t0
422      if (r.isError) live.failed += 1
423      if (id) callMs.set(id, ms)
424      if (id) liveCalls.delete(id)
425      if (id && turnId) await setCall($, turnId, { id, tool: e.tool, status: r.isError ? 'failed' : 'ok', ms })
426    }
427    return r
428  })
429
430  // a subagent's model requests: its tokens and effort for the band. The
431  // event streams, so the hook is a generator relaying every chunk; the
432  // stop chunk carries the usage.
433  on('turn.step', async function* ($, e, next) {
434    const stream = next(e)
435    let usage: { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number } | null = null
436    let step = await stream.next()
437    while (!step.done) {
438      const chunk = step.value
439      if (chunk.kind === 'stop') usage = chunk.usage
440      if (chunk.kind === 'text' && !e.agentId) textSinceCall = true
441      yield chunk
442      step = await stream.next()
443    }
444    if (e.agentId) {
445      const agentId = e.agentId
446      const effort = e.effort === undefined ? '' : String(e.effort)
447      const u = usage
448      const used = u ? u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens + u.output_tokens : 0
449      void update($, agents, as => (as ?? []).map(a => (a.agentId === agentId ? { ...a, effort: effort || a.effort, tokens: a.tokens + used } : a)))
450    }
451    return step.value
452  })
453
454  on('agent.spawn', async ($, e, next) => {
455    const r = await next(e)
456    if (e.subagentType) spawned.add(e.subagentType)
457    if (typeof (e as { name?: unknown }).name === 'string') spawned.add((e as { name: string }).name)
458    if (r.agentId) {
459      const agent: GlassAgent = {
460        agentId: r.agentId,
461        description: e.description,
462        kind: e.subagentType,
463        model: r.model,
464        effort: '',
465        stage: 'thinking' + G.ellipsis,
466        file: '',
467        tokens: 0,
468        startedAt: await $.clock.now(),
469      }
470      await update($, agents, as => [...(as ?? []).filter(a => a.agentId !== agent.agentId), agent])
471    }
472    return r
473  })
474
475  on('turn.complete', async ($, e, next) => {
476    if (e.agentId) {
477      const agentId = e.agentId
478      await update($, agents, as => (as ?? []).filter(a => a.agentId !== agentId))
479      return next(e)
480    }
481    const u = e.usage
482    let costUsd: number | null = null
483    if (live.costStart !== null) {
484      try {
485        const now = (await $.session.usage()).cost?.usd
486        if (typeof now === 'number') costUsd = Math.max(0, now - live.costStart)
487      } catch {
488        costUsd = null
489      }
490    }
491    const finishedAt = await $.clock.now()
492    const turn: GlassTurn = {
493      turnId: e.turnId,
494      durationMs: e.durationMs,
495      tools: live.tools,
496      edits: live.edits,
497      failed: live.failed,
498      inTokens: u ? u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens : 0,
499      cacheTokens: u ? u.cache_read_input_tokens : 0,
500      outTokens: u ? u.output_tokens : 0,
501      costUsd,
502      lastToolId: live.lastToolId,
503      answer: e.answer.slice(0, ANSWER_MAX),
504      startedAt: live.startedAt || finishedAt - e.durationMs,
505      finishedAt,
506    }
507    await update($, turns, h => [...(h ?? []), turn].slice(-HISTORY))
508    // a finished turn stays open: the tree is the evidence of what the
509    // prompt did (owner's call, 2026-10-03; /fold tucks old turns away)
510    return next(e)
511  })
512
513  // ---- footer ------------------------------------------------------------
514  // The engine's `Baked for 12s` line is dropped by request: the dots line
515  // under the user row carries the counts, and the turn's cost and
516  // tokens stay in state for anything that wants them later.
517  on('ui.render', { component: 'TurnDuration' }, ($, e, next) => {
518    if (e.surface !== 'terminal') return next(e)
519    return $.ui.resolve(e).Box({ display: 'none', children: [] })
520  })
521
522  // ---- the hint line under the prompt: key reminders dropped ---------------
523  on('ui.render', { component: 'PromptHint' }, ($, e, next) => {
524    if (e.surface !== 'terminal') return next(e)
525    const h = cleanHint(e.props.hint)
526    return h ? renderHint($.ui.resolve(e), palette, h) : next(e)
527  })
528
529  // ---- spinner: the action count while tools run -------------------------
530  on('ui.render', { component: 'Spinner' }, ($, e, next) => {
531    if (e.surface !== 'terminal' || e.props.mode !== 'tool-use' || live.tools === 0) return next(e)
532    const n = live.tools
533    return next({ ...e, props: { ...e.props, suffix: `${G.ellipsis} ${G.middot} ${n} ${n === 1 ? 'action' : 'actions'}` } })
534  })
535
536  // ---- background band ---------------------------------------------------
537  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
538    if (e.surface !== 'terminal' || e.props.hasSurvey) return next(e)
539    // The band is one site for every plugin: what the plugins beneath draw
540    // stays, under the agents' frame, and alone when no agent runs.
541    const below = await next(e)
542    const list = (await read($, agents)) ?? []
543    if (list.length === 0) return below
544    const t = $.ui.resolve(e)
545    const state = (await read($, band)) ?? 'open'
546    const frame = renderBand(t, palette, {
547      agents: list,
548      now: await $.clock.now(),
549      columns: e.props.bodyColumns,
550      open: state === 'open',
551      onToggle: () => void update($, band, s => (s === 'closed' ? 'open' : 'closed')),
552      tasksCommand: commands.has('tasks') ? 'tasks' : commands.has('bashes') ? 'bashes' : null,
553    })
554    return t.Box({ flexDirection: 'column', children: [frame, below] })
555  })
556}
557
558/** Keeps the tool_use_ids of an unfolded group's calls. */
559function remember(ids: Set<string>, calls: ReadonlyArray<{ tool_use_id?: string }>): void {
560  for (const c of calls) if (typeof c.tool_use_id === 'string') ids.add(c.tool_use_id)
561}
562
563/**
564 * An Edit's or a Write's result as glass's diff card, null when there is
565 * nothing to draw. A Write that created the file has no patch: its whole
566 * content draws as added lines under `Created`.
567 */
568function editCard(t: Elements['terminal'], palette: Palette, output: unknown, input: unknown, columns: number): RenderElement | null {
569  const out = output as { structuredPatch?: unknown; filePath?: unknown; type?: unknown; content?: unknown } | undefined
570  const patch = out?.structuredPatch
571  const patched = Array.isArray(patch) ? (patch as Hunk[]).filter(h => h && typeof h.oldStart === 'number' && typeof h.newStart === 'number' && Array.isArray(h.lines)) : []
572  const created = patched.length === 0 && out?.type === 'create' && typeof out.content === 'string'
573  const hunks = created ? [{ oldStart: 0, newStart: 1, lines: (out.content as string).replace(/\n$/, '').split('\n').map(l => `+${l}`) }] : patched
574  if (hunks.length === 0) return null
575  const args = input as { file_path?: unknown } | undefined
576  const path = typeof out?.filePath === 'string' ? out.filePath : typeof args?.file_path === 'string' ? args.file_path : ''
577  return renderDiff(t, palette, hunks, { path, columns, ...(created ? { verb: 'Created' } : {}) })
578}
579
hooks/chrome.ts 1076 lines
1// Turn chrome, following SPEC.md "Turn chrome": the user row with its
2// assistant header and dots line, the tool tree, the folded group, event
3// rows and the background band. One glyph
4// family (glyphs.ts), one ruler (width.ts), one hierarchy rule: bright for
5// the live thing, `meta` for the done thing, `faint` for scaffolding,
6// saturated color on marks only.
7import type { Elements, RenderElement, RenderNode, UiPressArgument } from 'claude-code'
8
9import type { GlassAgent, GlassCall, GlassTurn } from '../types'
10import { G } from './glyphs'
11import { CODE_COLOR, highlightLine, langOf } from './highlight'
12import type { State } from './highlight'
13import { paintLine } from './output'
14import type { Palette } from './palette'
15import { measure } from './render'
16import { shellSpans } from './shell'
17import { cellWidth, clipPath } from './width'
18
19type Table = Elements['terminal']
20type Press = (e: UiPressArgument) => void
21
22// the API bounds a Text string at this many characters
23const MAX_TEXT = 10000
24
25const EDIT_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit', 'MultiEdit'])
26export function isEditTool(name: string): boolean {
27  return EDIT_TOOLS.has(name)
28}
29
30// `3:31 PM` in the machine's locale
31const clock = new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit' })
32export function clockTime(ms: number): string {
33  return clock.format(ms)
34}
35
36export function fmtDuration(ms: number): string {
37  const s = Math.round(ms / 1000)
38  if (s < 60) return `${s}s`
39  const m = Math.floor(s / 60)
40  if (m < 60) return `${m}m ${String(s % 60).padStart(2, '0')}s`
41  // an hour on: `2h 05m`, so the clock's seven cells still hold it whole
42  return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}m`
43}
44
45// a tool's wall time: tenths under ten seconds, whole seconds after
46export function fmtToolTime(ms: number): string {
47  if (ms < 10_000) return `${(ms / 1000).toFixed(1)}s`
48  return fmtDuration(ms)
49}
50
51export function fmtTokens(n: number): string {
52  if (n < 1000) return String(n)
53  if (n < 10_000) return `${(n / 1000).toFixed(1)}k`
54  if (n < 1e6) return `${Math.round(n / 1000)}k`
55  return `${(n / 1e6).toFixed(1)}M`
56}
57
58// Empryo's rule: three decimals under a cent, two from there
59export function fmtCost(usd: number): string {
60  if (usd < 0.01) return `$${usd.toFixed(3)}`
61  return `$${usd.toFixed(2)}`
62}
63
64// a Text string the API accepts: tab and newline are the only controls
65export function safeText(s: string, max = MAX_TEXT): string {
66  const clean = s.replace(/\r\n?/g, '\n').replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '')
67  return clean.length > max ? clean.slice(0, max - 1) + G.ellipsis : clean
68}
69
70// cut to `n` cells with an ellipsis
71export function clip(s: string, n: number): string {
72  if (cellWidth(s) <= n) return s
73  // no room: nothing, not a lone ellipsis a cell past the edge
74  if (n <= 0) return ''
75  let out = ''
76  for (const ch of s) {
77    if (cellWidth(out + ch) > n - 1) break
78    out += ch
79  }
80  return out + G.ellipsis
81}
82
83// pad or cut to exactly `n` cells
84function fit(s: string, n: number): string {
85  const c = clip(s, n)
86  return c + ' '.repeat(Math.max(0, n - cellWidth(c)))
87}
88
89// a path padded or cut to exactly `n` cells, cut in the middle so the file
90// name stays: `hooks/...render.ts`, not `hooks/rend...`
91function fitPath(s: string, n: number): string {
92  const c = clipPath(s, n)
93  return c + ' '.repeat(Math.max(0, n - cellWidth(c)))
94}
95
96// a row of air above a tree row, with the trunk drawn through it so the
97// tree stays one line (the owner wants a blank between runs, 2026-10-03)
98// Tree rows sit 2 cells in from each edge (owner's request, 2026-10-03:
99// the right column was flush with the terminal's edge), the same 2 the
100// prose measure leaves on the right.
101const TREE_INSET = 2
102function spaced(t: Table, p: Palette, row: RenderElement, air = true): RenderElement {
103  return t.Box({ flexDirection: 'column', marginLeft: TREE_INSET, marginRight: TREE_INSET, children: air ? [t.Text({ color: p.faint, children: [G.pipe] }), row] : [row] })
104}
105
106// the session's directory: paths under it draw relative (`SPEC.md`, not
107// the whole home path); set at session.start, empty draws paths whole
108let cwd = ''
109export function setCwd(dir: string): void {
110  cwd = dir.replace(/\/+$/, '')
111}
112export function rel(path: string): string {
113  return cwd && path.startsWith(cwd + '/') ? path.slice(cwd.length + 1) : path
114}
115
116const plural = (n: number, one: string) => `${n} ${n === 1 ? one : one + 's'}`
117const sep = ` ${G.middot} `
118
119// ---- user row ------------------------------------------------------------
120
121export type TurnView = {
122  turnId: string
123  calls: GlassCall[]
124  /** the finished turn, once it is */
125  done: GlassTurn | null
126}
127
128export type UserRow = {
129  text: string
130  submittedAt: number | null
131  /** when the turn for this prompt began; the assistant header follows once known */
132  startedAt: number | null
133  turn: TurnView | null
134  columns: number
135}
136
137// the dots: one per call in call order, grouped by consecutive tool with a
138// space between groups; `ok` green, `err` red, hollow while running. Past
139// DOTS_MAX calls the first DOTS_SHOWN draw and `+N` in `faint` stands for
140// the rest, so the summary after them is never pushed off the row (45
141// calls filled 80 columns with dots, 2026-10-06); the summary keeps every
142// count, failures included.
143const DOTS_MAX = 16
144const DOTS_SHOWN = 12
145function dots(t: Table, p: Palette, calls: GlassCall[]): RenderNode[] {
146  const out: RenderNode[] = []
147  let prev: string | null = null
148  const shown = calls.length > DOTS_MAX ? calls.slice(0, DOTS_SHOWN) : calls
149  for (const c of shown) {
150    if (prev !== null && c.tool !== prev) out.push(t.Text({ children: [' '] }))
151    prev = c.tool
152    const mark = c.status === 'running' ? G.hollow : c.status === 'failed' ? G.cross : G.disc
153    const color = c.status === 'failed' ? p.err : c.status === 'ok' ? p.ok : p.meta
154    // the dot and its tree row light together under the pointer
155    out.push(t.Text({ color, hover: { scope: callScope(c.id), backgroundColor: p.rowHover }, children: [mark] }))
156  }
157  if (shown.length < calls.length) out.push(t.Text({ color: p.faint, children: [` +${calls.length - shown.length}`] }))
158  return out
159}
160
161// `Bash x3 . Read x2`: the tools in first-seen order with their counts
162function toolCounts(calls: GlassCall[]): string {
163  const counts = new Map<string, number>()
164  for (const c of calls) counts.set(c.tool, (counts.get(c.tool) ?? 0) + 1)
165  return [...counts].map(([tool, n]) => (n > 1 ? `${toolText(tool)} ${G.times}${n}` : toolText(tool))).join(sep)
166}
167
168// ---- tool names ----------------------------------------------------------
169
170// MCP tools arrive as `mcp__<server>__<tool>`: the label drops `mcp__` and
171// splits the rest, the server drawn once in `faint` before the name. Servers
172// lose the prefixes Claude Code adds (`plugin_<plugin>_`, `claude_ai_`) and
173// a trailing `_MCP`, and read in kebab case; a name's underscore runs fold
174// to one, or to spaces under NAME_SPACES (owner's request, 2026-10-07:
175// `mcp__figma__get_design_context` read as rules, not a name). The server
176// was `meta` first, which read lighter than the faint subject on water.
177const NAME_SPACES = false
178export type ToolLabel = { server: string | null; name: string }
179export function toolLabel(tool: string): ToolLabel {
180  const m = /^mcp__(.+?)__(.+)$/.exec(tool)
181  if (!m) return { server: null, name: tool.replace(/_+/g, '_') }
182  const server = m[1]!.replace(/^plugin_[^_]+_(?=.)/, '').replace(/^claude_ai_(?=.)/, '').replace(/(?<=.)_mcp$/i, '')
183  const name = NAME_SPACES ? m[2]!.replace(/_+/g, ' ').trim() : m[2]!.replace(/_+/g, '_')
184  return { server: server.toLowerCase().replace(/_+/g, '-'), name: name || m[2]! }
185}
186export function toolText(tool: string): string {
187  const l = toolLabel(tool)
188  return l.server ? `${l.server} ${l.name}` : l.name
189}
190function toolNodes(t: Table, p: Palette, tool: string, bold: boolean): RenderNode[] {
191  const l = toolLabel(tool)
192  const name = t.Text({ color: p.tool, bold, children: [l.name] })
193  return l.server ? [t.Text({ color: p.faint, children: [l.server + ' '] }), name] : [name]
194}
195
196// `<diamond> You . 01:11 PM`, the prompt under it at the measure, then after
197// one blank row the assistant header `<fisheye> Claude . 01:14 PM` and the
198// dots line: the one place a header can sit above both the tool rows and
199// the reply, since the engine fixes the row order. Folded, the dots line
200// reads the tool counts while the turn runs and the totals once it is
201// done.
202export function renderUserRow(t: Table, p: Palette, r: UserRow): RenderElement {
203  const m = measure(r.columns)
204  const rows: RenderElement[] = [
205    // a turn separator: one blank row, a faint rule the measure wide, one
206    // more blank row, so a new prompt stands apart from the reply above
207    // (owner's request, 2026-10-03)
208    // the turn boundary is one titled rule: `<diamond> You . 3:20 PM ---`
209    // to the measure, a blank row above it (proposal 7, 2026-10-04: it
210    // replaces a bare rule with a blank row each side, two rows saved)
211    t.Box({
212      marginTop: 1,
213      children: [
214        t.Text({
215          wrap: 'truncate-end',
216          children: [
217            t.Text({ color: p.accentUser, bold: true, children: [G.diamond + ' You'] }),
218            ...(r.submittedAt === null ? [] : [t.Text({ color: p.meta, children: [sep + clockTime(r.submittedAt)] })]),
219            t.Text({ color: p.faint, children: [' ' + G.rule.repeat(Math.max(1, m - cellWidth(G.diamond + ' You' + (r.submittedAt === null ? '' : sep + clockTime(r.submittedAt))) - 1))] }),
220          ],
221        }),
222      ],
223    }),
224    t.Box({ marginLeft: 2, width: m, children: [t.Text({ wrap: 'wrap', children: [safeText(r.text)] })] }),
225  ]
226  if (r.startedAt !== null) {
227    rows.push(
228      t.Box({
229        marginTop: 1,
230        children: [
231          t.Text({
232            children: [
233              t.Text({ color: p.accent, bold: true, children: [G.fisheye + ' Claude'] }),
234              t.Text({ color: p.meta, children: [sep + clockTime(r.startedAt)] }),
235            ],
236          }),
237        ],
238      }),
239    )
240  }
241  const v = r.turn
242  if (v && v.calls.length > 0) {
243    const summary: RenderNode[] = []
244    if (v.done) {
245      summary.push(plural(v.done.tools, 'action'))
246      if (v.done.edits > 0) summary.push(sep + plural(v.done.edits, 'edit'))
247      if (v.done.failed > 0) summary.push(sep, t.Text({ color: p.err, children: [`${v.done.failed} failed`] }))
248    } else {
249      summary.push(toolCounts(v.calls))
250      const failed = v.calls.filter(c => c.status === 'failed').length
251      if (failed > 0) summary.push(sep, t.Text({ color: p.err, children: [`${failed} failed`] }))
252    }
253    // dots as sibling Texts, so each can light its row (a Text nested in a
254    // Text follows its hover group but cannot heat it)
255    const left = t.Box({
256      flexDirection: 'row',
257      flexShrink: 1,
258      children: [...dots(t, p, v.calls), t.Text({ children: ['  '] }), t.Box({ flexShrink: 1, children: [t.Text({ wrap: 'truncate-end', color: v.done ? p.meta : p.bold, children: summary })] })],
259    })
260    // no chevron and no Copy: a Button in a transcript row never received
261    // its press live (2026-10-03), so folding moved to /fold and /unfold
262    rows.push(
263      t.Box({
264        key: `dots:${v.turnId}`,
265        flexDirection: 'row',
266        marginLeft: 2,
267        hover: { backgroundColor: p.rowHover },
268        children: [t.Box({ flexDirection: 'row', flexShrink: 1, children: [left] })],
269      }),
270    )
271  }
272  return t.Box({ flexDirection: 'column', children: rows })
273}
274
275// ---- tool tree -----------------------------------------------------------
276
277export type TreeRow = {
278  tool_use_id: string
279  tool: string
280  input: unknown
281  isRunning: boolean
282  isErrored: boolean
283  isInterrupted: boolean
284  output?: unknown
285}
286
287// no `last`: knowing a turn's last call needs a subscription to the turn,
288// so every row draws the tee and the trunk runs on (0.3.10)
289export type TreeOptions = {
290  /** wall time once known */
291  durationMs: number | null
292  /** a trunk row above; false inside a run of the same tool (2026-10-04) */
293  air?: boolean
294  /** while running: the clock's first cells (clockCells); a ticker blits the rest */
295  clock?: string
296}
297
298function str(args: Record<string, unknown>, k: string): string | null {
299  return typeof args[k] === 'string' ? (args[k] as string) : null
300}
301
302// the subject of a row: a Bash command tokenized, live or done (the owner
303// liked the colors on the done row, 2026-10-05); other subjects step down
304// to faint once done
305function subjectNodes(t: Table, p: Palette, tool: string, input: unknown, live: boolean): RenderNode[] {
306  const args = (input ?? {}) as Record<string, unknown>
307  const out: RenderNode[] = []
308  const command = tool === 'Bash' ? str(args, 'command') : null
309  if (command) {
310    const text = safeText(command.replace(/\s*\n\s*/g, ' '), 400)
311    const spans = shellSpans(text, true) ?? [{ text, kind: 'plain' as const }]
312    for (const s of spans) out.push(s.kind === 'plain' ? s.text : t.Text({ color: p[s.kind], children: [s.text] }))
313    return out
314  }
315  const path = str(args, 'file_path') ?? str(args, 'path') ?? str(args, 'notebook_path')
316  const url = str(args, 'url')
317  const other = str(args, 'pattern') ?? str(args, 'query') ?? str(args, 'description') ?? str(args, 'skill') ?? str(args, 'prompt') ?? str(args, 'command')
318  // a done row steps everything after the name down to faint, paths included
319  if (other && !path) out.push(t.Text({ ...(live ? {} : { color: p.faint }), children: [safeText(other.replace(/\s*\n\s*/g, ' '), 200)] }))
320  if (path) out.push(t.Text({ color: live ? p.path : p.faint, children: [safeText(rel(path), 400)] }))
321  if (url) out.push(t.Text({ color: live ? p.url : p.faint, underline: live, children: [safeText(url, 400)] }))
322  if (other && path) out.push(t.Text({ color: live ? p.meta : p.faint, children: [sep + safeText(other.replace(/\s*\n\s*/g, ' '), 200)] }))
323  return out
324}
325
326// lines a Bash result holds, for the `. N lines` tail of a done row
327function outputLines(output: unknown): number | null {
328  if (!output || typeof output !== 'object') return null
329  const o = output as { stdout?: unknown; stderr?: unknown }
330  const text = [o.stdout, o.stderr].filter((s): s is string => typeof s === 'string' && s.trim() !== '').join('\n').replace(/\s+$/, '')
331  return text === '' ? 0 : text.split('\n').length
332}
333
334// The connector and mark that open a tree row, in a box that never shrinks.
335// A Text in a row shrinks with its siblings: when the subject overflows, the
336// row squeezed the two-cell `o ` mark to one cell and the gap after it went
337// (`oBash  cat > ...`, seen 2026-10-05). Only the subject truncates now.
338function rowHead(t: Table, ...cells: RenderElement[]): RenderElement {
339  return t.Box({ flexDirection: 'row', flexShrink: 0, children: cells })
340}
341
342// `|- v Bash  git log --oneline . 3 lines                        0.8s`
343// Connector `faint`, mark in its status color, the name in `tool` (bold
344// while live), the subject truncating (ctrl+o has the whole call), the
345// time at the right. The row lights under the pointer.
346export function renderTreeRow(t: Table, p: Palette, row: TreeRow, o: TreeOptions): RenderElement {
347  const live = row.isRunning
348  const failed = row.isErrored || row.isInterrupted
349  const mark = live ? G.hollow : failed ? G.cross : G.tick
350  const markColor = live ? p.meta : failed ? p.err : p.ok
351  const subject = subjectNodes(t, p, row.tool, row.input, live)
352  const tail: RenderNode[] = []
353  if (!live && row.tool === 'Bash') {
354    const n = outputLines(row.output)
355    if (n !== null && n > 0) tail.push(t.Text({ color: p.faint, children: [sep + plural(n, 'line')] }))
356  }
357  // under a tenth of a second the time says nothing (`0.0s`): none
358  const right = o.durationMs === null || o.durationMs < 100 ? '' : fmtToolTime(o.durationMs)
359  return spaced(t, p, t.Box({
360    key: `row:${row.tool_use_id}`,
361    flexDirection: 'row',
362    hover: { scope: callScope(row.tool_use_id), backgroundColor: p.rowHover },
363    children: [
364      rowHead(t, t.Text({ color: p.faint, children: [G.tee + G.rule + ' '] }), t.Text({ color: markColor, children: [mark + ' '] })),
365      t.Box({
366        flexGrow: 1,
367        flexShrink: 1,
368        children: [
369          t.Text({
370            wrap: 'truncate-end',
371            children: [...toolNodes(t, p, row.tool, live), ...(subject.length ? ['  ', ...subject] : []), ...tail],
372          }),
373        ],
374      }),
375      ...(live && o.clock && 'Raster' in t
376        ? [t.Box({ flexShrink: 0, marginLeft: 2, children: [t.Raster({ key: 'clock', columns: CLOCK_CELLS, rows: 1, cells: o.clock })] })]
377        : right
378          ? [t.Box({ flexShrink: 0, marginLeft: 2, children: [t.Text({ color: live ? p.meta : p.faint, children: [right] })] })]
379          : []),
380    ],
381  }), o.air ?? true)
382}
383
384// cells a body row leaves for the trunk column before its content
385const TRUNK = 3
386
387// A body row under a tree row: the trunk down its left, `h` rows tall,
388// the content indented under the row's mark.
389function trunked(t: Table, p: Palette, h: number, content: RenderNode[]): RenderElement {
390  return t.Box({
391    flexDirection: 'row',
392    marginLeft: TREE_INSET,
393    marginRight: TREE_INSET,
394    children: [
395      t.Box({ width: TRUNK, flexShrink: 0, children: [t.Text({ color: p.faint, children: [Array(Math.max(1, h)).fill(G.pipe).join('\n')] })] }),
396      t.Box({ flexGrow: 1, flexShrink: 1, flexDirection: 'column', children: content }),
397    ],
398  })
399}
400
401// characters of one output line kept: more than any terminal row shows
402const OUTPUT_LINE_MAX = 1000
403
404// a line as a terminal leaves it: a carriage return (a progress bar's
405// `50%\r100%`) starts the line over, so only the last pass shows
406function overstruck(line: string): string {
407  const s = line.replace(/\r+$/, '')
408  return s.slice(s.lastIndexOf('\r') + 1)
409}
410
411export type OutputOptions = {
412  columns: number
413  /** lines past this fold to a `faint` count */
414  maxLines: number
415}
416
417// The engine's Bash result body, redrawn so the output can be painted (the
418// engine strips escape codes from a rewritten result, so colors have to be
419// Text) and so the trunk runs through it: one trunked row per line, the
420// rest folded to `+N lines`. (The dentistry-symbol connector and the engine's own
421// collapsed body for long output were the first design; both broke the
422// tree's line, owner's request 2026-10-03.)
423export function renderToolOutput(t: Table, p: Palette, lines: string[], o: OutputOptions = { columns: 80, maxLines: lines.length }): RenderElement {
424  const shown = lines.slice(0, o.maxLines)
425  // one row per output line, clipped: a long line no longer doubles the
426  // body's height, and ctrl+o has the whole output (2026-10-04)
427  // each line made a string the API takes: no control characters (a `\b`
428  // or a bell refused the whole tree) and cut well past any terminal's
429  // width, since the row clips anyway (a minified JSON line ran 16000)
430  const rows = shown.map(line =>
431    trunked(t, p, 1, [
432      t.Text({
433        wrap: 'truncate-end',
434        children: paintLine(safeText(overstruck(line), OUTPUT_LINE_MAX)).map(s => (s.color ? t.Text({ color: p[s.color], children: [s.text] }) : t.Text({ color: p.meta, children: [s.text] }))),
435      }),
436    ]),
437  )
438  const rest = lines.length - shown.length
439  if (rest > 0) rows.push(trunked(t, p, 1, [t.Text({ color: p.faint, children: [G.ellipsis + ` +${plural(rest, 'line')}`] })]))
440  return t.Box({ flexDirection: 'column', children: rows })
441}
442
443// ---- an Edit's diff ------------------------------------------------------
444
445export type Hunk = { oldStart: number; newStart: number; lines: string[] }
446
447export type DiffOptions = {
448  /** the file's path, for the highlighter's language */
449  path: string
450  /** the terminal's width */
451  columns: number
452  /** `Created` or `Deleted` before the path in the title; absent for an update */
453  verb?: string
454}
455
456// rows a diff body draws before folding the rest into a count
457const MAX_DIFF_ROWS = 200
458const DIFF_MIN = 60
459
460// An Edit's `structuredPatch` as glass draws it, in place of the engine's
461// own diff panel (owner's request, 2026-10-03, after Empryo's card): a
462// rounded `faint` frame on the trunk column, sized to the hunks, one number column (the new
463// number on a kept or added line, the old on a removed one) in `faint`,
464// `+` in `ok` on an `addBg` row, `-` in `err` on a `delBg` row, the code
465// painted by glass's highlighter, an ellipsis row between hunks.
466export function renderDiff(t: Table, p: Palette, hunks: ReadonlyArray<Hunk>, o: DiffOptions): RenderElement {
467  const lang = langOf(o.path)
468  const last = Math.max(1, ...hunks.flatMap(h => [h.oldStart + h.lines.length, h.newStart + h.lines.length]))
469  const digits = String(last).length
470  type Piece = { text: string; color?: string }
471  type Line = { kind: 'add' | 'del' | 'ctx' | 'note'; num: number | null; pieces: Piece[] }
472  const lines: Line[] = []
473  let widest = 0
474  let total = 0
475  let hidden = 0
476  let add = 0
477  let del = 0
478  hunks.forEach((h, i) => {
479    const state: State = { inBlock: false }
480    // one context line before the first change and one after the last;
481    // context between changes stays, and the numbers keep counting
482    const changed = h.lines.map((l, k) => (l[0] === '+' || l[0] === '-' ? k : -1)).filter(k => k >= 0)
483    const lo = changed.length ? changed[0]! - 1 : -Infinity
484    const hi = changed.length ? changed[changed.length - 1]! + 1 : Infinity
485    let k = -1
486    if (i > 0 && total < MAX_DIFF_ROWS) lines.push({ kind: 'note', num: null, pieces: [{ text: G.ellipsis, color: p.faint }] })
487    let old = h.oldStart
488    let cur = h.newStart
489    for (const raw of h.lines) {
490      k++
491      const head = raw[0]
492      const kind = head === '+' ? 'add' : head === '-' ? 'del' : head === '\\' ? 'note' : 'ctx'
493      const num = kind === 'del' ? old : cur
494      if (kind === 'ctx' || kind === 'del') old++
495      if (kind === 'ctx' || kind === 'add') cur++
496      if (kind === 'add') add++
497      if (kind === 'del') del++
498      if (kind === 'ctx' && (k < lo || k > hi)) continue
499      total++
500      if (total > MAX_DIFF_ROWS) {
501        hidden++
502        continue
503      }
504      // tabs expand here: the engine and the terminal disagree on a tab's width
505      const body = safeText(raw.slice(1), 2000).replace(/\t/g, '    ')
506      widest = Math.max(widest, cellWidth(body))
507      if (kind === 'note') {
508        lines.push({ kind, num: null, pieces: [{ text: body, color: p.faint }] })
509        continue
510      }
511      const pieces = highlightLine(body, lang, state).map(s => (s.kind === 'plain' ? { text: s.text } : { text: s.text, color: p[CODE_COLOR[s.kind]] }))
512      lines.push({ kind, num, pieces })
513    }
514  })
515  if (hidden > 0) lines.push({ kind: 'note', num: null, pieces: [{ text: G.ellipsis + ` +${plural(hidden, 'line')} (ctrl+o to expand)`, color: p.faint }] })
516
517  const name = safeText(rel(o.path), 400)
518  const counts = (add > 0 ? ` +${add}` : '') + (del > 0 ? ` -${del}` : '')
519  const verb = o.verb ? o.verb + ' ' : ''
520  const cap = Math.max(20, o.columns - 2 * TREE_INSET)
521  const width = Math.min(cap, Math.max(DIFF_MIN, widest + digits + 3 + 4, cellWidth(verb + name + counts) + 8))
522  // inside the frame: a border and a padding cell each side
523  const inner = width - 4
524  const gutter = digits + 3
525  const room = Math.max(1, inner - gutter)
526
527  // The frame is drawn by glass, row by row, so the file can sit in the top
528  // border, `SPEC.md +1 -1` between the arcs (owner's pick, 2026-10-03; an absolute
529  // title over a Box border is clipped by the engine). glass breaks every
530  // code line at the room itself, so the engine never wraps inside the card
531  // and each row carries its own two edges: no glyph column guesses a height.
532  const edge = (s: string) => t.Text({ color: p.faint, children: [s] })
533  const out: RenderElement[] = []
534  const fixed = 3 + cellWidth(verb + counts) + 3
535  const shown = o.path ? clipPath(name, Math.max(4, width - fixed)) : ''
536  const titleW = o.path ? cellWidth(verb + shown + counts) + 1 : 0
537  out.push(
538    t.Text({
539      children: [
540        edge(G.arcTL + G.rule + (o.path ? ' ' : '')),
541        ...(o.path
542          ? [
543              ...(verb ? [t.Text({ color: p.meta, children: [verb] })] : []),
544              t.Text({ color: p.path, children: [shown] }),
545              ...(add > 0 ? [' ', t.Text({ color: p.ok, children: [`+${add}`] })] : []),
546              ...(del > 0 ? [' ', t.Text({ color: p.err, children: [`-${del}`] })] : []),
547              ' ',
548            ]
549          : []),
550        edge(G.rule.repeat(Math.max(1, width - 3 - (o.path ? 1 : 0) - titleW)) + G.arcTR),
551      ],
552    }),
553  )
554  for (const l of lines) {
555    const bg = l.kind === 'add' ? p.addBg : l.kind === 'del' ? p.delBg : undefined
556    const mark = l.kind === 'add' ? '+' : l.kind === 'del' ? '-' : ' '
557    const markColor = l.kind === 'add' ? p.ok : l.kind === 'del' ? p.err : p.faint
558    chunk(l.pieces, room).forEach((part, n) => {
559      const used = part.reduce((w, x) => w + cellWidth(x.text), 0)
560      const head: RenderNode[] =
561        n === 0 && l.num !== null
562          ? [t.Text({ color: bg ? p.meta : p.faint, children: [String(l.num).padStart(digits) + ' '] }), t.Text({ color: markColor, children: [mark + ' '] })]
563          : [' '.repeat(gutter)]
564      out.push(
565        t.Box({
566          flexDirection: 'row',
567          children: [
568            edge(G.pipe + ' '),
569            t.Box({
570              width: inner,
571              flexShrink: 0,
572              ...(bg ? { backgroundColor: bg } : {}),
573              children: [
574                t.Text({
575                  wrap: 'truncate-end',
576                  children: [...head, ...part.map(x => (x.color ? t.Text({ color: x.color, children: [x.text] }) : x.text)), ' '.repeat(Math.max(0, room - used))],
577                }),
578              ],
579            }),
580            edge(' ' + G.pipe),
581          ],
582        }),
583      )
584    })
585  }
586  out.push(edge(G.arcBL + G.rule.repeat(width - 2) + G.arcBR))
587  // the card sits on the trunk column: its left edge is the trunk
588  return t.Box({ marginLeft: TREE_INSET, marginRight: TREE_INSET, width, flexDirection: 'column', children: out })
589}
590
591// colored pieces cut into rows of at most `w` cells, by the engine's ruler
592function chunk(pieces: ReadonlyArray<{ text: string; color?: string }>, w: number): { text: string; color?: string }[][] {
593  const rows: { text: string; color?: string }[][] = [[]]
594  let used = 0
595  for (const piece of pieces) {
596    let buf = ''
597    for (const ch of piece.text) {
598      const cw = cellWidth(ch)
599      if (used + cw > w && used > 0) {
600        if (buf) rows[rows.length - 1]!.push({ ...piece, text: buf })
601        rows.push([])
602        buf = ''
603        used = 0
604      }
605      buf += ch
606      used += cw
607    }
608    if (buf) rows[rows.length - 1]!.push({ ...piece, text: buf })
609  }
610  return rows
611}
612
613// ---- a Bash result that changed files ------------------------------------
614
615export type ChangedFile = { filePath: string; hunks: Hunk[]; created?: true; deleted?: true }
616
617export type BashResultOptions = {
618  /** the painted body shows this many lines before folding the rest */
619  maxLines: number
620  moreFiles: number
621  columns: number
622}
623
624// A Bash result whose command rewrote files: the output body as the
625// standalone one draws (folded past `maxLines` to a `faint` count), then
626// per file, after one trunked row of air, a header `Updated <path> +a -b` and the
627// same rounded diff card an Edit gets (owner's request, 2026-10-03: the
628// engine's own panel for these was the one pink thing left).
629export function renderBashResult(t: Table, p: Palette, lines: string[], files: ReadonlyArray<ChangedFile>, o: BashResultOptions): RenderElement {
630  const rows: RenderElement[] = []
631  const air = () => trunked(t, p, 1, [t.Text({ children: [''] })])
632  if (lines.length > 0) rows.push(renderToolOutput(t, p, lines, { columns: o.columns, maxLines: o.maxLines }))
633  for (const f of files) {
634    const verb = f.created ? 'Created' : f.deleted ? 'Deleted' : undefined
635    rows.push(air())
636    // the card's top border names the file; a change with no hunks keeps a header line
637    if (f.hunks.length > 0) {
638      rows.push(renderDiff(t, p, f.hunks, { path: f.filePath, columns: o.columns, ...(verb ? { verb } : {}) }))
639      continue
640    }
641    rows.push(
642      trunked(t, p, 1, [
643        t.Text({
644          wrap: 'truncate-end',
645          children: [
646            t.Text({ color: p.meta, children: [(verb ?? 'Updated') + ' '] }),
647            t.Text({ color: p.path, children: [safeText(rel(f.filePath), 400)] }),
648          ],
649        }),
650      ]),
651    )
652  }
653  if (o.moreFiles > 0) {
654    rows.push(air())
655    rows.push(trunked(t, p, 1, [t.Text({ color: p.faint, children: [G.ellipsis + ` +${o.moreFiles} more file${o.moreFiles === 1 ? '' : 's'}`] })]))
656  }
657  return t.Box({ flexDirection: 'column', children: rows })
658}
659
660// ---- folded group --------------------------------------------------------
661
662export type GroupCall = { tool: string; input?: unknown; isRunning: boolean; isErrored: boolean; isInterrupted: boolean }
663
664export type GroupOptions = {
665  isActive: boolean
666  /** the row's key, which its hover tint needs; null draws it untinted */
667  key: string | null
668}
669
670// `|- > Read x3  a.ts . b.ts [1 edit]`, all `faint`, the mark in `meta`; a
671// failed count in `err`; a live group ends in an ellipsis. No fold button:
672// a Button in a transcript row never received its press live (2026-10-03),
673// so /expand opens every group. Each MCP server takes a line of its own,
674// named once at its head, under one hover (owner's request, 2026-10-07).
675export function renderGroupRow(t: Table, p: Palette, calls: ReadonlyArray<GroupCall>, o: GroupOptions): RenderElement {
676  const runs = new Map<string, GroupCall[]>()
677  for (const c of calls) {
678    const server = toolLabel(c.tool).server ?? ''
679    const run = runs.get(server)
680    if (run) run.push(c)
681    else runs.set(server, [c])
682  }
683  const lines = [...runs].map(([server, run]) => groupLine(t, p, server, run, o.isActive && (runs.size === 1 || run.some(c => c.isRunning))))
684  return spaced(t, p, t.Box({
685    ...(o.key ? { key: `group:${o.key}`, hover: { backgroundColor: p.rowHover } } : {}),
686    flexDirection: 'column',
687    children: lines,
688  }))
689}
690
691function groupLine(t: Table, p: Palette, server: string, calls: GroupCall[], live: boolean): RenderElement {
692  const edits = calls.filter(c => isEditTool(c.tool)).length
693  const failed = calls.filter(c => c.isErrored || c.isInterrupted).length
694  // what the run touched: the tools with counts, then each subject once
695  const counts = new Map<string, number>()
696  for (const c of calls) {
697    const name = toolLabel(c.tool).name
698    counts.set(name, (counts.get(name) ?? 0) + 1)
699  }
700  const tools = [...counts].map(([tool, n]) => (n > 1 ? `${tool} ${G.times}${n}` : tool)).join(sep)
701  const subjects = [...new Set(calls.map(c => {
702    const a = (c.input ?? {}) as Record<string, unknown>
703    const s = str(a, 'file_path') ?? str(a, 'path') ?? str(a, 'pattern') ?? str(a, 'query') ?? str(a, 'url') ?? clip((str(a, 'command') ?? '').replace(/\s+/g, ' '), 60)
704    return safeText(rel(s).replace(/\s+/g, ' '), 200)
705  }).filter(s => s !== ''))]
706  const parts: RenderNode[] = server ? [t.Text({ color: p.faint, children: [server + ' '] })] : []
707  parts.push(t.Text({ color: p.tool, children: [tools] }))
708  if (subjects.length) parts.push('  ' + subjects.join(sep))
709  if (edits > 0) parts.push(` [${plural(edits, 'edit')}]`)
710  if (failed > 0) parts.push(sep, t.Text({ color: p.err, children: [`${failed} failed`] }))
711  const tail = live ? sep + `${calls.filter(c => c.isRunning).length} running` + G.ellipsis : ''
712  return t.Box({
713    flexDirection: 'row',
714    children: [
715      rowHead(t, t.Text({ color: p.faint, children: [G.tee + G.rule + ' '] }), t.Text({ color: p.meta, children: [G.right + ' '] })),
716      t.Box({ flexShrink: 1, children: [t.Text({ wrap: 'truncate-end', color: p.faint, children: [...parts, tail] })] }),
717    ],
718  })
719}
720
721// ---- events after the turn -----------------------------------------------
722
723export type EventTask = { status?: string; durationMs?: number }
724
725// a background task's notification as one tree row:
726// `|- v Agent "Research Empryo" finished                         1m 12s`
727// A Box with a hover style must carry a key, or the validator refuses the
728// whole tree and the engine draws its own row. The key is the message's
729// own id: two notifications with the same first line must not share one.
730// Every event row draws the tee, as tree rows do: knowing the last needs a
731// subscription to the session's messages.
732export function renderEventRow(t: Table, p: Palette, id: string, text: string, task: EventTask | undefined, expanded = false): RenderElement {
733  const linesAll = text.split('\n')
734  const at = linesAll.findIndex(l => l.trim() !== '')
735  const first = safeText(linesAll[at] ?? '', 400)
736  const rest = expanded ? safeText(linesAll.slice(at + 1).join('\n').trim(), MAX_TEXT) : ''
737  const status = (task?.status ?? '').toLowerCase()
738  const failed = /fail|error|kill|cancel/.test(status)
739  // under a tenth of a second the time says nothing, as on a tree row
740  const right = typeof task?.durationMs === 'number' && task.durationMs >= 100 ? fmtToolTime(task.durationMs) : ''
741  // `Agent "Count hook source lines" finished` draws as a tool row
742  const agent = /^Agent "(.+)" \w+/.exec(first)
743  const row = spaced(t, p, t.Box({
744    key: `event:${id}`,
745    flexDirection: 'row',
746    hover: { backgroundColor: p.rowHover },
747    children: [
748      rowHead(t, t.Text({ color: p.faint, children: [G.tee + G.rule + ' '] }), t.Text({ color: failed ? p.err : p.ok, children: [(failed ? G.cross : G.tick) + ' '] })),
749      t.Box({ flexGrow: 1, flexShrink: 1, children: [t.Text({ wrap: 'truncate-end', color: p.meta, children: agent ? [t.Text({ color: p.tool, children: ['Agent'] }), '  ' + agent[1]!] : [first] })] }),
750      ...(right ? [t.Box({ flexShrink: 0, marginLeft: 2, children: [t.Text({ color: p.faint, children: [right] })] })] : []),
751    ],
752  }))
753  if (rest === '') return row
754  return t.Box({
755    flexDirection: 'column',
756    children: [row, t.Box({ marginLeft: 5, children: [t.Text({ wrap: 'wrap', color: p.meta, children: [rest] })] })],
757  })
758}
759
760// a message another agent sent, folded: `|- . Message from @Explore` in
761// `private`, with the engine's own hint at the right; ctrl+o shows the body
762export function renderMessageRow(t: Table, p: Palette, name: string): RenderElement {
763  return spaced(t, p, t.Box({
764    key: `message:${name}`,
765    flexDirection: 'row',
766    hover: { backgroundColor: p.rowHover },
767    children: [
768      rowHead(t, t.Text({ color: p.faint, children: [G.tee + G.rule + ' '] }), t.Text({ color: p.faint, children: [G.ring + ' '] })),
769      t.Box({ flexGrow: 1, flexShrink: 1, children: [t.Text({ wrap: 'truncate-end', color: p.private, children: [`Message from @${safeText(name, 80)}`] })] }),
770      t.Box({ flexShrink: 0, marginLeft: 2, children: [t.Text({ color: p.faint, children: ['ctrl+o'] })] }),
771    ],
772  }))
773}
774
775// ---- background band -----------------------------------------------------
776
777const BAND_ROWS = 5
778const COL_NAME = 20
779const COL_MODEL = 22
780const COL_STAGE = 14
781const COL_TOKENS = 8
782
783export type BandOptions = {
784  agents: GlassAgent[]
785  now: number
786  columns: number
787  open: boolean
788  onToggle: Press | null
789  /** the slash command that lists tasks, when the session has one */
790  tasksCommand: string | null
791}
792
793// the agent's face: eyes level while it thinks, lowered while it reads
794function face(a: GlassAgent): string {
795  const eye = G.disc
796  const mouth = a.stage.startsWith('thinking') ? G.smile : G.down
797  return `(${eye}${mouth}${eye})`
798}
799
800function shortModel(model: string): string {
801  return model.replace(/^claude-/, '').replace(/-\d{8}$/, '')
802}
803
804// A rounded frame the band's width, the title in its top edge and the
805// oldest agent's elapsed time at the right; one row per live agent, newest
806// last, five at most and `+N more`; the bottom edge carries the hint.
807// Closed, one strip: the title, the faces, the newest agent's stage.
808export function renderBand(t: Table, p: Palette, o: BandOptions): RenderElement {
809  const { agents, now } = o
810  // the engine draws its `[-]` collapse mark over the band's top-right
811  // cells; the frame stops four short of it (2026-10-04)
812  const w = Math.max(40, o.columns - 4)
813  const oldest = agents.reduce((n, a) => Math.min(n, a.startedAt), now)
814  const elapsed = fmtDuration(Math.max(0, now - oldest))
815  const titleText = `background${sep}${agents.length}`
816  const chevron = o.onToggle
817    ? t.Button({ key: 'band:toggle', label: o.open ? G.down : G.right, plain: true, onPress: o.onToggle })
818    : t.Text({ color: p.accent, children: [o.open ? G.down : G.right] })
819  const title = t.Text({
820    children: [
821      t.Text({ color: p.accent, children: [G.dotted + ' '] }),
822      t.Text({ color: p.accent, bold: true, children: ['background'] }),
823      t.Text({ color: p.meta, children: [sep] }),
824      t.Text({ color: p.accent, bold: true, children: [String(agents.length)] }),
825      ' ',
826    ],
827  })
828
829  if (!o.open) {
830    // `- o background . 3 v - (o_o) (o_o)  Research Empryo . thinking... ----- 1m 25s -`
831    // the fill is measured from the strings around it, so the strip is
832    // exactly the band wide
833    const newest = agents[agents.length - 1]!
834    const lead = `${G.rule} `
835    const titleStr = `${G.dotted} background${sep}${agents.length} `
836    const mid = ` ${G.rule} `
837    const tail = ` ${elapsed} ${G.rule}`
838    const fixedFor = (faces: string) => cellWidth(lead + titleStr) + 1 + cellWidth(mid + faces) + 2 + 1 + cellWidth(tail)
839    // a narrow terminal drops the oldest faces, all of them if it must, so
840    // 16 cells stay for the name and the fill; the name then takes what is
841    // left but one
842    let shown = agents.slice(-BAND_ROWS)
843    while (shown.length > 0 && w - fixedFor(shown.map(face).join(' ')) < 16) shown = shown.slice(1)
844    const faces = shown.map(face).join(' ')
845    const fixed = fixedFor(faces)
846    const room = w - fixed
847    const name = clip(`${a11(newest.description)}${sep}${newest.stage}`, room >= 16 ? room - 8 : Math.max(0, room - 1))
848    const fill = Math.max(1, w - fixed - cellWidth(name))
849    return t.Box({
850      flexDirection: 'row',
851      width: w,
852      children: [
853        t.Text({ color: p.accent, children: [lead] }),
854        title,
855        chevron,
856        t.Text({ color: p.accent, children: [mid] }),
857        t.Text({ color: p.warn, children: [faces] }),
858        t.Text({ color: p.meta, children: [`  ${name} `] }),
859        t.Text({ color: p.accent, children: [G.rule.repeat(fill)] }),
860        t.Text({ color: p.meta, children: [` ${elapsed} `] }),
861        t.Text({ color: p.accent, children: [G.rule] }),
862      ],
863    })
864  }
865
866  // the top edge: arc, rule, the title, rules to the elapsed time, arc
867  const topUsed = cellWidth(`${G.arcTL}${G.rule} ${G.dotted} background${sep}${agents.length} `) + 1 + 1 + cellWidth(` ${elapsed} ${G.rule}${G.arcTR}`)
868  const top = t.Box({
869    flexDirection: 'row',
870    children: [
871      t.Text({ color: p.accent, children: [G.arcTL + G.rule + ' '] }),
872      title,
873      chevron,
874      t.Text({ color: p.accent, children: [' ' + G.rule.repeat(Math.max(1, w - topUsed))] }),
875      t.Text({ color: p.meta, children: [` ${elapsed} `] }),
876      t.Text({ color: p.accent, children: [G.rule + G.arcTR] }),
877    ],
878  })
879  const edge = (s: string) => t.Text({ color: p.accent, children: [s] })
880  const inner = w - 4
881  const shown = agents.slice(-BAND_ROWS)
882  const more = agents.length - shown.length
883  const cols = bandColumns(inner)
884  const rows = shown.map(a => {
885    const modelText = `${shortModel(a.model)}${a.effort ? sep + a.effort : ''}`
886    return t.Box({
887      flexDirection: 'row',
888      children: [
889        edge(G.pipe + ' '),
890        t.Text({
891          children: [
892            t.Text({ color: p.warn, children: [G.diamond + ' ' + face(a) + ' '] }),
893            t.Text({ color: p.bold, bold: true, children: [fit(a11(a.description), cols.name)] }),
894            ' ',
895            t.Text({ color: p.accent, children: [fit(modelText, cols.model)] }),
896            ' ',
897            t.Text({ color: p.meta, children: [fit(a.stage, cols.stage)] }),
898            ' ',
899            t.Text({ color: p.meta, children: [fit(a.tokens > 0 ? fmtTokens(a.tokens) : '', cols.tokens)] }),
900            ' ',
901            t.Text({ color: p.faint, children: [fitPath(rel(a.file), cols.file)] }),
902          ],
903        }),
904        edge(' ' + G.pipe),
905      ],
906    })
907  })
908  if (more > 0) {
909    rows.push(
910      t.Box({
911        flexDirection: 'row',
912        children: [edge(G.pipe + ' '), t.Text({ color: p.faint, children: [fit(`  +${more} more`, inner)] }), edge(' ' + G.pipe)],
913      }),
914    )
915  }
916  const hint = 'They outlive this turn'
917  const cmd = o.tasksCommand ? `/${o.tasksCommand}` : ''
918  const bottomUsed = 2 + 1 + cellWidth(hint) + 1 + (cmd ? cellWidth(cmd) + 2 : 0) + 2
919  const bottom = t.Box({
920    flexDirection: 'row',
921    children: [
922      t.Text({ color: p.accent, children: [G.arcBL + G.rule + ' '] }),
923      t.Text({ color: p.faint, children: [hint + ' '] }),
924      t.Text({ color: p.accent, children: [G.rule.repeat(Math.max(1, w - bottomUsed))] }),
925      ...(cmd ? [t.Text({ color: p.meta, children: [` ${cmd} `] })] : []),
926      t.Text({ color: p.accent, children: [G.rule + G.arcBR] }),
927    ],
928  })
929  return t.Box({ flexDirection: 'column', width: w, children: [top, ...rows, bottom] })
930}
931
932// A band row's column widths inside `inner` cells: the diamond, a space,
933// the five-cell face and a space (8), then name, model, stage, tokens and
934// file with one space after each of the first four. The file takes what is
935// left; on a narrow terminal the model gives up cells first, then the
936// name, the tokens column, the stage, and the name and model again, so a
937// row never outgrows its frame.
938function bandColumns(inner: number): { name: number; model: number; stage: number; tokens: number; file: number } {
939  const c = { name: COL_NAME, model: COL_MODEL, stage: COL_STAGE, tokens: COL_TOKENS }
940  let over = 8 + 4 + c.name + c.model + c.stage + c.tokens - inner
941  const give = (k: keyof typeof c, min: number) => {
942    const d = Math.max(0, Math.min(over, c[k] - min))
943    c[k] -= d
944    over -= d
945  }
946  give('model', 8)
947  give('name', 8)
948  give('tokens', 0)
949  give('stage', 6)
950  give('model', 1)
951  give('name', 1)
952  return { ...c, file: Math.max(0, -over) }
953}
954
955// a description as one printable line
956function a11(s: string): string {
957  return safeText(s.replace(/\s+/g, ' ').trim(), 120)
958}
959
960// A backgrounded Agent's result, in place of the engine's body (its corner
961// bracket broke the trunk, 2026-10-04): one trunked line in `faint`.
962export function renderAgentLaunch(t: Table, p: Palette, remote: boolean): RenderElement {
963  const text = remote ? `running remotely${sep}/tasks` : `running in the background${sep}${G.arrowDown} to manage`
964  return trunked(t, p, 1, [t.Text({ color: p.faint, wrap: 'truncate-end', children: [text] })])
965}
966
967// A Bash command that printed nothing, in place of the engine's body (its
968// corner bracket broke the trunk, 2026-10-06): one trunked line in `faint`.
969export function renderNoOutput(t: Table, p: Palette): RenderElement {
970  return trunked(t, p, 1, [t.Text({ color: p.faint, wrap: 'truncate-end', children: ['(No output)'] })])
971}
972
973// A loaded skill, in place of the engine's body (its corner bracket sat off
974// the trunk, 2026-10-09): one trunked line in `faint`, with the engine's
975// tool count and model where it gives them. A forked skill draws as an
976// Agent's launch.
977export function renderSkillLoad(t: Table, p: Palette, output: unknown): RenderElement {
978  const o = (output && typeof output === 'object' ? output : {}) as { status?: unknown; background?: unknown; allowedTools?: unknown; model?: unknown }
979  if (o.status === 'forked') return o.background === true ? renderAgentLaunch(t, p, false) : trunked(t, p, 1, [t.Text({ color: p.faint, children: ['done'] })])
980  const parts = ['skill loaded']
981  if (Array.isArray(o.allowedTools) && o.allowedTools.length > 0) parts.push(`${plural(o.allowedTools.length, 'tool')} allowed`)
982  if (typeof o.model === 'string' && o.model !== '') parts.push(safeText(o.model, 80))
983  return trunked(t, p, 1, [t.Text({ color: p.faint, wrap: 'truncate-end', children: [parts.join(sep)] })])
984}
985
986// A failed non-Bash call's error, in place of the engine's body (its corner
987// bracket broke the trunk, 2026-10-06): the reason's first line in `err`,
988// the rest a `faint` count; ctrl+o has it whole.
989export function renderToolError(t: Table, p: Palette, text: string): RenderElement {
990  const lines = text.split('\n').map(l => l.trim()).filter(l => l !== '')
991  const rows = [trunked(t, p, 1, [t.Text({ color: p.err, wrap: 'truncate-end', children: [safeText(lines[0] ?? 'Error', 400)] })])]
992  if (lines.length > 1) rows.push(trunked(t, p, 1, [t.Text({ color: p.faint, children: [G.ellipsis + ` +${plural(lines.length - 1, 'line')}`] })]))
993  return t.Box({ flexDirection: 'column', children: rows })
994}
995
996// ---- live clock ----------------------------------------------------------
997
998/** the hover group a call's dot and its tree row share */
999export function callScope(id: string): string {
1000  return ('glass:' + id).slice(0, 64)
1001}
1002
1003/** cells the running row's clock takes: `12m 04s` at most */
1004export const CLOCK_CELLS = 7
1005// 99h 59m 59s, the longest time seven cells hold
1006const CLOCK_MAX_MS = ((99 * 60 + 59) * 60 + 59) * 1000
1007
1008const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
1009function base64(bytes: Uint8Array): string {
1010  let out = ''
1011  for (let i = 0; i < bytes.length; i += 3) {
1012    const a = bytes[i]!
1013    const b = i + 1 < bytes.length ? bytes[i + 1]! : 0
1014    const c = i + 2 < bytes.length ? bytes[i + 2]! : 0
1015    const n = (a << 16) | (b << 8) | c
1016    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]!
1017    out += i + 1 < bytes.length ? B64[(n >> 6) & 63]! : '='
1018    out += i + 2 < bytes.length ? B64[n & 63]! : '='
1019  }
1020  return out
1021}
1022
1023// A Raster's cells for the elapsed time, right-aligned in CLOCK_CELLS: each
1024// cell a [codePoint, fg, bg] triplet of little-endian u32, the background
1025// the terminal's own (bit 24 alone). ASCII digits and letters only.
1026export function clockCells(ms: number, fg: string): string {
1027  // past 99h 59m the clock holds there: `100h 00m` is a cell too wide
1028  const text = fmtDuration(Math.min(Math.max(0, ms), CLOCK_MAX_MS)).padStart(CLOCK_CELLS)
1029  const color = /^#[0-9a-f]{6}$/i.test(fg) ? parseInt(fg.slice(1), 16) : 0x01000000
1030  const words = new Uint32Array(CLOCK_CELLS * 3)
1031  for (let i = 0; i < CLOCK_CELLS; i++) {
1032    words[i * 3] = text.charCodeAt(i)
1033    words[i * 3 + 1] = color
1034    words[i * 3 + 2] = 0x01000000
1035  }
1036  return base64(new Uint8Array(words.buffer))
1037}
1038
1039// ---- the hint line under the prompt ----------------------------------------
1040
1041// The engine's hint, its key reminders dropped (owner's request, 2026-10-04:
1042// keep the line under the prompt clean): `(shift+tab to cycle)` and
1043// `<left arrow> for agents` go. The vim mode comes first, wherever the engine
1044// put it (live: `<mode> . -- INSERT --`), then the permission mode. Null when
1045// the hint has neither and nothing was dropped (`? for shortcuts`), so the
1046// engine keeps its own line.
1047export function cleanHint(hint: string): { vim: string; rest: string } | null {
1048  const dropped = hint
1049    .replace(/\s*\((?:shift|ctrl|alt|meta)\+[a-z]+ to cycle\)/gi, '')
1050    .replace(new RegExp('\\s*' + G.middot + '?\\s*' + G.arrowLeft + '\\s*for agents', 'g'), '')
1051  const vimMatch = /--\s*[A-Z]+\s*--/.exec(dropped)
1052  const vim = vimMatch ? vimMatch[0] : ''
1053  const sepRe = new RegExp('^[\\s' + G.middot + ']+|[\\s' + G.middot + ']+$', 'g')
1054  const rest = (vim ? dropped.replace(vim, ' ') : dropped).replace(new RegExp('\\s*' + G.middot + '\\s*' + G.middot + '\\s*', 'g'), ' ' + G.middot + ' ').replace(sepRe, '')
1055  const changed = dropped !== hint
1056  if (!changed && !vim && !MODE_RE.test(rest)) return null
1057  return { vim, rest }
1058}
1059
1060const MODE_RE = /mode on|accept edits on|plan mode/i
1061
1062// the vim mode in `accentUser` (the person's own color: typing is theirs),
1063// the permission mode colored by what it means: auto and bypass `warn`, plan
1064// `accent`, accepting edits `ok`; anything else `meta`
1065export function renderHint(t: Table, p: Palette, h: { vim: string; rest: string }): RenderElement {
1066  const modeColor = /auto|bypass/i.test(h.rest) ? p.warn : /plan/i.test(h.rest) ? p.accent : /accept/i.test(h.rest) ? p.ok : p.meta
1067  return t.Text({
1068    wrap: 'truncate-end',
1069    children: [
1070      ...(h.vim ? [t.Text({ color: /INSERT/.test(h.vim) ? p.accentUser : p.meta, children: [h.vim] })] : []),
1071      ...(h.vim && h.rest ? ['  '] : []),
1072      ...(h.rest ? [t.Text({ color: modeColor, children: [h.rest] })] : []),
1073    ],
1074  })
1075}
1076
hooks/glyphs.ts 54 lines
1// Every non-ASCII glyph the mod draws, built from code points. The agent
2// tool path has dropped private-use literals on the way into a file before
3// (the pill caps once shipped as ''), so no glyph outside printable ASCII
4// is ever written as a literal in hooks/, comments included. Verify with:
5//   LC_ALL=C grep -rn '[^ -~]' hooks/   -> must print nothing
6const cp = (n: number) => String.fromCodePoint(n)
7
8export const G = {
9  /** gutter mark beside a paragraph that needs the reader: LEFT ONE QUARTER BLOCK, U+258E */
10  mark: cp(0x258e),
11  /** rules and table header line: BOX DRAWINGS LIGHT HORIZONTAL, U+2500 */
12  rule: cp(0x2500),
13  /** list markers: BULLET U+2022, WHITE BULLET U+25E6, BALLOT BOX U+2610, BALLOT BOX WITH CHECK U+2611 */
14  dot: cp(0x2022),
15  ring: cp(0x25e6),
16  box: cp(0x2610),
17  check: cp(0x2611),
18  /** separator: MIDDLE DOT, U+00B7 */
19  middot: cp(0x00b7),
20  /** HORIZONTAL ELLIPSIS, U+2026 */
21  ellipsis: cp(0x2026),
22  /** tree connectors: BOX DRAWINGS LIGHT VERTICAL AND RIGHT U+251C, VERTICAL U+2502 */
23  tee: cp(0x251c),
24  pipe: cp(0x2502),
25  /** status marks: CHECK MARK U+2713, BALLOT X U+2717, WHITE CIRCLE U+25CB */
26  tick: cp(0x2713),
27  cross: cp(0x2717),
28  hollow: cp(0x25cb),
29  /** FISHEYE U+25C9: the assistant header mark and a folded group */
30  fisheye: cp(0x25c9),
31  /** BLACK DIAMOND U+25C6: the user row and a band row */
32  diamond: cp(0x25c6),
33  /** DOTTED CIRCLE U+25CC: the band header */
34  dotted: cp(0x25cc),
35  /** BLACK RIGHT-POINTING SMALL TRIANGLE U+25B8, DOWN U+25BE: fold chevrons */
36  right: cp(0x25b8),
37  down: cp(0x25be),
38  /** the band's rounded frame: BOX DRAWINGS LIGHT ARC DOWN AND RIGHT U+256D, DOWN AND LEFT U+256E, UP AND RIGHT U+2570, UP AND LEFT U+256F */
39  arcTL: cp(0x256d),
40  arcTR: cp(0x256e),
41  arcBL: cp(0x2570),
42  arcBR: cp(0x256f),
43  /** the count sign in `Bash x3`: MULTIPLICATION SIGN U+00D7 */
44  times: cp(0xd7),
45  /** a status dot: BLACK CIRCLE U+25CF */
46  disc: cp(0x25cf),
47  /** the agent face's smile: UNDERTIE U+203F */
48  smile: cp(0x203f),
49  /** DOWNWARDS ARROW U+2193: the engine's `down to manage` key */
50  arrowDown: cp(0x2193),
51  /** LEFTWARDS ARROW U+2190: the engine's `left for agents` key */
52  arrowLeft: cp(0x2190),
53}
54
hooks/markdown.ts 344 lines
1// A small markdown reader for assistant replies. It covers what the mod
2// draws itself: paragraphs, headings, lists, fences, rules, tables, quotes
3// and the Bottom line callout. Raw html is kept for the engine's own
4// Markdown element. Streaming-safe: unclosed markup stays literal, and a
5// callout, once its head line is seen, stays a callout as rows arrive.
6
7export type Inline =
8  | { kind: 'text'; text: string }
9  | { kind: 'code'; text: string }
10  | { kind: 'bold' | 'italic' | 'strike'; children: Inline[] }
11  | { kind: 'link'; text: string; href: string }
12
13// `blocks`: fences indented under the item, drawn under its text (0.4.22)
14export type ListItem = { depth: number; marker: string; task?: 'todo' | 'done'; inlines: Inline[]; blocks?: Block[] }
15export type CalloutRow = { label: string; inlines: Inline[] }
16export type Align = 'left' | 'center' | 'right'
17
18export type Block =
19  | { kind: 'paragraph'; inlines: Inline[] }
20  | { kind: 'heading'; level: number; inlines: Inline[] }
21  | { kind: 'code'; lang: string; source: string }
22  | { kind: 'list'; items: ListItem[] }
23  | { kind: 'rule' }
24  | { kind: 'raw'; text: string }
25  | { kind: 'quote'; blocks: Block[] }
26  // align: null where the separator names none (`---`), so a numeric column can right-align
27  | { kind: 'table'; header: Inline[][]; align: (Align | null)[]; rows: Inline[][][]; raw: string }
28  | { kind: 'callout'; title: string; rows: CalloutRow[] }
29
30const LIST_RE = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/
31const TASK_RE = /^\[([ xX])\]\s+(.*)$/
32const HEADING_RE = /^(#{1,6})\s+(.*?)\s*#*\s*$/
33const RULE_RE = /^\s*([-*_])(\s*\1){2,}\s*$/
34const TABLE_SEP_RE = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/
35// the info string may carry attributes after the language: ```ts title=x
36const FENCE_RE = /^(\s{0,3})(`{3,}|~{3,})\s*([\w+#.-]*)[^`]*$/
37// a fence indented under a list item, any depth
38const ITEM_FENCE_RE = /^(\s+)(`{3,}|~{3,})\s*([\w+#.-]*)[^`]*$/
39const QUOTE_RE = /^\s*>\s?(.*)$/
40const RAW_START_RE = /^\s*<[a-zA-Z!/]/
41// `**Bottom line**`, `**Bottom line**:` and `**Bottom line:**`
42const CALLOUT_HEAD_RE = /^\*\*(Bottom line):?\*\*:?\s*$/i
43const CALLOUT_ROW_RE = /^(?:[-*+]\s+)?\*{0,2}(Verified|Issue|Fix)\s*:?\*{0,2}\s*:?\s+(.*)$/
44
45// U+FE0F after a symbol that is not an emoji by default (U+26A0 and kin)
46const TEXT_SYMBOL_VS16 = new RegExp('(\\P{Emoji_Presentation})' + String.fromCodePoint(0xfe0f), 'gu')
47
48export function parseMarkdown(input: string): Block[] {
49  // the API allows only tab and newline as control characters in a Text;
50  // anything else would get the whole tree refused
51  const text = input
52    .replace(/\r\n?/g, '\n')
53    .replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '')
54    // a variation selector that turns a text-default symbol into an emoji
55    // makes the terminal draw it 2 cells while the engine counts 1, and the
56    // next character is drawn over: keep the symbol, drop the selector
57    .replace(TEXT_SYMBOL_VS16, '$1')
58  const lines = text.split('\n')
59  const blocks: Block[] = []
60  let i = 0
61  // a header line followed by a separator with the same cell count
62  const isTableStart = (idx: number) => {
63    const l = lines[idx]
64    const sep = lines[idx + 1]
65    return l !== undefined && sep !== undefined && l.includes('|') && TABLE_SEP_RE.test(sep) && splitRow(sep).length === splitRow(l).length
66  }
67  const startsBlock = (idx: number) => {
68    const l = lines[idx]!
69    return (
70      FENCE_RE.test(l) || HEADING_RE.test(l) || LIST_RE.test(l) || RULE_RE.test(l) || QUOTE_RE.test(l) || RAW_START_RE.test(l) || isTableStart(idx)
71    )
72  }
73
74  while (i < lines.length) {
75    const line = lines[i]!
76    if (line.trim() === '') {
77      i++
78      continue
79    }
80    const fence = FENCE_RE.exec(line)
81    if (fence) {
82      const marks = fence[2]!
83      const close = new RegExp(`^\\s{0,3}\\${marks[0]}{${marks.length},}\\s*$`)
84      const body: string[] = []
85      i++
86      while (i < lines.length && !close.test(lines[i]!)) body.push(lines[i++]!)
87      i++
88      blocks.push({ kind: 'code', lang: fence[3] ?? '', source: body.join('\n') })
89      continue
90    }
91    const heading = HEADING_RE.exec(line)
92    if (heading) {
93      blocks.push({ kind: 'heading', level: heading[1]!.length, inlines: parseInline(heading[2]!) })
94      i++
95      continue
96    }
97    if (RULE_RE.test(line)) {
98      blocks.push({ kind: 'rule' })
99      i++
100      continue
101    }
102    if (isTableStart(i)) {
103      const start = i
104      const header = splitRow(line)
105      const align = splitRow(lines[i + 1]!).map(alignOf)
106      i += 2
107      const rows: Inline[][][] = []
108      while (i < lines.length && lines[i]!.trim() !== '' && lines[i]!.includes('|')) {
109        rows.push(splitRow(lines[i++]!).map(parseInline))
110      }
111      blocks.push({ kind: 'table', header: header.map(parseInline), align, rows, raw: lines.slice(start, i).join('\n') })
112      continue
113    }
114    if (QUOTE_RE.test(line)) {
115      const inner: string[] = []
116      while (i < lines.length && lines[i]!.trim() !== '') inner.push(lines[i++]!.replace(QUOTE_RE, '$1'))
117      blocks.push({ kind: 'quote', blocks: parseMarkdown(inner.join('\n')) })
118      continue
119    }
120    if (RAW_START_RE.test(line)) {
121      const raw: string[] = []
122      while (i < lines.length && lines[i]!.trim() !== '') raw.push(lines[i++]!)
123      blocks.push({ kind: 'raw', text: raw.join('\n') })
124      continue
125    }
126    if (LIST_RE.test(line)) {
127      const items: { depth: number; marker: string; text: string; blocks: Block[] }[] = []
128      while (i < lines.length) {
129        const l = lines[i]!
130        if (l.trim() === '') {
131          // a blank inside an item's fence is the fence's; otherwise the
132          // list goes on only into another item
133          if (i + 1 < lines.length && (LIST_RE.test(lines[i + 1]!) || ITEM_FENCE_RE.test(lines[i + 1]!)) && items.length > 0) {
134            i++
135            continue
136          }
137          break
138        }
139        const m = LIST_RE.exec(l)
140        const fence = items.length > 0 ? ITEM_FENCE_RE.exec(l) : null
141        if (m) {
142          items.push({ depth: Math.floor(m[1]!.length / 2), marker: m[2]!, text: m[3]!, blocks: [] })
143        } else if (fence) {
144          // a fence typed under the item (`1. Run:` then an indented block):
145          // its lines lose the item's indent and draw as a card under the
146          // text, not as inline code with newlines in it (0.4.22)
147          const indent = fence[1]!.length
148          const marks = fence[2]!
149          const close = new RegExp(`^\\s*\\${marks[0]}{${marks.length},}\\s*$`)
150          const body: string[] = []
151          i++
152          while (i < lines.length && !close.test(lines[i]!)) {
153            const row = lines[i++]!
154            body.push(row.slice(Math.min(indent, row.length - row.trimStart().length)))
155          }
156          items[items.length - 1]!.blocks.push({ kind: 'code', lang: fence[3] ?? '', source: body.join('\n') })
157        } else if (/^\s+\S/.test(l) && items.length > 0) {
158          items[items.length - 1]!.text += '\n' + l.trim()
159        } else {
160          break
161        }
162        i++
163      }
164      blocks.push({
165        kind: 'list',
166        items: items.map(it => {
167          const task = TASK_RE.exec(it.text)
168          const blocks = it.blocks.length ? { blocks: it.blocks } : {}
169          if (!task) return { depth: it.depth, marker: it.marker, inlines: parseInline(it.text), ...blocks }
170          return {
171            depth: it.depth,
172            marker: it.marker,
173            task: task[1] === ' ' ? 'todo' : 'done',
174            inlines: parseInline(task[2]!),
175            ...blocks,
176          }
177        }),
178      })
179      continue
180    }
181    const para: string[] = [line]
182    i++
183    while (i < lines.length && lines[i]!.trim() !== '' && !startsBlock(i)) para.push(lines[i++]!)
184    blocks.push(paragraphBlock(para))
185  }
186  return mergeCallouts(blocks)
187}
188
189// `| a | b |` -> ['a', 'b']; a `\|` inside a cell stays a pipe
190function splitRow(line: string): string[] {
191  const cells = line
192    .trim()
193    .replace(/^\|/, '')
194    .replace(/\|$/, '')
195    .split(/(?<!\\)\|/)
196    .map(c => c.trim().replace(/\\\|/g, '|'))
197  return cells
198}
199
200function alignOf(sep: string): Align | null {
201  const left = sep.startsWith(':')
202  const right = sep.endsWith(':')
203  if (left && right) return 'center'
204  if (right) return 'right'
205  return left ? 'left' : null
206}
207
208// a callout row: labelled once `Label:` has arrived, a blank label before
209function calloutRow(line: string): CalloutRow {
210  const m = CALLOUT_ROW_RE.exec(line)
211  return m ? { label: m[1]!, inlines: parseInline(m[2]!) } : { label: '', inlines: parseInline(line) }
212}
213
214// Monotonic: once the head line reads **Bottom line**, the block is a card
215// for the rest of the stream, whatever the rows look like so far
216function paragraphBlock(lines: string[]): Block {
217  const head = CALLOUT_HEAD_RE.exec(lines[0]!)
218  if (head) return { kind: 'callout', title: head[1]!, rows: lines.slice(1).map(calloutRow) }
219  return { kind: 'paragraph', inlines: parseInline(lines.join('\n')) }
220}
221
222// `**Bottom line**` as a title-only card followed by a list: the list items
223// are its rows
224function mergeCallouts(blocks: Block[]): Block[] {
225  const out: Block[] = []
226  for (let i = 0; i < blocks.length; i++) {
227    const b = blocks[i]!
228    const next = blocks[i + 1]
229    if (b.kind === 'callout' && b.rows.length === 0 && next?.kind === 'list') {
230      out.push({ kind: 'callout', title: b.title, rows: next.items.map(it => calloutRow(inlineText(it.inlines))) })
231      i++
232      continue
233    }
234    out.push(b)
235  }
236  return out
237}
238
239export function inlineText(inlines: Inline[]): string {
240  return inlines
241    .map(n => (n.kind === 'text' || n.kind === 'code' || n.kind === 'link' ? n.text : inlineText(n.children)))
242    .join('')
243}
244
245// sticky: matched at `lastIndex`, so a long run is never copied per bracket
246const URL_RE = /https?:\/\/[^\s<>)]+/y
247// `[label](href)` and `[label](href "title")`; the title is not drawn
248const MD_LINK_RE = /\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/y
249
250export function parseInline(src: string): Inline[] {
251  const out: Inline[] = []
252  let buf = ''
253  const flush = () => {
254    if (buf) out.push({ kind: 'text', text: buf })
255    buf = ''
256  }
257  const n = src.length
258  let i = 0
259  while (i < n) {
260    const c = src[i]!
261    if (c === '\\' && i + 1 < n && '`*_~[]\\'.includes(src[i + 1]!)) {
262      buf += src[i + 1]
263      i += 2
264      continue
265    }
266    if (c === '`') {
267      let run = 1
268      while (src[i + run] === '`') run++
269      const ticks = '`'.repeat(run)
270      const close = src.indexOf(ticks, i + run)
271      if (close >= 0) {
272        flush()
273        let code = src.slice(i + run, close)
274        if (code.length > 2 && code.startsWith(' ') && code.endsWith(' ')) code = code.slice(1, -1)
275        out.push({ kind: 'code', text: code })
276        i = close + run
277        continue
278      }
279    }
280    if (src.startsWith('**', i) || src.startsWith('__', i) || src.startsWith('~~', i)) {
281      const d = src.slice(i, i + 2)
282      const close = findClose(src, i + 2, d)
283      if (close >= 0) {
284        flush()
285        out.push({ kind: d === '~~' ? 'strike' : 'bold', children: parseInline(src.slice(i + 2, close)) })
286        i = close + 2
287        continue
288      }
289    }
290    if ((c === '*' || c === '_') && i + 1 < n && !/\s/.test(src[i + 1]!) && (c === '*' || i === 0 || !/\w/.test(src[i - 1]!))) {
291      const close = findClose(src, i + 1, c)
292      if (close >= 0) {
293        flush()
294        out.push({ kind: 'italic', children: parseInline(src.slice(i + 1, close)) })
295        i = close + 1
296        continue
297      }
298    }
299    // a link, or an image `![alt](src)`, which a terminal shows as the link
300    // its alt text names (the bang drew as text before 0.4.22)
301    if (c === '[' || (c === '!' && src[i + 1] === '[')) {
302      const at = c === '!' ? i + 1 : i
303      MD_LINK_RE.lastIndex = at
304      const m = MD_LINK_RE.exec(src)
305      if (m) {
306        flush()
307        out.push({ kind: 'link', text: m[1]!, href: m[2]! })
308        i = at + m[0].length
309        continue
310      }
311    }
312    if (c === 'h') {
313      URL_RE.lastIndex = i
314      const m = URL_RE.exec(src)
315      if (m) {
316        // sentence punctuation after a bare URL belongs to the sentence
317        const url = m[0].replace(/[.,;:!?]+$/, '')
318        flush()
319        out.push({ kind: 'link', text: url, href: url })
320        i += url.length
321        continue
322      }
323    }
324    buf += c
325    i++
326  }
327  flush()
328  return out
329}
330
331// the closing delimiter: not preceded by a space, and for `_` not followed
332// by a word character
333function findClose(src: string, from: number, d: string): number {
334  let j = src.indexOf(d, from)
335  while (j >= 0) {
336    // `**bold *nested***`: the bold closes on the last two of the three stars
337    if (d === '**' && src[j + 2] === '*') j += 1
338    const after = src[j + d.length]
339    if (!/\s/.test(src[j - 1]!) && (d[0] !== '_' || after === undefined || !/\w/.test(after))) return j
340    j = src.indexOf(d, j + 1)
341  }
342  return -1
343}
344
hooks/palette.ts 330 lines
1// Colors are raw hex; `Text.color` takes a theme key or a raw color.
2export type Palette = {
3  cmd: string
4  sub: string
5  flag: string
6  str: string
7  path: string
8  op: string
9  num: string
10  var: string
11  url: string
12  comment: string
13  /** inline code that is not a shell command */
14  code: string
15  /** H1 and H2 */
16  heading: string
17  /** H3 and deeper */
18  heading3: string
19  /** the reply bullet */
20  accent: string
21  /** list markers: dot, ring and the open task box */
22  bullet: string
23  /** ordered list numbers */
24  orderedNum: string
25  /** heading rules, table borders, horizontal rules */
26  rule: string
27  /** the thin bar down a quote's left edge */
28  quoteBar: string
29  /** success: status dots, the done task mark, ok words in output */
30  ok: string
31  err: string
32  warn: string
33  /** bold prose, brighter than the dimmed terminal fg */
34  bold: string
35  /** tint for code cards and the Bottom line card: one visible step above the terminal bg */
36  blockBg: string
37  /** tool name in a tool row header */
38  tool: string
39  /** Bottom line callout: bar + title, then the three labels and row text */
40  calloutBar: string
41  verified: string
42  issue: string
43  fix: string
44  calloutText: string
45  /** Claude's own planning note (a paragraph opening with Private), drawn italic */
46  private: string
47  /**
48   * gutter mark beside a paragraph that asks something of the reader: the
49   * palette's warn, never ok (it is not "done") and never a bar's color
50   * (the quote and Bottom line bars are the same glyph)
51   */
52  mark: string
53  /** one step under prose: times, counts, durations, the subject of a done tool row */
54  meta: string
55  /** scaffolding: tree connectors, folds, hints */
56  faint: string
57  /** the tint a tree row takes under the pointer */
58  rowHover: string
59  /** the user row's diamond and name */
60  accentUser: string
61  /** glass's own code highlighter: keywords, calls, types, strings, numbers */
62  codeKw: string
63  codeFn: string
64  codeType: string
65  codeStr: string
66  codeNum: string
67  /** row tints in a diff: an added line, a removed line */
68  addBg: string
69  delBg: string
70}
71
72export type PaletteName = 'tidepool' | 'codex' | 'rose' | 'undertow' | 'water'
73
74// The owner's own nvim theme, ~/Documents/codes/packages/tidepool.nvim,
75// hybrid variant: lifted content over muted scaffolding. Mapped by role:
76// strings -> olive, constants/raw -> pine, functions -> iris, builtins -> foam,
77// links/dirs -> mint, operators quiet, errors -> love, warn -> gold.
78const tidepool: Palette = {
79  cmd: '#d2bdff', // iris: the verb of the line, as functions are
80  sub: '#6cc0e5', // foam: builtins
81  flag: '#96bdff', // parameter blue
82  str: '#9cce8b', // olive
83  path: '#d6b477', // gold_soft: a warm, calm color of its own, so paths never read as links
84  op: '#56738a', // muted operator, by design quiet
85  num: '#7bc0ae', // lifted pine
86  var: '#6cc0e5', // foam: builtin variables
87  url: '#7aa2f7', // mint underline
88  comment: '#86a8a8',
89  code: '#7bc0ae', // @markup.raw = pine
90  heading: '#9cce8b', // olive: the owner's pick over the theme's iris for H2
91  heading3: '#e9b873', // @markup.heading.3 = gold
92  accent: '#6cc0e5', // foam
93  bullet: '#7aa2b5', // punctuation
94  orderedNum: '#7aa2b5',
95  rule: '#48708c', // muted: line numbers, folds
96  quoteBar: '#7bc0ae', // lifted pine: colored, but quieter than foam
97  ok: '#9cce8b', // olive
98  err: '#e67680', // love
99  warn: '#e9b873', // gold
100  bold: '#c5d8e6',
101  blockBg: '#0e2a3a', // one visible step above the bg (1.23:1)
102  tool: '#d083e8', // rose: tags
103  calloutBar: '#6cc0e5', // foam
104  verified: '#9cce8b', // olive
105  issue: '#e67680', // love
106  fix: '#d2bdff', // iris
107  calloutText: '#c5d8e6',
108  private: '#486e6e', // the teal the owner set as CLAUDE_HL_PRIVATE
109  mark: '#e9b873', // warn (gold): a gutter mark reads "read this", apart from ok and the bars
110  meta: '#7aa2b5', // punctuation
111  faint: '#48708c', // muted
112  rowHover: '#0e2a3a', // the block tint
113  accentUser: '#7bc0ae', // lifted pine
114  codeKw: '#7aa2f7', // mint blue: the owner's pick from the engine's reply fence
115  codeFn: '#9cce8b', // olive
116  codeType: '#7bc0ae', // pine
117  codeStr: '#e67680', // love
118  codeNum: '#7bc0ae', // pine
119  addBg: '#0e2a3a', // the band tint, not a green: the marker and its color say added (colorblind-safe, owner's pick 2026-10-03)
120  delBg: '#241c2a', // a dim plum, apart from the band on the blue-violet axis
121}
122
123// Read off the frames of Empryo's v3 desktop (the X post of 2026-10-02):
124// a dark blue-grey ground, sea-glass green for the live thing, dusty rose
125// for errors, cyan for the brand marks. Empryo's own `proxysoul-undertow`
126// theme is not public, so every value is a sample, not a token.
127const undertow: Palette = {
128  cmd: '#8fd3e8', // the brand cyan, lifted
129  sub: '#9ab3a8', // sea-glass
130  flag: '#c9a7e0', // dusty violet
131  str: '#d4c38a', // sand
132  path: '#d6b477', // gold
133  op: '#6b6e78',
134  num: '#9ab3a8',
135  var: '#8fd3e8',
136  url: '#5ec8d8',
137  comment: '#8a8d94',
138  code: '#9ab3a8',
139  heading: '#e7e6e9',
140  heading3: '#c9ccd3',
141  accent: '#5ec8d8', // the brand cyan: the assistant mark, the band
142  bullet: '#8a8d94',
143  orderedNum: '#8a8d94',
144  rule: '#3c4043',
145  quoteBar: '#9ab3a8',
146  ok: '#4fd1a0',
147  err: '#e85c6b', // dusty rose
148  warn: '#e8a04c',
149  bold: '#ffffff',
150  blockBg: '#1c1e23',
151  tool: '#5ec8d8',
152  calloutBar: '#5ec8d8',
153  verified: '#4fd1a0',
154  issue: '#e85c6b',
155  fix: '#c9a7e0',
156  calloutText: '#e7e6e9',
157  private: '#52535c',
158  mark: '#e8a04c', // warn
159  meta: '#8a8d94',
160  faint: '#52535c',
161  rowHover: '#1e2024',
162  accentUser: '#4fd1a0',
163  codeKw: '#5ec8d8', // the brand cyan
164  codeFn: '#8fd3e8',
165  codeType: '#e8a04c',
166  codeStr: '#9ab3a8', // sea-glass
167  codeNum: '#e8a04c',
168  addBg: '#1b2a2f', // Empryo's own row band
169  delBg: '#2a2126',
170}
171
172// Empryo's `proxysoul-water` theme, exact: the tokens of PROXYSOUL_WATER
173// over PROXYSOUL_MAIN in Empryo's src/core/theme/tokens.ts (read
174// 2026-10-02). Empryo has no shell-token colors, so the kinds are mapped
175// by role onto its brand set: brand for the verb, brandAlt for builtins
176// and raw, brandSecondary for flags, warning for paths, amber for
177// strings. Text steps are its textPrimary, textSecondary and textMuted;
178// textDim is kept for rules only, since textFaint vanishes off Empryo's
179// own near-black ground.
180const water: Palette = {
181  cmd: '#00a2ce', // brand
182  sub: '#3bb8d8', // brandAlt
183  flag: '#009a8b', // brandSecondary
184  str: '#e65f2a', // amber
185  path: '#948ff0', // periwinkle: water's secondary-text hue with the saturation lifted, so paths sit apart from the cyan code, the mint ok and the grey body text (owner's pick 2026-10-03; #a4a1e8 sat too near the text, sea glass #6ec9ab blurred into code and ok)
186  op: '#8e8ca1', // textSecondary
187  num: '#3bb8d8', // info
188  var: '#009a8b',
189  url: '#00a2ce',
190  comment: '#8e8ca1',
191  code: '#3bb8d8',
192  heading: '#e7e7ee', // textPrimary
193  heading3: '#3bb8d8',
194  accent: '#00a2ce', // accentAssistant
195  bullet: '#8e8ca1',
196  orderedNum: '#8e8ca1',
197  rule: '#5a5870', // textDim lifted: #3d3b50 is 1.69:1 on night-owl's ground and the rules vanished
198  quoteBar: '#009a8b',
199  ok: '#4fd1a0', // lifted: Empryo's success #0b8b00 is mud on a dark ground
200  err: '#e85c6b', // the dusty rose of Empryo's desktop; its #ee0b2a glares
201  warn: '#de7c00', // warning
202  bold: '#e7e7ee',
203  blockBg: '#0e2a3a', // Empryo's bgElevated #0e1a22 is 1.04:1 on night-owl's ground; this is the 1.23:1 step the other palettes use
204  tool: '#00a2ce',
205  calloutBar: '#00a2ce',
206  verified: '#4fd1a0',
207  issue: '#e85c6b',
208  fix: '#3bb8d8',
209  calloutText: '#d4d4dc', // a step under textPrimary, so the labels lead
210  private: '#6e6c82', // as faint
211  mark: '#de7c00', // warn: the teal it had was the quote bar's, the same glyph
212  meta: '#8e8ca1', // textSecondary
213  faint: '#6e6c82', // textMuted lifted one step: #5c5a6e is 2.7:1 on night-owl's ground
214  rowHover: '#0e2a3a', // same step; Empryo's brandDim #0e2030 is 1.11:1 here
215  accentUser: '#009a8b',
216  codeKw: '#00a2ce',
217  codeFn: '#3bb8d8',
218  codeType: '#de7c00',
219  codeStr: '#e65f2a',
220  codeNum: '#3bb8d8',
221  addBg: '#0e2a3a',
222  delBg: '#33222e', // lifted from #241c2a (1.11:1) toward SPEC's 1.2:1 tint floor
223}
224
225// Every palette spells out every key: no shared block, so a theme reads whole.
226export const PALETTES: Record<PaletteName, Palette> = {
227  tidepool,
228  undertow,
229  water,
230  // One Dark, as claude-hl's THEME_CODEX carried it, filled out by role:
231  // cyan for the marks and bars, the gutter greys from the theme's own
232  // comment and line-number steps.
233  codex: {
234    cmd: '#6fb3ff',
235    sub: '#7fc8b8',
236    flag: '#e78fc7',
237    str: '#e5c07b',
238    path: '#56b6c2',
239    op: '#6fb3ff',
240    num: '#d19a66',
241    var: '#98c379',
242    url: '#6fb3ff',
243    comment: '#7a9a9a',
244    code: '#e5c07b',
245    heading: '#c678dd',
246    heading3: '#e5c07b', // the theme's yellow, under the purple H2
247    accent: '#c678dd',
248    bullet: '#7a9a9a',
249    orderedNum: '#7a9a9a',
250    rule: '#7a9a9a',
251    quoteBar: '#7fc8b8', // the sub teal, apart from the purple headings
252    ok: '#98c379',
253    err: '#e06c75',
254    warn: '#e5c07b',
255    bold: '#dcdfe4', // the theme's fg, lifted
256    blockBg: '#0e2a3a', // the one visible step above the night-owl ground
257    tool: '#c678dd',
258    calloutBar: '#56b6c2', // cyan
259    verified: '#98c379',
260    issue: '#e06c75',
261    fix: '#c678dd',
262    calloutText: '#dcdfe4',
263    private: '#4b6a6a', // a teal step under the comment grey
264    mark: '#e5c07b', // warn
265    meta: '#8a97a8', // the theme's line-number grey, lifted
266    faint: '#4f5b6b', // its gutter grey
267    rowHover: '#0e2a3a',
268    accentUser: '#98c379',
269    codeKw: '#c678dd',
270    codeFn: '#61afef',
271    codeType: '#e5c07b',
272    codeStr: '#98c379',
273    codeNum: '#d19a66',
274    addBg: '#0e2a3a',
275    delBg: '#241c2a',
276  },
277  // Rose Pine, as claude-hl's THEME_ROSE carried it, filled out from the
278  // theme's own set: foam for the marks and bars, iris for the fix, gold
279  // for H3, subtle and muted for the two grey steps.
280  rose: {
281    cmd: '#9ccfd8',
282    sub: '#c4a7e7',
283    flag: '#ebbcba',
284    str: '#f6c177',
285    path: '#e0def4',
286    op: '#9ccfd8',
287    num: '#ea9a97',
288    var: '#eb6f92',
289    url: '#9ccfd8',
290    comment: '#6e6a86',
291    code: '#c4a7e7',
292    heading: '#c4a7e7',
293    heading3: '#f6c177', // gold
294    accent: '#c4a7e7',
295    bullet: '#6e6a86',
296    orderedNum: '#908caa', // subtle
297    rule: '#6e6a86',
298    quoteBar: '#9ccfd8', // foam
299    ok: '#3e8fb0', // pine
300    err: '#eb6f92', // love
301    warn: '#f6c177',
302    bold: '#e0def4', // text
303    blockBg: '#0e2a3a',
304    tool: '#c4a7e7',
305    calloutBar: '#9ccfd8', // foam
306    verified: '#9ccfd8', // the theme has no green; foam reads as calm
307    issue: '#eb6f92',
308    fix: '#c4a7e7', // iris
309    calloutText: '#e0def4',
310    private: '#6e6a86', // muted
311    mark: '#f6c177', // warn: the foam it had was both bars'
312    meta: '#908caa', // subtle
313    faint: '#6e6a86', // muted
314    rowHover: '#0e2a3a',
315    accentUser: '#9ccfd8',
316    codeKw: '#31748f',
317    codeFn: '#ebbcba',
318    codeType: '#9ccfd8',
319    codeStr: '#f6c177',
320    codeNum: '#ea9a97',
321    addBg: '#0e2a3a',
322    delBg: '#241c2a',
323  },
324}
325
326export function paletteNamed(name: unknown): Palette {
327  if (name === 'codex' || name === 'rose' || name === 'undertow' || name === 'water') return PALETTES[name]
328  return PALETTES.tidepool
329}
330
hooks/prose.ts 83 lines
1// What a paragraph of Claude's prose asks of the reader, ported from
2// claude-hl: a question, an opening instruction, or an attention phrase
3// earns a gutter mark. Matters of wording, decided by small tables.
4
5const ATTENTION = [
6  // asks of the reader
7  'you need', "you'll need", 'you must', 'you should', 'you have to', 'you may want', 'you might want',
8  "you can't", 'you cannot', "don't forget", 'your call', 'up to you', 'let me know',
9  'do you want', 'should i', 'which one', 'manually', 'by hand', 'before you', 'if you want', 'on your machine',
10  'your machine', 'your terminal', 'yourself', 'restart', 'reboot', 'reinstall', 're-run', 'rerun', 'log in',
11  'sign in',
12  // what did not happen
13  'not verif*', "can't verify", 'cannot verify', 'could not verify', "couldn't verify", 'unverified',
14  'untested', 'not tested', 'no tests', 'i did not', "i didn't", "i haven't", 'i could not', "i couldn't",
15  'skipped', 'left out', 'blocked', 'not yet', "won't work", 'will not work', 'does not work', "doesn't work",
16  'still fail*', 'keeps fail*', 'regress*', 'assum*',
17  // risk
18  'warning', 'caution', 'careful', 'risk*', 'danger*', 'breaking', 'irreversib*', 'destructive', 'data loss',
19  'backup', 'security', 'vulnerab*', 'secret*', 'credential*', 'password*', 'deprecat*', 'caveat*', 'however',
20  'important', 'todo', 'fixme',
21]
22
23// openers that make the paragraph an instruction to the reader
24const IMPERATIVE = [
25  'run', 'set', 'add', 'install', 'restart', 'open', 'check', 'update', 'remove', 'delete', 'replace',
26  'rename', 'export', 'copy', 'paste', 'change', 'edit', 'enable', 'disable', 'confirm', 'review', 'decide',
27  'choose', 'pick', 'try', 'note', 'beware', 'remember', "don't", 'do not', 'never', 'always', 'please',
28  'make sure', 'be sure',
29]
30
31// verbs near the front of a prompt that ask Claude to write something rather
32// than do something: the reply is then the thing itself, and nothing in it
33// asks the reader for anything
34const WRITE_WORDS = new Set([
35  'write', 'draft', 'compose', 'reword', 'rewrite', 'rephrase', 'translate', 'proofread', 'polish',
36  'shorten', 'expand', 'summarize', 'summarise',
37])
38
39const RIGHT_QUOTE = String.fromCodePoint(0x2019)
40
41// ` word word ? word ` so phrases match on word boundaries
42function normalize(text: string): string {
43  let norm = ' '
44  let inWord = false
45  for (const c of text) {
46    const keep = /[\p{L}\p{N}]/u.test(c) || c === "'" || c === RIGHT_QUOTE || (c === '-' && inWord)
47    if (keep) {
48      norm += c === RIGHT_QUOTE ? "'" : c.toLowerCase()
49      inWord = true
50    } else {
51      if (inWord) norm += ' '
52      inWord = false
53      if (c === '?') norm += '? '
54    }
55  }
56  if (inWord) norm += ' '
57  return norm
58}
59
60/** does this paragraph need the reader? */
61export function needsAttention(text: string): boolean {
62  const norm = normalize(text)
63  if (norm.includes(' ? ')) return true
64  const body = norm.slice(1)
65  if (IMPERATIVE.some(w => body.startsWith(w + ' '))) return true
66  return ATTENTION.some(p => (p.endsWith('*') ? norm.includes(' ' + p.slice(0, -1)) : norm.includes(` ${p} `)))
67}
68
69/** does the prompt ask Claude to write something? one of WRITE_WORDS in its first six words */
70export function writeIntent(prompt: string): boolean {
71  return prompt
72    .toLowerCase()
73    .split(/[^\p{L}\p{N}]+/u)
74    .filter(w => w.length > 0)
75    .slice(0, 6)
76    .some(w => WRITE_WORDS.has(w))
77}
78
79/** Claude's own planning note: a paragraph opening with Private or Privately */
80export function isPrivateNote(text: string): boolean {
81  return /^Privately?\b/.test(text.trimStart())
82}
83
hooks/render.ts 683 lines
1// Draws a parsed reply as an element tree, following SPEC.md: full-width
2// prose, one rhythm rule, color-only inline code, two content-fit tinted
3// cards, one glyph family (see glyphs.ts), one width ruler (see width.ts).
4import type { Elements, RenderElement, RenderNode } from 'claude-code'
5
6import { G } from './glyphs'
7import { CODE_COLOR, highlight } from './highlight'
8import { inlineText } from './markdown'
9import type { Align, Block, CalloutRow, Inline, ListItem } from './markdown'
10import type { Palette } from './palette'
11import { bareWord, isUrl, pathLike } from './paths'
12import { isPrivateNote, needsAttention } from './prose'
13import { proseSpans, shellSpans } from './shell'
14import type { SpanKind } from './shell'
15import { cellWidth, clipMiddle } from './width'
16
17type Table = Elements['terminal']
18
19type Ctx = {
20  t: Table
21  p: Palette
22  /** the prose measure in cells */
23  m: number
24}
25
26export type RenderOptions = {
27  /** the first text block of a reply: keeps a blank row above */
28  first?: boolean
29  columns: number
30  /** gutter marks beside paragraphs that need the reader; off when the prompt asked for writing */
31  marks: boolean
32}
33
34const LABEL_WIDTH = 8
35const LABEL_COLOR: Record<string, keyof Palette> = {
36  Verified: 'verified',
37  Issue: 'issue',
38  Fix: 'fix',
39}
40
41// the API bounds a Text or Code string at this many characters
42const MAX_TEXT = 10000
43
44// A run as strings the API takes: past MAX_TEXT characters it goes as
45// several, side by side in the same Text, so it draws the same. A cut
46// never splits a surrogate pair.
47function cut(s: string): string[] {
48  if (s.length <= MAX_TEXT) return [s]
49  const out: string[] = []
50  for (let i = 0; i < s.length; ) {
51    let end = Math.min(s.length, i + MAX_TEXT)
52    const last = s.charCodeAt(end - 1)
53    if (end < s.length && last >= 0xd800 && last <= 0xdbff) end--
54    out.push(s.slice(i, end))
55    i = end
56  }
57  return out
58}
59// a tinted card never gets narrower than this
60const CARD_MIN = 60
61// how far H2 steps from the heading color toward `meta`
62const H2_STEP = 0.4
63// a table column squeezed to fit never gets narrower than this
64const COL_MIN = 4
65// a table cell that reads as a number: an optional sign or currency mark, digits
66// with separators, then a percent or a short unit (`1.5k`, `12ms`, `3GB`)
67const NUMERIC_RE = new RegExp('^[-+]?[$' + String.fromCodePoint(0xa3, 0x20ac) + ']?\\d[\\d.,]*(?:%|[A-Za-z]{1,2})?$')
68
69// a color `k` of the way from `a` to `b`; a non-hex color stays `a`
70function blend(a: string, b: string, k: number): string {
71  const hex = /^#[0-9a-f]{6}$/i
72  if (!hex.test(a) || !hex.test(b)) return a
73  const ch = (s: string, i: number) => parseInt(s.slice(1 + 2 * i, 3 + 2 * i), 16)
74  return '#' + [0, 1, 2].map(i => Math.round(ch(a, i) + (ch(b, i) - ch(a, i)) * k).toString(16).padStart(2, '0')).join('')
75}
76
77// inside backticks a path needs a slash or a `:line` suffix:
78// `hooks/render.ts:88`, `src/main.rs`, `~/.zshrc`; not `shell.ts`
79const PATH_RE = /^(?:~|\.{1,2})?[\w.-]*(?:\/[\w.-]+)+\/?(?::\d+(?::\d+)?)?$|^[\w.-]+\.\w+:\d+(?::\d+)?$/
80
81export function measure(columns: number): number {
82  return Math.max(20, columns - 2)
83}
84
85// the words of a run of inlines: code spans and link targets left out, so a
86// `?` in `foo?.bar` or in a URL's query never marks the paragraph (0.4.22)
87function proseText(inlines: Inline[]): string {
88  return inlines
89    .map(n => (n.kind === 'text' ? n.text : n.kind === 'code' || n.kind === 'link' ? ' ' : proseText(n.children)))
90    .join('')
91}
92
93// cells a run of inlines takes once drawn
94function inlineWidth(inlines: Inline[]): number {
95  return inlines.reduce((n, node) => {
96    switch (node.kind) {
97      case 'text':
98      case 'link':
99      case 'code':
100        return n + cellWidth(node.text)
101      default:
102        return n + inlineWidth(node.children)
103    }
104  }, 0)
105}
106
107// a card hugs its content: the widest row plus padding, floored, capped
108function cardWidth(m: number, content: number): number {
109  return Math.min(m, Math.max(CARD_MIN, content))
110}
111
112export function renderReply(t: Table, blocks: Block[], p: Palette, o: RenderOptions): RenderElement {
113  const c: Ctx = { t, p, m: measure(o.columns) }
114  const rows: RenderElement[] = []
115  let prev: Block['kind'] | null = null
116  blocks.forEach((b, i) => {
117    const el = renderBlock(c, b)
118    // one rhythm rule: a blank above every block except the first and any
119    // block right under a heading; the reply's first block keeps a blank,
120    // so it never sits glued to the tool rows above. No bullet: the
121    // assistant header under the user row carries it.
122    const marginTop = i === 0 ? (o.first ? 1 : 0) : prev === 'heading' ? 0 : 1
123    // a paragraph that asks something of the reader gets a gutter mark in
124    // the two cells every other block leaves blank
125    const marked = o.marks && b.kind === 'paragraph' && !isPrivateNote(inlineText(b.inlines)) && needsAttention(proseText(b.inlines))
126    if (marked) {
127      rows.push(
128        t.Box({
129          flexDirection: 'row',
130          marginTop,
131          children: [t.Text({ color: p.mark, children: [G.mark + ' '] }), t.Box({ flexDirection: 'column', flexGrow: 1, flexShrink: 1, children: [el] })],
132        }),
133      )
134    } else {
135      rows.push(t.Box({ flexDirection: 'column', marginLeft: 2, marginTop, children: [el] }))
136    }
137    prev = b.kind
138  })
139  return t.Box({ flexDirection: 'column', children: rows })
140}
141
142function renderBlock(c: Ctx, b: Block): RenderElement {
143  const { t, p, m } = c
144  switch (b.kind) {
145    case 'paragraph': {
146      // Claude's own planning note, drawn italic in a quiet color so the
147      // real answer stands out
148      if (isPrivateNote(inlineText(b.inlines))) {
149        return t.Box({ width: m, children: [t.Text({ wrap: 'wrap', italic: true, color: p.private, children: cut(inlineText(b.inlines)) })] })
150      }
151      return t.Box({ width: m, children: [t.Text({ wrap: 'wrap', children: renderInlines(c, b.inlines) })] })
152    }
153    case 'heading':
154      return t.Text({
155        bold: true,
156        // H2 one step under H1, toward `meta`, so section levels survive a long reply
157        color: b.level >= 3 ? p.heading3 : b.level === 2 ? blend(p.heading, p.meta, H2_STEP) : p.heading,
158        children: renderInlines(c, b.inlines),
159      })
160    case 'code':
161      return renderFence(c, b.lang, b.source)
162    case 'list':
163      return renderList(c, b.items)
164    case 'rule':
165      return t.Text({ color: p.rule, children: [G.rule.repeat(Math.min(m, CARD_MIN))] })
166    case 'raw':
167      return t.Markdown({ text: b.text.slice(0, MAX_TEXT) })
168    case 'quote':
169      return renderQuote(c, b.blocks)
170    case 'table':
171      return renderTable(c, b.header, b.align, b.rows, b.raw)
172    case 'callout':
173      return renderCallout(c, b.title, b.rows)
174  }
175}
176
177// a fence with no language (or a plain-text one) whose rows are mostly
178// arrows, box-drawing, block or shape glyphs, or their ASCII stand-ins, is
179// a picture, not code
180const DIAGRAM_LANGS = new Set(['', 'text', 'txt', 'ascii', 'diagram'])
181
182function isDrawingGlyph(code: number): boolean {
183  return (code >= 0x2190 && code <= 0x21ff) || (code >= 0x2500 && code <= 0x25ff)
184}
185
186// the same pictures drawn in plain ASCII: a corner or tree branch (`+--`,
187// `|--`, `` `-- ``), a line into a corner (`--+`), a two-dash arrow (`-->`,
188// `<==`), a row boxed at both ends, or a connector row of `|`, `v`, `^`.
189// One dash is code's (`->`, `=>`), so it never counts, nor a corner after a
190// `+` (a diffstat's `+++---`)
191const ASCII_DRAWN = [
192  /(?<!\+)[+|`\\][-=]{2,}/,
193  /[-=]{2,}[+|]/,
194  /<[-=]{2,}|[-=]{2,}>/,
195  /^\s*\|.*\|\s*$/,
196  /^[\s|v^]*[|v^][\s|v^]*$/,
197]
198
199function isDrawnRow(row: string): boolean {
200  return [...row].some(ch => isDrawingGlyph(ch.codePointAt(0)!)) || ASCII_DRAWN.some(re => re.test(row))
201}
202
203function isDiagram(lang: string, source: string): boolean {
204  if (!DIAGRAM_LANGS.has(lang.toLowerCase())) return false
205  const rows = source.split('\n').filter(l => l.trim() !== '')
206  const drawn = rows.filter(isDrawnRow).length
207  return rows.length > 0 && drawn * 2 >= rows.length
208}
209
210// what a fence draws, worked out once for renderFence and blockRows so the
211// quote bar cannot drift from the card (0.4.0 dropped the short header in
212// renderFence alone, and the bar ran a row long until 0.4.9)
213interface FenceShape {
214  code: string
215  lines: string[]
216  diagram: boolean
217  gutter: boolean // the numbered gutter and the header row, past 8 lines
218  digits: number
219  width: number // the card, padding included
220}
221
222function fenceShape(lang: string, source: string, m: number): FenceShape {
223  // tabs expand here: the terminal jumps a tab without painting the card's
224  // tint, and the engine counts it as one cell (as renderDiff does)
225  const code = source.replace(/\n$/, '').replace(/\t/g, '    ')
226  const lines = code === '' ? [] : code.split('\n')
227  const diagram = isDiagram(lang, code)
228  const gutter = !diagram && lines.length > 8
229  const digits = String(lines.length).length
230  const widest = Math.max(0, ...lines.map(cellWidth))
231  const header = cellWidth(lang) + (gutter ? cellWidth(`${lines.length} lines`) + 2 : 0)
232  const width = cardWidth(m, Math.max(widest + (gutter ? digits + 2 : 0), header) + 2)
233  return { code, lines, diagram, gutter, digits, width }
234}
235
236// rows a fence draws: the header, then a row per line, more where a code
237// line wraps (its indent counts, the engine keeps leading spaces); a
238// diagram's rows are cut, never wrapped
239function fenceRows(s: FenceShape): number {
240  const text = s.width - 2 - (s.gutter ? s.digits + 2 : 0)
241  const wrapped = (l: string) => wrappedLines([{ kind: 'text', text: l.replace(/^ +/, i => 'x'.repeat(i.length)) }], text)
242  const body = s.diagram ? s.lines.length : s.lines.reduce((n, l) => n + wrapped(l), 0)
243  return (s.gutter ? 1 : 0) + Math.max(1, body)
244}
245
246// tinted card sized to its code: dim language left and line count right in
247// the header row, glass's own highlighter below, a gutter once long. A
248// diagram draws as typed: no header or gutter, rows cut at the card edge,
249// since a wrapped row breaks every box and arrow below it
250function renderFence(c: Ctx, lang: string, source: string): RenderElement {
251  const { t, p } = c
252  const { code, lines: codeLines, diagram, gutter, digits, width } = fenceShape(lang, source, c.m)
253  const lines = codeLines.length
254  const children: RenderElement[] = []
255  // a short fence has no header row: the highlighter colors by the
256  // language and the code says what it is (proposal 14, 2026-10-04)
257  if (gutter) {
258    children.push(
259      t.Box({
260        flexDirection: 'row',
261        children: [
262          t.Text({ color: p.faint, children: [lang ? lang.toUpperCase() : ''] }),
263          t.Box({ flexGrow: 1, children: [] }),
264          t.Text({ color: p.faint, children: [`${lines} lines`] }),
265        ],
266      }),
267    )
268  }
269  // the body is glass's own highlighter (hooks/highlight.ts), one row per
270  // line, the gutter in `faint` once over 8 lines: the engine's `Code`
271  // paints with a theme the owner rejected (2026-10-03)
272  highlight(code, lang.toLowerCase()).forEach((spans, i) => {
273    // a blank line is one space, so its row is drawn: a Text with no text
274    // may measure no rows at all, and the card would lose its blank lines
275    const body = t.Text({
276      wrap: diagram ? 'truncate-end' : 'wrap',
277      children: spans.length === 0 ? [' '] : spans.flatMap((s): RenderNode[] => (s.kind === 'plain' ? cut(s.text) : [t.Text({ color: p[CODE_COLOR[s.kind]], children: cut(s.text) })])),
278    })
279    children.push(
280      gutter
281        ? t.Box({
282            flexDirection: 'row',
283            children: [
284              t.Text({ color: p.faint, children: [String(i + 1).padStart(digits) + '  '] }),
285              t.Box({ flexGrow: 1, flexShrink: 1, children: [body] }),
286            ],
287          })
288        : body,
289    )
290  })
291  return t.Box({
292    flexDirection: 'column',
293    width,
294    backgroundColor: p.blockBg,
295    paddingLeft: 1,
296    paddingRight: 1,
297    children,
298  })
299}
300
301
302function renderList(c: Ctx, items: ListItem[]): RenderElement {
303  const { t, p, m } = c
304  const ordered = items.filter(it => /^\d/.test(it.marker))
305  const numWidth = Math.max(0, ...ordered.map(it => cellWidth(it.marker)))
306  const marker = (it: ListItem): RenderElement => {
307    // a done task is the one list mark that gets a color, as in tidepool
308    if (it.task === 'done') return t.Text({ color: p.ok, children: [G.check + ' '] })
309    if (it.task === 'todo') return t.Text({ color: p.bullet, children: [G.box + ' '] })
310    if (/^\d/.test(it.marker)) return t.Text({ color: p.orderedNum, children: [it.marker.padEnd(numWidth) + ' '] })
311    return t.Text({ color: p.bullet, children: [(it.depth > 0 ? G.ring : G.dot) + ' '] })
312  }
313  const row = (it: ListItem) =>
314    t.Box({
315      flexDirection: 'row',
316      // nested items end at the measure like everything else
317      width: Math.max(10, m - it.depth * 2),
318      marginLeft: it.depth * 2,
319      children: [
320        marker(it),
321        t.Box({
322          flexGrow: 1,
323          flexShrink: 1,
324          children: [t.Text({ wrap: 'wrap', children: renderInlines(c, it.inlines) })],
325        }),
326      ],
327    })
328  return t.Box({
329    flexDirection: 'column',
330    children: items.map(it => {
331      if (!it.blocks?.length) return row(it)
332      // a fence typed under the item sits under its text, at the text's
333      // left edge, as wide as the item's text at most (0.4.22)
334      const indent = it.depth * 2 + itemMarker(it, numWidth)
335      const inner = { ...c, m: Math.max(10, m - indent) }
336      return t.Box({
337        flexDirection: 'column',
338        children: [row(it), ...it.blocks.map(b => t.Box({ marginLeft: indent, children: [renderBlock(inner, b)] }))],
339      })
340    }),
341  })
342}
343
344// cells an item's marker takes before its text
345function itemMarker(it: ListItem, numWidth: number): number {
346  return /^\d/.test(it.marker) && !it.task ? numWidth + 1 : 2
347}
348
349// A quote: a thin continuous bar down the left edge, one quarter-block
350// glyph per row of the quote (the row count estimated from the same
351// wrapping the engine applies), a space, then the inner blocks at `M - 2`
352// with a blank row between them. No tint.
353function renderQuote(c: Ctx, blocks: Block[]): RenderElement {
354  const { t, p, m } = c
355  const inner = { ...c, m: m - 2 }
356  const height = quoteRows(blocks, m)
357  return t.Box({
358    flexDirection: 'row',
359    children: [
360      t.Box({ width: 2, flexShrink: 0, children: [t.Text({ color: p.quoteBar, children: [Array(height).fill(G.mark).join('\n')] })] }),
361      t.Box({
362        flexDirection: 'column',
363        flexGrow: 1,
364        flexShrink: 1,
365        width: m - 2,
366        rowGap: 1,
367        children: blocks.map(b => renderBlock(inner, b)),
368      }),
369    ],
370  })
371}
372
373// rows a quote takes at measure `m`: its blocks at `m - 2`, a blank between
374export function quoteRows(blocks: Block[], m: number): number {
375  const w = m - 2
376  const rows = blocks.reduce((n, b) => n + blockRows(b, w), 0) + Math.max(0, blocks.length - 1)
377  return Math.max(1, rows)
378}
379
380// rows a block takes at measure `m`, mirroring what renderBlock draws
381function blockRows(b: Block, m: number): number {
382  switch (b.kind) {
383    case 'paragraph':
384    case 'heading':
385      return wrappedLines(b.inlines, m)
386    case 'code':
387      return fenceRows(fenceShape(b.lang, b.source, m))
388    case 'list': {
389      const ordered = b.items.filter(it => /^\d/.test(it.marker))
390      const numWidth = Math.max(0, ...ordered.map(it => cellWidth(it.marker)))
391      return b.items.reduce((n, it) => {
392        const marker = itemMarker(it, numWidth)
393        const width = Math.max(10, m - it.depth * 2) - marker
394        const under = (it.blocks ?? []).reduce((k, inner) => k + blockRows(inner, Math.max(10, m - it.depth * 2 - marker)), 0)
395        return n + wrappedLines(it.inlines, width) + under
396      }, 0)
397    }
398    case 'rule':
399      return 1
400    case 'raw':
401      return wrappedLines([{ kind: 'text', text: b.text }], m)
402    case 'quote':
403      return quoteRows(b.blocks, m)
404    case 'table':
405      // a table too wide even squeezed is the engine's Markdown, estimated as raw is
406      return tableColumns(b.header, b.rows, m) ? b.rows.length + 2 : wrappedLines([{ kind: 'text', text: b.raw }], m)
407    case 'callout': {
408      const rowWidth = (r: CalloutRow) => 2 + LABEL_WIDTH + 2 + inlineWidth(r.inlines) + 1
409      const width = cardWidth(m, Math.max(0, ...b.rows.map(rowWidth)))
410      const textWidth = width - 2 - LABEL_WIDTH - 2 - 1
411      return 1 + Math.max(1, b.rows.reduce((n, r) => n + wrappedLines(r.inlines, textWidth), 0))
412    }
413  }
414}
415
416// each column's width: its widest cell, header included
417function tableWidths(header: Inline[][], rows: Inline[][][]): number[] {
418  return Array.from({ length: header.length }, (_, col) =>
419    Math.max(1, inlineWidth(header[col] ?? []), ...rows.map(r => inlineWidth(r[col] ?? []))),
420  )
421}
422
423// the column widths glass draws the table with at measure `m`, two cells
424// kept free: too wide, the widest column gives up a cell at a time down to
425// COL_MIN; null when even that does not fit. The quote bar asks the same
426// question renderTable does.
427function tableColumns(header: Inline[][], rows: Inline[][][], m: number): number[] | null {
428  const widths = tableWidths(header, rows)
429  const room = m - 2 - 2 * (header.length - 1)
430  let over = widths.reduce((a, w) => a + w, 0) - room
431  while (over > 0) {
432    const widest = widths.indexOf(Math.max(...widths))
433    if ((widths[widest] ?? 0) <= COL_MIN) return null
434    widths[widest]!--
435    over--
436  }
437  return widths
438}
439
440// a cell past its column's width: its text cut in the middle (styling goes,
441// the cell being plain text from there)
442function squeeze(inlines: Inline[], w: number): Inline[] {
443  return inlineWidth(inlines) <= w ? inlines : [{ kind: 'text', text: clipMiddle(inlineText(inlines), w) }]
444}
445
446// row-separator table: bold header, one rule, two-cell gaps, no verticals;
447// numeric columns right-align unless the markdown says otherwise; a table
448// wider than the measure squeezes its widest columns, cells cut in the
449// middle, and falls back to the engine's renderer only when that is not
450// enough (a table three cells too wide jumped to the engine's boxes)
451function renderTable(c: Ctx, header: Inline[][], align: (Align | null)[], rows: Inline[][][], raw: string): RenderElement {
452  const { t, p } = c
453  const cols = header.length
454  const widths = tableColumns(header, rows, c.m)
455  if (!widths) return t.Markdown({ text: raw.slice(0, MAX_TEXT) })
456  header = header.map((h, i) => squeeze(h, widths[i] ?? COL_MIN))
457  rows = rows.map(r => r.map((cell, i) => squeeze(cell, widths[i] ?? COL_MIN)))
458  // `42`, `-3`, `1.5k`, `$12`, `80%`, `12ms`; an empty cell does not decide
459  const numeric = (col: number) => {
460    const cells = rows.map(r => inlineText(r[col] ?? []).trim()).filter(x => x !== '')
461    return cells.length > 0 && cells.every(x => NUMERIC_RE.test(x))
462  }
463  const cell = (inlines: Inline[], col: number, isHeader: boolean): RenderElement => {
464    const pad = Math.max(0, (widths[col] ?? 0) - inlineWidth(inlines))
465    const a: Align = align[col] ?? (numeric(col) ? 'right' : 'left')
466    // the header sits where its column's cells do, so `Count` ends over its numbers
467    const last = col === cols - 1
468    // the last column never pads to its right, nor to its left when empty
469    const left = last && inlineWidth(inlines) === 0 ? 0 : a === 'right' ? pad : a === 'center' ? Math.floor(pad / 2) : 0
470    const right = last ? 0 : pad - left
471    return t.Text({
472      ...(isHeader ? { bold: true, color: p.bold } : {}),
473      children: [' '.repeat(left), ...renderInlines(c, inlines), ' '.repeat(right) + (last ? '' : '  ')],
474    })
475  }
476  const line = (cells: Inline[][], isHeader: boolean) =>
477    t.Box({
478      flexDirection: 'row',
479      children: Array.from({ length: cols }, (_, col) => cell(cells[col] ?? [], col, isHeader)),
480    })
481  return t.Box({
482    flexDirection: 'column',
483    children: [
484      line(header, true),
485      t.Text({ color: p.rule, children: [widths.map(w => G.rule.repeat(w)).join('  ')] }),
486      ...rows.map(r => line(r, false)),
487    ],
488  })
489}
490
491// lines a run of inlines takes when wrapped at `width`, the way the engine
492// wraps: at spaces, a word wider than the line broken across lines
493function wrappedLines(inlines: Inline[], width: number): number {
494  if (width < 1) return 1
495  let lines = 0
496  for (const raw of inlineText(inlines).split('\n')) {
497    lines++
498    let col = 0
499    for (const word of raw.split(' ')) {
500      const w = cellWidth(word)
501      if (w === 0 && col === 0) continue
502      const need = col === 0 ? w : col + 1 + w
503      if (need <= width) {
504        col = need
505      } else if (w > width) {
506        // a word wider than the line starts on a fresh line and is chopped
507        if (col > 0) lines++
508        lines += Math.ceil(w / width) - 1
509        col = w % width || width
510      } else {
511        lines++
512        col = w
513      }
514    }
515  }
516  return lines
517}
518
519// The Bottom line card. The title sits above the card as a plain bold line;
520// only the labelled rows live inside, sized to the longest row. A thin
521// continuous bar down the left edge: a quarter-block glyph on every row of
522// the card, the row count computed from the same wrapping the engine
523// applies. Compact: no padding and no blank rows; the colored label column
524// keeps the rows apart.
525function renderCallout(c: Ctx, title: string, rows: CalloutRow[]): RenderElement {
526  const { t, p } = c
527  const rowWidth = (r: CalloutRow) => 2 + LABEL_WIDTH + 2 + inlineWidth(r.inlines) + 1
528  const width = cardWidth(c.m, Math.max(0, ...rows.map(rowWidth)))
529  const textWidth = width - 2 - LABEL_WIDTH - 2 - 1
530  const lines = rows.map(r => wrappedLines(r.inlines, textWidth))
531  // each row's wrapped lines; the title is outside the card
532  const height = Math.max(1, lines.reduce((n, l) => n + l, 0))
533  const card = t.Box({
534    flexDirection: 'row',
535    width,
536    backgroundColor: p.blockBg,
537    children: [
538      t.Box({ width: 1, children: [t.Text({ color: p.calloutBar, children: [Array(height).fill(G.mark).join('\n')] })] }),
539      t.Box({
540        flexDirection: 'column',
541        flexGrow: 1,
542        flexShrink: 1,
543        paddingLeft: 1,
544        children: rows.map(r =>
545          t.Box({
546            flexDirection: 'row',
547            children: [
548              // a fixed-width box, so a long row never squeezes the label column
549              t.Box({
550                width: LABEL_WIDTH + 2,
551                flexShrink: 0,
552                children: [t.Text({ bold: true, color: p[LABEL_COLOR[r.label] ?? 'calloutText'], children: [r.label.padEnd(LABEL_WIDTH) + '  '] })],
553              }),
554              t.Box({
555                flexGrow: 1,
556                flexShrink: 1,
557                paddingRight: 1,
558                children: [t.Text({ color: p.calloutText, wrap: 'wrap', children: renderInlines(c, r.inlines) })],
559              }),
560            ],
561          }),
562        ),
563      }),
564    ],
565  })
566  return t.Box({
567    flexDirection: 'column',
568    children: [t.Text({ bold: true, color: p.calloutBar, children: [title] }), card],
569  })
570}
571
572function renderInlines(c: Ctx, inlines: Inline[]): RenderNode[] {
573  const { t, p } = c
574  return inlines.flatMap((n): RenderNode[] => {
575    switch (n.kind) {
576      case 'text':
577        return renderProse(c, n.text)
578      case 'bold':
579        return [t.Text({ bold: true, color: p.bold, children: renderInlines(c, n.children) })]
580      case 'italic':
581        return [t.Text({ italic: true, children: renderInlines(c, n.children) })]
582      case 'strike':
583        return [t.Text({ strikethrough: true, children: renderInlines(c, n.children) })]
584      case 'link':
585        return [renderLink(c, n.text, n.href)]
586      case 'code':
587        return [renderCode(c, n.text)]
588    }
589  })
590}
591
592// Prose outside backticks, painted the way claude-hl painted it: command
593// spans once there is evidence (`git push --follow-tags`, never `make
594// sure`), then paths and URLs anywhere (`README.md`, `~/.zshrc`,
595// `src/main.rs:42` with its line number in the number color).
596function renderProse(c: Ctx, text: string): RenderNode[] {
597  const { t, p } = c
598  const out: RenderNode[] = []
599  const spans = proseSpans(text)
600  let pos = 0
601  const colored = (s: string, kind: SpanKind): RenderNode[] => (kind === 'plain' ? cut(s) : [t.Text({ color: p[kind], children: cut(s) })])
602  for (const s of spans) {
603    if (s.start > pos) out.push(...renderWords(c, text.slice(pos, s.start)))
604    out.push(...colored(text.slice(s.start, s.end), s.kind))
605    pos = s.end
606  }
607  if (pos < text.length) out.push(...renderWords(c, text.slice(pos)))
608  return out
609}
610
611// paths and URLs in a run of plain prose
612function renderWords(c: Ctx, text: string): RenderNode[] {
613  if (!/[/.@]/.test(text)) return cut(text)
614  const { t, p } = c
615  const out: RenderNode[] = []
616  let buf = ''
617  const flush = () => {
618    if (buf) out.push(...cut(buf))
619    buf = ''
620  }
621  for (const part of text.split(/(\s+)/)) {
622    const { lead, word, tail } = bareWord(part)
623    if (!word) {
624      buf += part
625      continue
626    }
627    if (isUrl(word)) {
628      buf += lead
629      flush()
630      out.push(t.Text({ color: p.url, underline: true, children: cut(word) }))
631      buf += tail
632      continue
633    }
634    const path = pathLike(word)
635    if (path) {
636      buf += lead
637      flush()
638      out.push(t.Text({ color: p.path, children: cut(path.path) }))
639      if (path.lineno) out.push(t.Text({ color: p.num, children: [path.lineno] }))
640      buf += tail
641      continue
642    }
643    buf += part
644  }
645  flush()
646  return out
647}
648
649function renderLink(c: Ctx, label: string, href: string): RenderElement {
650  const { t, p } = c
651  const styled = t.Text({ color: p.url, underline: true, children: cut(label) })
652  const safe = safeHref(href)
653  return safe ? t.Link({ href: safe, children: [styled] }) : styled
654}
655
656// the Link element refuses the whole tree on a bad href, so be strict
657function safeHref(href: string): string | null {
658  try {
659    const u = new URL(href)
660    const ok = u.protocol === 'https:' || (u.protocol === 'http:' && u.hostname === 'localhost')
661    if (!ok || u.username || u.password || u.href.length > 2048) return null
662    if (!/^[\x21-\x7e]+$/.test(u.href)) return null
663    return u.href
664  } catch {
665    return null
666  }
667}
668
669// Inline code is color only, as Claude Desktop and the Codex TUI draw it:
670// shell tokens in their kind colors, paths in the path color underlined,
671// anything else in the code color. No tint, no padding.
672function renderCode(c: Ctx, code: string): RenderElement {
673  const { t, p } = c
674  const spans = shellSpans(code)
675  if (spans) {
676    return t.Text({
677      children: spans.flatMap((s): RenderNode[] => (s.kind === 'plain' ? cut(s.text) : [t.Text({ color: p[s.kind], children: cut(s.text) })])),
678    })
679  }
680  if (PATH_RE.test(code)) return t.Text({ color: p.path, children: cut(code) })
681  return t.Text({ color: p.code, children: cut(code) })
682}
683
hooks/highlight.ts 181 lines
1// glass's own code highlighter: one small tokenizer for the C-like family
2// and the script languages, painting with the palette's code keys. The
3// engine's `Code` element paints with its own theme, which the owner
4// rejected (2026-10-03); this keeps fences and diffs in the mod's colors.
5// Shell fences go through `shellSpans` and paint with the prose keys, so a
6// command reads in a fence exactly as it does in prose: the same command,
7// flag, string and operator colors (they took the code keys until 0.4.20,
8// so `--force` and `|` drew differently in the two places). Line by line: a block comment opened on one line carries to the
9// next through `State`.
10import type { Palette } from './palette'
11import { shellSpans } from './shell'
12import type { SpanKind } from './shell'
13
14// `sh.*`: a shell token in a fence, painted with the prose key of its kind
15export type CodeKind = 'kw' | 'fn' | 'type' | 'str' | 'num' | 'comment' | 'plain' | `sh.${Exclude<SpanKind, 'plain' | 'comment'>}`
16export type CodeSpan = { text: string; kind: CodeKind }
17
18/** the palette key a code kind paints with */
19export const CODE_COLOR: Record<Exclude<CodeKind, 'plain'>, keyof Palette> = {
20  kw: 'codeKw',
21  fn: 'codeFn',
22  type: 'codeType',
23  str: 'codeStr',
24  num: 'codeNum',
25  comment: 'comment',
26  'sh.cmd': 'cmd',
27  'sh.sub': 'sub',
28  'sh.flag': 'flag',
29  'sh.str': 'str',
30  'sh.path': 'path',
31  'sh.op': 'op',
32  'sh.num': 'num',
33  'sh.var': 'var',
34  'sh.url': 'url',
35}
36
37const SHELL_LANGS = new Set(['sh', 'bash', 'zsh', 'shell', 'console', 'fish'])
38// prose files: a diff of README.md is English, not code, so nothing paints
39const PROSE_LANGS = new Set(['md', 'markdown', 'mdx', 'txt', 'text', 'rst', 'adoc', 'asciidoc', 'org', 'tex', ''])
40// `#` opens a comment here; everywhere else `//` does
41const HASH_LANGS = new Set(['py', 'python', 'rb', 'ruby', 'yaml', 'yml', 'toml', 'ini', 'conf', 'perl', 'pl', 'r', 'make', 'makefile', 'dockerfile', 'nix', 'elixir', 'ex', 'exs', 'cmake'])
42const DASH_LANGS = new Set(['sql', 'lua', 'hs', 'haskell', 'ada'])
43
44const KEYWORDS = new Set(
45  `abstract as async await break case catch class const continue debugger default delete do else enum export extends
46   finally for from function if implements import in instanceof interface let new of package private protected public
47   return static super switch this throw try typeof var void while with yield readonly declare namespace module type
48   keyof infer satisfies override get set
49   def elif except lambda pass raise global nonlocal and or not is assert del with yield
50   fn impl struct trait mut pub use mod match loop where unsafe crate self Self dyn ref move
51   func go chan defer select range map fallthrough goto package
52   int float double char long short unsigned signed bool boolean byte string void auto struct union typedef sizeof
53   static_assert constexpr template typename virtual inline explicit friend operator
54   final native synchronized transient volatile throws instanceof extends implements
55   begin end then elsif unless until rescue ensure module require include attr_accessor
56   select insert update delete into values create table drop alter where join on group by order having limit
57   local nil repeat`
58    .split(/\s+/)
59    .filter(Boolean),
60)
61const CONSTANTS = new Set(['true', 'false', 'null', 'undefined', 'None', 'True', 'False', 'nil', 'NaN', 'Infinity'])
62
63export type State = { inBlock: boolean }
64
65const IDENT = /[A-Za-z_$][\w$]*/y
66const NUMBER = /0[xX][\da-fA-F_]+|0[bB][01_]+|\d[\d_]*(?:\.\d[\d_]*)?(?:[eE][+-]?\d+)?[a-zA-Z]*/y
67
68/** the language id a fence or a file path resolves to, lowercased */
69export function langOf(hint: string): string {
70  const h = hint.toLowerCase()
71  const dot = h.lastIndexOf('.')
72  const ext = dot >= 0 && !/[\/\\]/.test(h.slice(dot)) ? h.slice(dot + 1) : h
73  const base = h.split(/[\/\\]/).pop() ?? h
74  if (base === 'makefile' || base === 'dockerfile') return base
75  return ext
76}
77
78/** one line as colored spans; `state` carries a block comment across lines */
79export function highlightLine(line: string, lang: string, state: State = { inBlock: false }): CodeSpan[] {
80  if (PROSE_LANGS.has(lang)) return line === '' ? [] : [{ text: line, kind: 'plain' }]
81  if (SHELL_LANGS.has(lang)) {
82    // a comment-only line: shellSpans wants a command first, and the
83    // fallback below opens comments with `//`
84    const note = /^(\s*)(#.*)$/.exec(line)
85    if (note) return [...(note[1] ? [{ text: note[1], kind: 'plain' as const }] : []), { text: note[2]!, kind: 'comment' }]
86    const spans = shellSpans(line, true)
87    if (spans) return spans.map(s => ({ text: s.text, kind: s.kind === 'plain' || s.kind === 'comment' ? s.kind : (`sh.${s.kind}` as const) }))
88  }
89  const hash = HASH_LANGS.has(lang)
90  const dash = DASH_LANGS.has(lang)
91  const out: CodeSpan[] = []
92  const push = (text: string, kind: CodeKind) => {
93    if (text === '') return
94    const last = out[out.length - 1]
95    if (last && last.kind === kind) last.text += text
96    else out.push({ text, kind })
97  }
98  let i = 0
99  const n = line.length
100  if (state.inBlock) {
101    const end = line.indexOf('*/')
102    if (end < 0) {
103      push(line, 'comment')
104      return out
105    }
106    push(line.slice(0, end + 2), 'comment')
107    i = end + 2
108    state.inBlock = false
109  }
110  while (i < n) {
111    const ch = line[i]!
112    const two = line.slice(i, i + 2)
113    // comments
114    if ((!hash && !dash && two === '//') || (hash && ch === '#') || (dash && two === '--')) {
115      push(line.slice(i), 'comment')
116      break
117    }
118    if (!hash && two === '/*') {
119      const end = line.indexOf('*/', i + 2)
120      if (end < 0) {
121        push(line.slice(i), 'comment')
122        state.inBlock = true
123        break
124      }
125      push(line.slice(i, end + 2), 'comment')
126      i = end + 2
127      continue
128    }
129    // strings: one line, escapes honored
130    if (ch === '"' || ch === "'" || ch === '`') {
131      let j = i + 1
132      while (j < n && line[j] !== ch) j += line[j] === '\\' ? 2 : 1
133      push(line.slice(i, Math.min(n, j + 1)), 'str')
134      i = Math.min(n, j + 1)
135      continue
136    }
137    // numbers
138    if (/\d/.test(ch) && !(i > 0 && /[\w$]/.test(line[i - 1]!))) {
139      NUMBER.lastIndex = i
140      const m = NUMBER.exec(line)
141      if (m) {
142        push(m[0], 'num')
143        i += m[0].length
144        continue
145      }
146    }
147    // words
148    if (/[A-Za-z_$]/.test(ch)) {
149      IDENT.lastIndex = i
150      const m = IDENT.exec(line)!
151      const word = m[0]
152      // the nearest non-space character after the word and before it,
153      // found in place: copying the line per word was quadratic
154      let a = i + word.length
155      while (a < n && /\s/.test(line[a]!)) a++
156      const after = line[a]
157      let b = i - 1
158      while (b >= 0 && /\s/.test(line[b]!)) b--
159      const before = b >= 0 ? line[b]! : ''
160      let kind: CodeKind = 'plain'
161      if (KEYWORDS.has(word) && before !== '.') kind = 'kw'
162      else if (CONSTANTS.has(word)) kind = 'num'
163      else if (after === '(') kind = 'fn'
164      else if (/^[A-Z]/.test(word) && /[a-z]/.test(word)) kind = 'type'
165      else if (before === ':' && !hash) kind = 'type'
166      push(word, kind)
167      i += word.length
168      continue
169    }
170    push(ch, 'plain')
171    i++
172  }
173  return out
174}
175
176/** every line of a source as spans, block comments carried across */
177export function highlight(source: string, lang: string): CodeSpan[][] {
178  const state: State = { inBlock: false }
179  return source.split('\n').map(line => highlightLine(line, lang, state))
180}
181
hooks/output.ts 83 lines
1// Paints a tool's text output the way claude-hl painted Claude Code's gray
2// output: commands with evidence, paths and URLs, error, warning and success
3// words, numbers and durations, check and cross marks, and git status codes
4// at the start of a line. Returns colored segments; render.ts draws them as
5// Text, since the engine strips escape codes from a rewritten result.
6import type { Palette } from './palette'
7import { bareWord, isUrl, pathLike } from './paths'
8import { proseSpans } from './shell'
9
10const ERR_WORDS = new Set('error errors Error ERROR FAILED FAIL failed fail failure panicked panic fatal Fatal FATAL'.split(' '))
11const WARN_WORDS = new Set('warning warnings Warning WARNING warn WARN deprecated Deprecated'.split(' '))
12const OK_WORDS = new Set('ok OK passed pass PASS PASSED success Success succeeded done Done'.split(' '))
13
14const cp = (n: number) => String.fromCodePoint(n)
15const CHECKS = new Set([cp(0x2713), cp(0x2714)])
16const CROSSES = new Set([cp(0x2717), cp(0x2718), cp(0xd7)])
17
18// `12`, `0.42s`, `1.2k`, `80%`: a number with at most two letters or a percent
19const NUMBER_RE = /^\d+(?:\.\d+)*(?:[A-Za-z]{1,2}|%)?$/
20
21/** a run of output text; `color` absent means the output's own gray */
22export type Segment = { text: string; color?: keyof Palette }
23
24function wordSegments(raw: string): Segment[] {
25  const { lead, word, tail } = bareWord(raw)
26  if (!word) return [{ text: raw }]
27  const wrap = (color: keyof Palette, body = word): Segment[] => [
28    ...(lead ? [{ text: lead }] : []),
29    { text: body, color },
30    ...(tail ? [{ text: tail }] : []),
31  ]
32  if (ERR_WORDS.has(word) || CROSSES.has(word)) return wrap('err')
33  if (WARN_WORDS.has(word)) return wrap('warn')
34  if (OK_WORDS.has(word) || CHECKS.has(word)) return wrap('ok')
35  if (NUMBER_RE.test(word)) return wrap('num')
36  if (isUrl(word)) return wrap('url')
37  const path = pathLike(word)
38  if (path) {
39    return [
40      ...(lead ? [{ text: lead }] : []),
41      { text: path.path, color: 'path' },
42      ...(path.lineno ? [{ text: path.lineno, color: 'num' as const }] : []),
43      ...(tail ? [{ text: tail }] : []),
44    ]
45  }
46  return [{ text: raw }]
47}
48
49// plain text: words and the whitespace between them
50function textSegments(text: string): Segment[] {
51  return text.split(/(\s+)/).flatMap(part => (part === '' ? [] : /^\s+$/.test(part) ? [{ text: part }] : wordSegments(part)))
52}
53
54// git status codes at the start of a line: `M src/x.rs`, `?? new/`
55function gitStatus(line: string): { head: Segment[]; rest: string } | null {
56  const m = /^(\s*)([MADRCU?!]{1,2})(\s{1,2})(\S+)/.exec(line)
57  if (!m) return null
58  const codes = m[2]!
59  const word = m[4]!
60  if (!(word.includes('/') || word.includes('.') || codes.length === 2)) return null
61  const color = (c: string): keyof Palette =>
62    c === 'A' ? 'ok' : c === 'D' || c === 'U' ? 'err' : c === '?' || c === '!' ? 'comment' : 'warn'
63  const head: Segment[] = [...(m[1] ? [{ text: m[1] }] : []), ...[...codes].map(c => ({ text: c, color: color(c) }))]
64  return { head, rest: line.slice(m[1]!.length + codes.length) }
65}
66
67/** one output line as colored segments */
68export function paintLine(line: string): Segment[] {
69  const git = gitStatus(line)
70  const head = git ? git.head : []
71  const body = git ? git.rest : line
72  const out: Segment[] = [...head]
73  let pos = 0
74  for (const s of proseSpans(body)) {
75    if (s.start > pos) out.push(...textSegments(body.slice(pos, s.start)))
76    const kind = s.kind === 'plain' ? undefined : (s.kind as keyof Palette)
77    out.push(kind ? { text: body.slice(s.start, s.end), color: kind } : { text: body.slice(s.start, s.end) })
78    pos = s.end
79  }
80  if (pos < body.length) out.push(...textSegments(body.slice(pos)))
81  return out
82}
83
hooks/shell.ts 484 lines
1// Shell tokenizer for inline code, ported from claude-hl's `spans`.
2// Inline code is a known code context, so every token gets a color; the
3// prose guards (stop words, bare-word budget) are not needed here.
4
5const COMMANDS = new Set(
6  `git gh npm npx pnpm yarn bun bunx node deno python python3 pip pip3 uv go cargo
7rustc make cmake brew apt apt-get dnf pacman docker docker-compose kubectl helm
8terraform aws gcloud az ssh scp rsync curl wget tar zip unzip cd ls cat head
9tail less grep rg fd find xargs sed awk sort uniq wc tr cut tee echo printf
10export source chmod chown mkdir rmdir rm cp mv ln touch pwd which env nvim vim
11tmux ghostty claude claude-hl codex gemini open kill pkill ps lsof jq yq tsc tsx vitest
12jest eslint prettier next vite
13sudo doas nohup just pytest ruff mypy poetry pipx uvx conda mvn gradle dotnet
14swift ruby gem bundle rake rspec php composer psql mysql sqlite3 redis-cli
15mongosh systemctl journalctl launchctl xcodebuild xcrun flutter dart adb
16ffmpeg pandoc openssl gpg shellcheck protoc ansible vagrant nix zig perl lua
17gcc clang ninja bazel
18bash sh zsh fish exec exit man diff patch du df sleep time watch bat eza tree stat
19rustup nvm fnm pm2 podman k9s minikube kind htop nc dig ping ssh-keygen base64
20shasum sha256sum xxd hyperfine tokei cloc fzf zoxide code cursor pbcopy pbpaste
21xdg-open turbo nx lerna webpack esbuild biome prisma wrangler vercel netlify
22flyctl heroku supabase firebase ngrok mise asdf direnv gdb lldb valgrind strace
23mkcert caddy certbot crontab screen zellij`.split(/\s+/),
24)
25
26// tools whose bare-word args are subcommands; for others only flags, paths
27// and strings count
28const SUBCMD_TOOLS = new Set(
29  `git gh npm npx pnpm yarn bun bunx deno uv pip pip3 go cargo brew apt apt-get
30dnf pacman docker docker-compose kubectl helm terraform aws gcloud az claude
31codex gemini tmux make jq tsc next vite
32sudo doas nohup poetry pipx conda mvn gradle dotnet swift gem bundle rake
33composer systemctl journalctl launchctl flutter dart adb nix vagrant bazel openssl
34man rustup nvm fnm pm2 podman minikube kind turbo nx lerna prisma wrangler vercel
35netlify flyctl heroku supabase firebase mise asdf direnv crontab`.split(/\s+/),
36)
37
38// prefix runners: a known command right after them re-anchors the highlight
39const CHAIN_TOOLS = new Set('sudo doas env xargs nohup time watch exec'.split(' '))
40
41const MAX_SUB = 3
42
43export type SpanKind =
44  | 'cmd'
45  | 'sub'
46  | 'flag'
47  | 'str'
48  | 'path'
49  | 'op'
50  | 'num'
51  | 'var'
52  | 'url'
53  | 'comment'
54  | 'plain'
55
56export type Span = { text: string; kind: SpanKind }
57
58type TokKind =
59  | 'ws'
60  | 'str'
61  | 'chain'
62  | 'redirect'
63  | 'flag'
64  | 'path'
65  | 'sub'
66  | 'word'
67  | 'num'
68  | 'var'
69  | 'url'
70  | 'comment'
71
72type Tok = { kind: TokKind; start: number; end: number }
73
74const isSpace = (c: string) => /\s/.test(c)
75// newlines are whitespace too: multi-line commands keep their colors
76const isWs = (c: string) => c === ' ' || c === '\t' || c === '\n' || c === '\r'
77const isWord = (c: string) => /[A-Za-z0-9_]/.test(c)
78const isPathMark = (c: string) => '/.~=:@*'.includes(c)
79const isSentencePunct = (c: string) => '.,;:)'.includes(c)
80
81function wordEnd(t: string, j: number): number {
82  while (j < t.length && !isSpace(t[j]!)) j++
83  return j
84}
85
86function nextArg(t: string, i: number): Tok | null {
87  const n = t.length
88  if (i >= n) return null
89  const b = t[i]!
90  const atBoundary = (j: number) => j >= n || isSpace(t[j]!)
91  const opEnd = (j: number) => atBoundary(j) || !'><&|;'.includes(t[j]!)
92
93  if (isWs(b)) {
94    let j = i
95    while (j < n && isWs(t[j]!)) j++
96    return { kind: 'ws', start: i, end: j }
97  }
98  if (b === '"') {
99    let j = i + 1
100    while (j < n) {
101      if (t[j] === '\\' && j + 1 < n) j += 2
102      else if (t[j] === '"') return { kind: 'str', start: i, end: j + 1 }
103      else j++
104    }
105  } else if (b === "'" && !(i > 0 && /[A-Za-z0-9]/.test(t[i - 1]!))) {
106    // a quote opens only at a word's start, and closes on a quote that is
107    // not followed by a letter: the apostrophes in "the card's frame ...
108    // the card's room" are English, not a string (live, 2026-10-03)
109    let k = t.indexOf("'", i + 1)
110    while (k >= 0 && k + 1 < n && /[A-Za-z]/.test(t[k + 1]!) && /[A-Za-z]/.test(t[k - 1] ?? '')) k = t.indexOf("'", k + 1)
111    if (k >= 0) return { kind: 'str', start: i, end: k + 1 }
112  }
113  for (const op of ['&&', '||', '|', ';']) {
114    const j = i + op.length
115    if (t.startsWith(op, i) && (op === ';' ? atBoundary(j) : opEnd(j))) {
116      return { kind: 'chain', start: i, end: j }
117    }
118  }
119  for (const op of ['2>&1', '&>', '2>', '>>', '>', '<<', '<']) {
120    const j = i + op.length
121    if (t.startsWith(op, i) && opEnd(j)) return { kind: 'redirect', start: i, end: j }
122  }
123  if (b === '#' && (i + 1 >= n || isSpace(t[i + 1]!))) return { kind: 'comment', start: i, end: n }
124  if (b === '$') return { kind: 'var', start: i, end: wordEnd(t, i + 1) }
125  for (const scheme of ['http://', 'https://', 'ssh://', 'git@', 'file://', 'www.']) {
126    if (t.startsWith(scheme, i)) return { kind: 'url', start: i, end: wordEnd(t, i) }
127  }
128  if (b === '-') {
129    let j = i + 1
130    if (t[j] === '-') j++
131    if (j < n && /[A-Za-z]/.test(t[j]!)) {
132      j++
133      while (j < n && (isWord(t[j]!) || t[j] === '-')) j++
134      // `--flag=value`: the flag ends after `=`, the value is its own token
135      if (t[j] === '=') j++
136      return { kind: 'flag', start: i, end: j }
137    }
138    // numeric args: `tail -20`, `head -5` read as counts, not flags
139    if (j < n && /[0-9]/.test(t[j]!)) {
140      let k = j + 1
141      while (k < n && /[0-9]/.test(t[k]!)) k++
142      if (atBoundary(k) || t[k] === ')') return { kind: 'num', start: i, end: k }
143    }
144    // `--` and `-` on their own
145    if (atBoundary(j)) return { kind: 'flag', start: i, end: j }
146  }
147  // `chmod +x`, `date +%s`
148  if (b === '+' && i + 1 < n && !isSpace(t[i + 1]!)) return { kind: 'flag', start: i, end: wordEnd(t, i + 1) }
149  // numbers, versions, sizes, durations: 5  1.2.0  v1.2.0  10s  3.5GB  80%
150  if (/[0-9]/.test(b) || (b === 'v' && /[0-9]/.test(t[i + 1] ?? ''))) {
151    const j = wordEnd(t, i)
152    const bodyEnd = j - i > 1 && isSentencePunct(t[j - 1]!) ? j - 1 : j
153    const body = t.slice(i + (b === 'v' ? 1 : 0), bodyEnd)
154    if (body.length > 0 && /^[0-9.%kKmMgGBshd]+$/.test(body)) return { kind: 'num', start: i, end: j }
155  }
156  {
157    const j = wordEnd(t, i)
158    // `PORT=3000`: an environment assignment
159    if (/[A-Za-z_]/.test(b)) {
160      let k = i
161      while (k < j && isWord(t[k]!)) k++
162      if (k < j && t[k] === '=') return { kind: 'var', start: i, end: j }
163    }
164    const bodyEnd = j - i > 1 && isSentencePunct(t[j - 1]!) ? j - 1 : j
165    if ([...t.slice(i, bodyEnd)].some(isPathMark)) return { kind: 'path', start: i, end: j }
166  }
167  if (/[a-z]/.test(b)) {
168    let j = i + 1
169    while (j < n && /[a-z0-9-]/.test(t[j]!)) j++
170    // `size.` at the end of a sentence: keep the punctuation in the token so
171    // the span logic sees it and ends the span there
172    if (j < n && isSentencePunct(t[j]!) && atBoundary(j + 1)) j++
173    return { kind: 'sub', start: i, end: j }
174  }
175  if (isWord(b)) return { kind: 'word', start: i, end: wordEnd(t, i) }
176  return { kind: 'word', start: i, end: i + 1 }
177}
178
179/**
180 * Color spans for one inline-code string, or null when it is not a shell
181 * command line (its first real word is not a known command). With `trusted`
182 * the head is a command whatever it is: for a Bash tool row, where the text
183 * is known to be a command line.
184 */
185export function shellSpans(code: string, trusted = false): Span[] | null {
186  const t = code
187  const out: Span[] = []
188  const push = (start: number, end: number, kind: SpanKind) => {
189    if (end > start) out.push({ text: t.slice(start, end), kind })
190  }
191  let i = 0
192  // `$ ` prompt and leading env assignments precede the command
193  if (t.startsWith('$ ')) {
194    push(0, 2, 'comment')
195    i = 2
196  }
197  let cmd = ''
198  for (;;) {
199    const tok = nextArg(t, i)
200    if (!tok) return null
201    if (tok.kind === 'ws') {
202      push(tok.start, tok.end, 'plain')
203    } else if (tok.kind === 'var' && t[tok.start] !== '$') {
204      push(tok.start, tok.end, 'var')
205    } else {
206      const word = t.slice(tok.start, tok.end)
207      const isHead = tok.kind === 'sub' || tok.kind === 'word' || tok.kind === 'path'
208      if (isHead && (trusted || COMMANDS.has(word))) {
209        push(tok.start, tok.end, 'cmd')
210        cmd = word
211        i = tok.end
212        break
213      }
214      return null
215    }
216    i = tok.end
217  }
218
219  let subs = 0
220  let afterEq = false
221  let after: 'chain' | 'redirect' | null = null
222  let tok: Tok | null
223  while ((tok = nextArg(t, i))) {
224    const { kind, start, end } = tok
225    i = end
226    if (kind === 'ws') {
227      push(start, end, 'plain')
228      continue
229    }
230    const word = t.slice(start, end)
231    // a lone full stop or comma stays plain
232    if (word.length === 1 && isSentencePunct(word)) {
233      push(start, end, 'plain')
234      continue
235    }
236    // `--out=dist`: the word glued to a flag is its value
237    if (afterEq && (kind === 'sub' || kind === 'word')) {
238      afterEq = false
239      push(start, end, 'path')
240      continue
241    }
242    afterEq = kind === 'flag' && word.endsWith('=')
243
244    if (after === 'chain' && kind !== 'chain' && kind !== 'redirect') {
245      after = null
246      if ((kind === 'sub' || kind === 'word') && COMMANDS.has(word)) {
247        push(start, end, 'cmd')
248        cmd = word
249        subs = 0
250        continue
251      }
252      if (kind === 'path' || kind === 'var') {
253        push(start, end, kind)
254        cmd = ''
255        subs = 0
256        continue
257      }
258    }
259    if (after === 'redirect' && kind !== 'chain' && kind !== 'redirect') {
260      after = null
261      push(start, end, kind === 'str' || kind === 'var' || kind === 'num' ? kind : 'path')
262      continue
263    }
264
265    // a token carrying sentence punctuation: the body takes the color, the
266    // punctuation stays plain
267    const trimmed = TRIMMABLE.has(kind) && word.length > 1 && isSentencePunct(word[word.length - 1]!)
268    const bodyEnd = trimmed ? end - 1 : end
269    switch (kind) {
270      case 'sub':
271      case 'word': {
272        if (CHAIN_TOOLS.has(cmd) && COMMANDS.has(word)) {
273          push(start, bodyEnd, 'cmd')
274          cmd = word
275          subs = 0
276        } else if (kind === 'sub' && SUBCMD_TOOLS.has(cmd) && subs < MAX_SUB) {
277          subs++
278          push(start, bodyEnd, 'sub')
279        } else {
280          push(start, bodyEnd, 'path')
281        }
282        break
283      }
284      case 'chain':
285      case 'redirect':
286        after = kind
287        push(start, end, 'op')
288        break
289      default:
290        push(start, bodyEnd, kind)
291    }
292    if (trimmed) push(bodyEnd, end, 'plain')
293  }
294  return out
295}
296
297const TRIMMABLE = new Set<TokKind>(['sub', 'word', 'path', 'num', 'var'])
298
299// words that end a command span in prose: the sentence is resuming
300const STOP_WORDS = new Set(
301  'and or then to the a an in on for with is it that this if of at by from so but you we i will can should after before when do not into was are has have your our'.split(' '),
302)
303// in prose, bare non-subcommand words tolerated before giving up
304const MAX_BARE = 3
305// command words that are also plain English: a number after one (`sleep 5
306// seconds`, `code across 13 files`) is the sentence, not an argument, so it
307// does not count as evidence on its own; a flag, a path or an operator still does
308const ENGLISH = new Set(
309  'code sleep time watch open sort find cut tree kill touch make head tail less man diff patch which env source echo tee go kind dig ping stat zip tar cat'.split(' '),
310)
311// text right before a command that marks it as a command line rather than prose
312const RUNNER_PREFIXES = ['Ran', 'Run', 'Running', 'Bash(', '$']
313// chars that may not precede a command word
314const isCmdGlue = (c: string) => isWord(c) || '/.@~-'.includes(c)
315
316export type ProseSpan = { start: number; end: number; kind: SpanKind }
317
318// the next known command word at or after `from`, at a word boundary
319function findCmd(t: string, from: number): [number, number] | null {
320  let i = from
321  while (i < t.length) {
322    if (isSpace(t[i]!)) {
323      i++
324      continue
325    }
326    const start = i
327    while (i < t.length && !isSpace(t[i]!)) i++
328    for (let p = start; p < i; p++) {
329      if (p === 0 || !isCmdGlue(t[p - 1]!)) {
330        if (COMMANDS.has(t.slice(p, i))) return [p, i]
331      }
332    }
333  }
334  return null
335}
336
337/**
338 * Command spans inside prose, claude-hl's hard case. `make sure`, `go ahead`
339 * and `next step` are valid command shapes, so a command in a sentence only
340 * paints once a flag, path, string, number or operator turns up, or when a
341 * runner prefix (`Ran`, `$`) precedes it. `git push --follow-tags` paints,
342 * `git status` in a sentence does not, nor does `make sure the build passes`.
343 */
344export function proseSpans(text: string): ProseSpan[] {
345  const t = text
346  const out: ProseSpan[] = []
347  let pos = 0
348  let found: [number, number] | null
349  while ((found = findCmd(t, pos))) {
350    const [cs, ce] = found
351    let cmd = t.slice(cs, ce)
352    const mark = out.length
353    out.push({ start: cs, end: ce, kind: 'cmd' })
354    const before = t.slice(0, cs).trimEnd()
355    const strong = RUNNER_PREFIXES.some(p => before.endsWith(p))
356    let i = ce
357    let subs = 0
358    let nargs = 0
359    let bare = 0
360    let afterEq = false
361    let evidence = false
362    let after: 'chain' | 'redirect' | null = null
363    let tok: Tok | null
364    scan: while ((tok = nextArg(t, i))) {
365      const { kind, start, end } = tok
366      if (kind === 'ws') {
367        i = end
368        continue
369      }
370      const word = t.slice(start, end)
371      // a lone full stop or comma is the sentence, not an argument
372      if (word.length === 1 && isSentencePunct(word)) break
373      if (afterEq && (kind === 'sub' || kind === 'word')) {
374        afterEq = false
375        out.push({ start, end, kind: 'path' })
376        nargs++
377        i = end
378        continue
379      }
380      afterEq = kind === 'flag' && word.endsWith('=')
381      if (after === 'chain' && kind !== 'chain' && kind !== 'redirect') {
382        if ((kind === 'sub' || kind === 'word') && COMMANDS.has(word)) {
383          out.push({ start, end, kind: 'cmd' })
384          cmd = word
385          subs = 0
386          after = null
387          evidence = true
388          i = end
389          continue
390        }
391        if ((kind === 'path' && /^(\.\/|\/|~)/.test(word)) || kind === 'var') {
392          out.push({ start, end, kind: kind === 'var' ? 'var' : 'path' })
393          cmd = ''
394          subs = 0
395          after = null
396          evidence = true
397          nargs++
398          i = end
399          continue
400        }
401        break
402      }
403      if (after === 'redirect' && kind !== 'chain' && kind !== 'redirect') {
404        if (kind === 'comment' || kind === 'flag') break
405        out.push({ start, end, kind: kind === 'str' || kind === 'var' || kind === 'num' ? kind : 'path' })
406        after = null
407        nargs++
408        evidence = true
409        i = end
410        continue
411      }
412      let color: SpanKind
413      switch (kind) {
414        case 'sub':
415        case 'word': {
416          if (CHAIN_TOOLS.has(cmd) && COMMANDS.has(word)) {
417            out.push({ start, end, kind: 'cmd' })
418            cmd = word
419            subs = 0
420            nargs++
421            evidence = true
422            i = end
423            continue
424          }
425          if (STOP_WORDS.has(word)) break scan
426          if (kind === 'sub' && SUBCMD_TOOLS.has(cmd) && subs < MAX_SUB) {
427            subs++
428            color = 'sub'
429          } else if (strong) {
430            color = 'path'
431          } else {
432            // prose: take a few bare words on trust while waiting for
433            // evidence; once there is some, a bare word is more likely the
434            // sentence resuming than an argument
435            bare++
436            if (evidence || bare > MAX_BARE) break scan
437            color = 'path'
438          }
439          break
440        }
441        case 'chain':
442        case 'redirect':
443          after = kind
444          color = 'op'
445          break
446        default:
447          color = kind
448      }
449      // operators only count once something real follows them; a bare
450      // number after an English-word command is not evidence
451      if (kind !== 'sub' && kind !== 'word' && kind !== 'chain' && kind !== 'redirect') {
452        if (!(kind === 'num' && ENGLISH.has(cmd))) evidence = true
453      }
454      // a bare word followed by sentence punctuation ends the span
455      if (TRIMMABLE.has(kind) && word.length > 1 && isSentencePunct(word[word.length - 1]!)) {
456        out.push({ start, end: end - 1, kind: color })
457        nargs++
458        i = end - 1
459        break
460      }
461      out.push({ start, end, kind: color })
462      nargs++
463      i = end
464    }
465    // a trailing chain operator paints nothing on its own
466    if (after) {
467      const last = out[out.length - 1]
468      if (last && last.kind === 'op') {
469        i = last.start
470        out.pop()
471        nargs--
472      }
473    }
474    if (nargs === 0 || (!strong && !evidence)) {
475      // bare command word in prose, or `make sure`: leave it alone
476      out.length = mark
477      pos = ce
478      continue
479    }
480    pos = i
481  }
482  return out
483}
484
hooks/width.ts 161 lines
1// Terminal cell widths, mirroring the engine's ruler: Claude Code lays out
2// with Bun.stringWidth(s, { ambiguousIsNarrow: true }). Every width in the
3// mod (table columns, heading rules, ordered gutters) goes through this so
4// the tree and the layout engine agree per glyph. Standalone: the hook
5// environment has no Bun. ASCII-only source; glyphs come from code points.
6//
7// The rules below were read off Bun 1.3 (2026-10-07, /tmp probe against
8// Bun.stringWidth): a grapheme is summed per code point, marks (Mn, Me, Mc)
9// and the invisible formats (ZWJ, ZWNJ, bidi marks, BOM, tags) count 0, a
10// VS16 lifts a one-cell Emoji-property base (digits, #, *, (c), the
11// text-default symbols) to 2, a keycap is 1, a flag pair is 1, a ZWJ
12// sequence counts its head, and a jamo vowel or tail after a head counts 0.
13const cp = (n: number) => String.fromCodePoint(n)
14const VS16 = 0xfe0f
15const ZWJ = 0x200d
16const KEYCAP = 0x20e3
17const SGR = new RegExp(cp(0x1b) + '\\[[0-9;]*m', 'g')
18const MARK = /^[\p{Mn}\p{Me}\p{Mc}]$/u
19const EMOJI = /^\p{Emoji}$/u
20const EMOJI_PRESENTATION = /^\p{Emoji_Presentation}$/u
21
22const segmenter =
23  typeof Intl !== 'undefined' && 'Segmenter' in Intl
24    ? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
25    : null
26
27// East Asian Wide and Fullwidth ranges the Emoji_Presentation property does
28// not already cover (assigned code points; Bun follows EastAsianWidth.txt)
29const WIDE: ReadonlyArray<readonly [number, number]> = [
30  [0x1100, 0x115f],
31  [0x2329, 0x232a],
32  [0x2e80, 0x303e],
33  [0x3041, 0x3247],
34  [0x3250, 0x33ff],
35  [0x3400, 0x4dbf],
36  [0x4e00, 0x9fff],
37  [0xa000, 0xa4c6],
38  [0xa960, 0xa97c],
39  [0xac00, 0xd7a3],
40  [0xf900, 0xfaff],
41  [0xfe10, 0xfe19],
42  [0xfe30, 0xfe6b],
43  [0xff01, 0xff60],
44  [0xffe0, 0xffe6],
45  [0x16fe0, 0x16fe4],
46  [0x17000, 0x187f7],
47  [0x18800, 0x18cd5],
48  [0x18d00, 0x18d08],
49  [0x1aff0, 0x1affe],
50  [0x1b000, 0x1b122],
51  [0x1b132, 0x1b132],
52  [0x1b150, 0x1b152],
53  [0x1b155, 0x1b155],
54  [0x1b164, 0x1b167],
55  [0x1b170, 0x1b2fb],
56  [0x1f200, 0x1f202],
57  [0x1f210, 0x1f23b],
58  [0x1f240, 0x1f248],
59  [0x1f250, 0x1f251],
60  [0x1f260, 0x1f265],
61  [0x20000, 0x2fffd],
62  [0x30000, 0x3fffd],
63]
64
65function isWide(c: number): boolean {
66  return WIDE.some(([lo, hi]) => c >= lo && c <= hi)
67}
68
69// the format characters Bun counts as nothing; the other Cf (a soft hyphen,
70// a word joiner, the bidi controls) take a cell there. The Thai and Lao
71// SARA AM and the halfwidth voiced marks sit in Bun's zero table too.
72function isInvisible(c: number): boolean {
73  return (c >= 0x200b && c <= 0x200f) || c === 0xfeff || (c >= 0xe0000 && c <= 0xe007f) || c === 0xe33 || c === 0xeb3 || c === 0xff9e || c === 0xff9f
74}
75
76const isRegional = (c: number) => c >= 0x1f1e6 && c <= 0x1f1ff
77const isModifier = (c: number) => c >= 0x1f3fb && c <= 0x1f3ff
78// a jamo vowel or tail: one cell alone, none after a head or a syllable
79const isJamoTail = (c: number) => (c >= 0x1160 && c <= 0x11ff) || (c >= 0xd7b0 && c <= 0xd7ff)
80
81function codePointWidth(c: number): number {
82  if (c < 0x20 || (c >= 0x7f && c < 0xa0)) return 0
83  if (isInvisible(c) || isModifier(c)) return 0
84  const s = cp(c)
85  if (MARK.test(s)) return 0
86  if (EMOJI_PRESENTATION.test(s) || isWide(c)) return 2
87  return 1
88}
89
90function graphemeWidth(g: string): number {
91  const points = [...g].map(ch => ch.codePointAt(0)!)
92  const head = points[0]
93  if (head === undefined) return 0
94  if (points.includes(KEYCAP)) return 1
95  if (isRegional(head)) return 1
96  let n = 0
97  for (let i = 0; i < points.length; i++) {
98    const c = points[i]!
99    if (c === ZWJ) break
100    let w = codePointWidth(c)
101    // a selector lifts a one-cell Emoji-property base to the emoji form
102    if (w === 1 && points[i + 1] === VS16 && EMOJI.test(cp(c))) w = 2
103    if (i > 0 && isJamoTail(c)) w = 0
104    n += w
105  }
106  return n
107}
108
109/** cells `s` takes on the terminal, SGR escapes ignored */
110export function cellWidth(s: string): number {
111  const clean = s.replace(SGR, '')
112  if (segmenter) {
113    let n = 0
114    for (const { segment } of segmenter.segment(clean)) n += graphemeWidth(segment)
115    return n
116  }
117  let n = 0
118  for (const ch of clean) n += graphemeWidth(ch)
119  return n
120}
121
122// HORIZONTAL ELLIPSIS, U+2026, as glyphs.ts has it (width.ts stays standalone)
123const ELLIPSIS = cp(0x2026)
124
125/** the longest head of `s` within `n` cells */
126function headCells(s: string, n: number): string {
127  let out = ''
128  for (const ch of s) {
129    if (cellWidth(out + ch) > n) break
130    out += ch
131  }
132  return out
133}
134
135/** the longest tail of `s` within `n` cells */
136function tailCells(s: string, n: number): string {
137  const chars = [...s]
138  let out = ''
139  for (let i = chars.length - 1; i >= 0; i--) {
140    if (cellWidth(chars[i] + out) > n) break
141    out = chars[i] + out
142  }
143  return out
144}
145
146/** cut to `n` cells in the middle: the head and the tail stay, an ellipsis between */
147export function clipMiddle(s: string, n: number): string {
148  if (cellWidth(s) <= n) return s
149  if (n <= 0) return ''
150  const head = Math.ceil((n - 1) / 2)
151  return headCells(s, head) + ELLIPSIS + tailCells(s, n - 1 - head)
152}
153
154/** a path cut to `n` cells, its file name kept whole when it fits: `hooks/...render.ts` */
155export function clipPath(s: string, n: number): string {
156  if (cellWidth(s) <= n) return s
157  const base = s.slice(s.lastIndexOf('/') + 1)
158  if (base === s || cellWidth(base) + 2 > n) return clipMiddle(s, n)
159  return headCells(s, n - cellWidth(base) - 2) + ELLIPSIS + '/' + base
160}
161
hooks/paths.ts 74 lines
1// Paths and URLs as claude-hl recognized them, used in prose and in tool
2// output. Absolute, relative and home paths always count; otherwise a known
3// extension or a dotfile is needed, so "and/or" and "e.g." stay plain.
4
5export const EXTENSIONS = new Set(
6  `rs ts tsx js jsx mjs cjs mts cts json jsonc toml yaml yml md mdx txt py pyi go rb java kt kts swift c cc cpp h
7hpp cs php html htm css scss sass less sh bash zsh fish lock sql env xml svg png jpg jpeg gif webp ico csv tsv log ini cfg conf lua vim el
8ex exs erl hs ml scala dart proto graphql gql wasm zip tar gz tgz pdf ipynb sum mod
9vue svelte astro tf hcl nix zig jl r m mm tex bib rst adoc ps1 bat cmd gradle properties
10pem crt key plist ttf otf woff woff2 mp3 mp4 wav sqlite db bak mk cmake prisma sol toml`.split(/\s+/),
11)
12
13// files named by convention, no extension: a path when the case matches
14export const SPECIAL_NAMES = new Set(['Makefile', 'Dockerfile', 'Justfile', 'Gemfile', 'Rakefile', 'Procfile', 'Containerfile', 'Vagrantfile'])
15
16const isWord = (c: string) => /[A-Za-z0-9_]/.test(c)
17
18export function isUrl(tok: string): boolean {
19  return ['http://', 'https://', 'ssh://', 'git@', 'file://', 'www.'].some(p => tok.startsWith(p)) && tok.length > 8
20}
21
22export type PathMatch = {
23  /** the path itself */
24  path: string
25  /** a trailing `:line` or `:line:col`, empty when absent */
26  lineno: string
27}
28
29/** does `tok` look like a file path? */
30export function pathLike(tok: string): PathMatch | null {
31  let end = tok.length
32  // peel up to two `:digits` suffixes
33  for (let k = 0; k < 2; k++) {
34    const colon = tok.lastIndexOf(':', end - 1)
35    if (colon <= 0) break
36    const tail = tok.slice(colon + 1, end)
37    if (tail.length > 0 && /^\d+$/.test(tail)) end = colon
38    else break
39  }
40  const p = tok.slice(0, end)
41  if (p.length < 2 || p.includes('//')) return null
42  // brackets inside a path segment are a router's (`app/[id]/page.tsx`,
43  // `app/(auth)/layout.tsx`): allowed only balanced, so `foo(bar.js` stays prose
44  if (![...p].every(c => isWord(c) || '/.-~@+[]()'.includes(c))) return null
45  if (/[[\]()]/.test(p) && !(p.includes('/') && balanced(p, '[', ']') && balanced(p, '(', ')'))) return null
46  const rooted = p.startsWith('/') || p.startsWith('./') || p.startsWith('../') || p.startsWith('~/')
47  const last = p.split('/').pop() ?? p
48  const dot = last.lastIndexOf('.')
49  const extOk = dot > 0 && EXTENSIONS.has(last.slice(dot + 1).toLowerCase())
50  const special = SPECIAL_NAMES.has(last)
51  const dotfile = !p.includes('/') && p.startsWith('.') && /^[\w.-]+$/.test(p.slice(1)) && /[A-Za-z]/.test(p.slice(1))
52  if (!(rooted || extOk || special || dotfile)) return null
53  return { path: p, lineno: tok.slice(end) }
54}
55
56// as many openers as closers, every closer after its opener
57function balanced(p: string, open: string, close: string): boolean {
58  let depth = 0
59  for (const c of p) {
60    if (c === open) depth++
61    else if (c === close && --depth < 0) return false
62  }
63  return depth === 0
64}
65
66/** strip the brackets and quotes a word in prose may carry: `(src/x.rs),` */
67export function bareWord(tok: string): { lead: string; word: string; tail: string } {
68  let a = 0
69  let b = tok.length
70  while (a < b && '([{"\'`<'.includes(tok[a]!)) a++
71  while (a < b && ')]}"\'`>,.;:!?'.includes(tok[b - 1]!)) b--
72  return { lead: tok.slice(0, a), word: tok.slice(a, b), tail: tok.slice(b) }
73}
74