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…

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.
git push --follow-tags paints, make sure the build passes does not).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.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.▎ 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 or Privately draws italic and quiet.╭─ 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.• ◦ ☐ ☑ 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.◆ 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.├─ ✓ 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.Every width is measured the way the engine measures cells, so emoji and CJK in tables stay aligned.
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
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.
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.
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.
MIT
hooks/register.ts 579 lines1import { 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}
579hooks/chrome.ts 1076 lines1// 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}
1076hooks/glyphs.ts 54 lines1// 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}
54hooks/markdown.ts 344 lines1// 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 ``, 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}
344hooks/palette.ts 330 lines1// 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}
330hooks/prose.ts 83 lines1// 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}
83hooks/render.ts 683 lines1// 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}
683hooks/highlight.ts 181 lines1// 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}
181hooks/output.ts 83 lines1// 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}
83hooks/shell.ts 484 lines1// 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}
484hooks/width.ts 161 lines1// 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}
161hooks/paths.ts 74 lines1// 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