SLOPSHOPPER

lucid

Restrained, readable transcript for dark and light terminals: warm greys, one accent, AA contrast, quiet tool rows. A mouse selection pastes rendered prose and…

newrowsguardtimer
v0.7.0no licenseupdated 2026-10-23vvojciech/claude-kit/plugins/lucid
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · lucid
fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ◌ Wants to edit file:///work/app/src/auth.ts ⏺ Bash(bun test) ⎿ 3 pass, 1 fail Done. I made refresh reject expired claims and added an audit call. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Your message
fix the failing auth test and add an audit log call
Claude's reply
Done. I made refresh reject expired claims and added an audit call.
Tool row
◌ Wants to edit file:///work/app/src/auth.ts
README

lucid

A busy turn in Claude Code is mostly tool rows. A folded group says only Read 2 files, not which files, and every other call takes two or three rows of Tool(args) and output, so the answer you asked for scrolls away.

lucid names the targets under each folded group and gives every call one row that says what happened, such as Couldn't run npm test login. It also redraws replies and your prompts in warm greys with one orange accent. A mouse selection pastes as plain text, and code pastes exactly as written.

lucid drawing a prompt, a folded tool group, a failed test run, a background dev server, then a reply with a heading, a task list, a code block and a table

The same turn without lucid, for comparison:

The same turn in Claude Code's default look: a group row with counts only, Bash(...) and Update(...) rows, a bulleted reply and a boxed table

Install

/plugin marketplace add vvojciech/claude-kit
/plugin install lucid@vvojciech

To get the default look back, uninstall it from the /plugin menu.

Requirements

Claude Code 2.1.287 or later.

Works with peek (images drawn where they sit in a reply), copy-that (copy the markdown behind a reply) and mermaid (sequence diagrams). lucid draws replies, prompts and tool rows itself and doesn't pass them on, so those mods have to load before it. See load order for how to set it.

Features

Tool rows

  • One row per call: a state glyph, a verb and the target, such as Ran, Read, Edited, Wrote, Searched, Fetched, Watched or Loaded skill.
  • Verbs are past tense once a call is done. A call waiting at the permission dialog reads Wants to run, Wants to write and so on. A failed call reads Couldn't run, Couldn't read and so on.
  • States: pending ◌, running ◐, started ○, done ●, failed ✗, and ⊘ for stopped, not allowed or cancelled.
  • Stopped means you interrupted a call that had started. Not allowed means the permission check refused it. Cancelled means it never started, or you pressed Esc at its permission dialog.
  • Paths show relative to the session directory, home as ~, link to the file, and cut in the middle so the file name stays.
  • A settled group gets a headline in kinds and counts, like Read 2 files, ran 1 command. Under it is one line per kind, with the targets joined by · . Bash gets one line per command. Failed or refused calls are named in their own state.
  • Background shells, monitors and agents read as running, with a muted background note, until their task ends. They read as started when this session never saw the end.
  • Task notifications draw as rows with the task's own status and how long it ran.
  • MCP tools read Called server › tool, with the server name humanised.
  • Agent calls read Delegated, or Delegated to <type> for a named agent type.
  • A new file shows its line count and the first 10 lines. An error shows up to 10 lines, the first in the error colour.

Theme and palette

  • Follows /theme and redraws when you change it.
  • A theme name containing "light" or "dark" picks the background, ANSI themes included. Any other theme reads the terminal background from COLORFGBG, and falls back to dark.
  • Every colour is a fixed hex value, so ANSI themes get the same palette as the rest.
  • Warm grey text in four strengths, one orange accent, and separate colours for running, done, failed, added and removed.
  • Text colours meet 4.5:1 on the background and on the prompt panel. Rules, bars and the accent meet 3:1.
  • Daltonized themes draw added lines in blue and done marks in grey-white, so neither relies on green.

Markdown

  • h1 is bold and underlined with a faint rule under it. h2 is bold and underlined, h3 bold and muted. h1 and h2 get a wider gap above them.
  • Lists keep their markdown markers. Ordered lists keep their numbers and delimiter. Tasks show - [ ] and - [x], and done items are greyed.
  • Tables keep their grid when they fit. Numeric columns align right, and text columns shrink before numbers do. When the columns can't fit, each row stacks as header: value lines.
  • Code blocks show the language as a muted label with no fences. Keywords are bold, comments muted italic. Diffs colour added and removed lines, and shell blocks colour commands, flags and strings.
  • A code line wider than the terminal soft-wraps, with a faint ↪ beside each continuation row.
  • Inline code sits on a chip. Inline shell commands are coloured, and file paths are picked out with their :line suffix.
  • Quotes get one bar per nesting level and quieter text. GitHub alerts ([!NOTE], [!TIP], [!IMPORTANT], [!WARNING], [!CAUTION]) get a label.
  • Footnote references read [1], and footnote bodies hang past their label.
  • http, https, mailto and file links become terminal hyperlinks. Other schemes keep just the label, and relative links show the target as a path.
  • Leading YAML front matter shows as a yaml block. Thematic breaks draw as a short faint rule.
  • HTML: b, i, u, ins, del, code, kbd, a, q, sup, sub and br render. <pre> becomes a code block, <details> and <summary> become a ▸ line, images show as [image: alt], and comments are hidden. Other block tags flow as plain text.
  • Named and numeric HTML entities are decoded.
  • Past the engine's size and nesting limits, the rest of a reply draws as plain text.
  • Widths are measured in terminal cells, so emoji and wide characters count as two.

Images

  • With peek loaded, a local alt image in a reply draws as a thumbnail where it sits, with the file name, pixel size and a ⤢ open button beside it.
  • An image in the middle of a sentence breaks onto its own row between the words around it.
  • Everything else stays [image: alt]: web URLs, images inside lists, quotes, tables or headings, and any image when peek isn't loaded, can't find the file, or the terminal can't draw pictures. peek still shows those under the reply.

Prompt panel

  • Your prompt sits on a full-width panel with an accent bar on the left.
  • Pasted blocks collapse to [pasted N lines]. A prompt that is only a paste also shows its first line.

Mouse selection

  • Prose sits flush left with no reply bullet, so it pastes as it would without lucid.
  • Headings carry no # marker and are followed by a blank line, so they paste as their own paragraph.
  • List and task markers are the markdown they came from.
  • Code sits in the same column as prose with no bar or gutter in the way, and long lines are left whole for the terminal to wrap. A selection pastes the exact source.
  • The inline code chip is a background colour, so it never ends up in the paste.

For the markdown source, use copy-that.

While a reply streams

  • Inline markers such as ** or a backtick show as written until they close, then the span restyles. Claude Code's own renderer does the same.
  • The last line or two can wait a moment while they could still turn into a table, a fence or a list.

Commands

None. lucid starts drawing as soon as it loads, and follows /theme from then on.

Known limits

  • Running rows, and rows you expand with ctrl+o, keep Claude Code's default look.
  • The N background agents launched row is Claude Code's own. A mod can't reach it.
  • A bare URL long enough to wrap is clickable on its first row only.
  • If a light terminal draws dark, pick a theme with "light" in its name in /theme, or start Claude Code with COLORFGBG='0;15' set (a background of 7 or 15 reads as light).
  • If lucid doesn't draw at all, a mod listed before it draws replies without passing them on. Fix the load order.
  • Each streamed token re-parses the block still being written. That takes a few milliseconds at normal sizes, and 50 to 125ms per token for a single block near 100KB.

For mod authors

lucid adds $.lucid to the engine. Types live in types/index.d.ts.

  • blocks({ text, requestId }) returns the block outline of a reply as lucid parses it: kind, source, depth, start and end offsets in the original text, and the language and body of code blocks. Pass the requestId of a reply that is still streaming to get the streaming parse.
  • tone() returns the background (dark or light), whether the theme is daltonized, and a frozen copy of the palette as #rrggbb values. It is undefined until lucid has drawn once.
  • Reply images go through peek's $.peek.picture, which does the reading, converting and sizing.
  • lucid leaves these rows to the engine: the grouped Agent launch row, live and expanded rows (isRunning, isActive, isExpanded), and groups that hold anything other than tool calls (isCallsOnly false).
  • The Stopped, Not allowed and Cancelled marks live in session state, so a reload keeps them.
Source 26 files
hooks/register.tsx 230 lines
1import type { EngineInterface, Register, RenderElement } from 'claude-code'
2
3import { renderMarkdown } from './blocks'
4import { renderPrompt } from './prompt'
5import type { Draw } from './runs'
6import { renderFailure, renderToolRow, renderWrittenFile } from './tool-rows'
7import { ReplyStream } from './live'
8import { CallLedger } from './permits'
9import { blocksOf, parseBlocks } from './message'
10import { imageSources, picturesOf, thumbnail } from './pictures'
11import type { Pictures } from './pictures'
12import { drawnTone, forgetTheme, themePalette } from './theme'
13import { TASK_STILL_RUNNING, TASK_UNTRACKED, backgroundTaskId, callEnd, createdContent, describeCall, failureOf, isInterimNotice, notificationRow, stateOf, stoppedTaskId, summariseGroup, unsuccessfulState } from './tools'
14import type { CallEnd, Roots, ToolCall } from './tools'
15
16const DEFAULT_COLUMNS = 100
17
18const STOPPED = 'killed'
19
20const replies = new ReplyStream()
21
22const ledger = new CallLedger()
23
24function markSummary({ kit, palette }: Draw, message: RenderElement): RenderElement {
25  const { Box, Text } = kit
26  return (
27    <Box flexDirection="column">
28      {message}
29      <Text color={palette.muted}>· summary</Text>
30    </Box>
31  )
32}
33
34async function rootsOf($: Pick<EngineInterface, 'session' | 'env'>): Promise<Roots> {
35  const home = await $.env.get('HOME').catch(() => undefined)
36  return { cwd: await $.session.cwd(), home }
37}
38
39async function agentStatusOf($: Pick<EngineInterface, 'agent'>, id: string): Promise<string | undefined> {
40  const agents = await $.agent.list().catch(() => [])
41  return agents.find(agent => agent.id === id)?.status
42}
43
44type Tracker = Pick<EngineInterface, 'state' | 'clock'>
45
46async function endOf($: Tracker, id: string): Promise<string | undefined> {
47  return (await $.state.get({ plugin: 'lucid', key: 'endedTasks', id })).value
48}
49
50async function wasLaunched($: Tracker, id: string): Promise<boolean> {
51  return (await $.state.get({ plugin: 'lucid', key: 'launchedTasks', id })).value === true
52}
53
54async function taskStatusOf($: Tracker & Pick<EngineInterface, 'agent'>, call: Pick<ToolCall, 'tool' | 'output'>): Promise<string | undefined> {
55  const id = backgroundTaskId(call.tool, call.output)
56  if (id === undefined) return undefined
57  if (call.tool === 'Agent') return (await agentStatusOf($, id)) ?? (await endOf($, id)) ?? TASK_UNTRACKED
58  return (await endOf($, id)) ?? ((await wasLaunched($, id)) ? TASK_STILL_RUNNING : TASK_UNTRACKED)
59}
60
61async function hasRun($: Tracker, id: string | undefined): Promise<boolean> {
62  return id !== undefined && (await $.state.get({ plugin: 'lucid', key: 'ranCalls', id })).value === true
63}
64
65async function wasRefused($: Tracker, id: string | undefined): Promise<boolean> {
66  return id !== undefined && (await $.state.get({ plugin: 'lucid', key: 'refusedCalls', id })).value === true
67}
68
69async function fateOf($: Tracker, call: { tool_use_id?: string }): Promise<{ hasRun: boolean; refused: boolean }> {
70  return { hasRun: await hasRun($, call.tool_use_id), refused: await wasRefused($, call.tool_use_id) }
71}
72
73async function observing(bookkeeping: () => Promise<void>): Promise<void> {
74  await bookkeeping().catch(() => undefined)
75}
76
77function quietly(write: Promise<unknown>): void {
78  write.catch(() => undefined)
79}
80
81async function markAllRun($: Tracker, ids: ReadonlyArray<string>): Promise<void> {
82  for (const id of ids) {
83    if (!(await hasRun($, id))) $.clock.after(0, () => quietly($.state.set({ plugin: 'lucid', key: 'ranCalls', id }, true)))
84  }
85}
86
87async function markRefused($: Tracker, id: string | undefined): Promise<void> {
88  if (id !== undefined && !(await wasRefused($, id))) $.clock.after(0, () => quietly($.state.set({ plugin: 'lucid', key: 'refusedCalls', id }, true)))
89}
90
91async function recordEnding($: Tracker, id: string | undefined, ending: CallEnd | undefined): Promise<void> {
92  if (ending === 'ran' && id !== undefined) await markAllRun($, [id])
93  if (ending === 'declined') await markRefused($, id)
94}
95
96async function recordLaunch($: Tracker, tool: string, result: unknown): Promise<void> {
97  const id = backgroundTaskId(tool, result)
98  if (id !== undefined) await $.state.set({ plugin: 'lucid', key: 'launchedTasks', id }, true)
99}
100
101async function recordEnd($: Tracker, id: string | undefined, status: string | undefined): Promise<void> {
102  if (id === undefined || status === undefined || (await endOf($, id)) === status) return
103  $.clock.after(0, () => quietly($.state.set({ plugin: 'lucid', key: 'endedTasks', id }, status)))
104}
105
106async function opened($: EngineInterface, path: string): Promise<void> {
107  await $.peek.open({ path }).catch(() => undefined)
108}
109
110export const register: Register = on => {
111  on('engine.create', async (_$, e, next) => {
112    forgetTheme()
113    return {
114      ...(await next(e)),
115      lucid: { blocks: ({ text, requestId }) => blocksOf(text, { streaming: requestId !== undefined && replies.isStreaming(requestId) }), tone: drawnTone },
116    }
117  })
118
119  on('turn.start', async ($, e, next) => {
120    if (e.agentId === undefined) replies.turnStarted()
121    return next(e)
122  })
123
124  on('session.append', { door: 'response' }, async ($, e, next) => {
125    const stored = await next(e)
126    if (e.agentId === undefined) {
127      replies.stored(e.uuid)
128      $.ui.invalidate('ui.render')
129    }
130    return stored
131  }).catch(($, e, next) => next(e))
132
133  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
134    if (e.surface !== 'terminal') return next(e)
135    const kit = $.ui.resolve(e)
136    const draw = { kit, palette: await themePalette($) }
137    const columns = e.viewport?.columns ?? DEFAULT_COLUMNS
138    const streaming = replies.isStreaming(e.requestId)
139    const sources = imageSources(parseBlocks(e.props.text, { streaming }))
140    const found = sources.length === 0 ? new Map() : await picturesOf($, sources, { requestId: e.requestId, columns, isFullscreen: e.viewport?.isFullscreen })
141    const pictures: Pictures = { found, node: (picture, key) => thumbnail(kit, draw.palette, picture, key, () => void opened($, picture.path)) }
142    const message = renderMarkdown(draw, e.props.text, columns, streaming, pictures)
143    return e.props.isSummary ? markSummary(draw, message) : message
144  }).catch(($, e, next) => next(e))
145
146  on('tool.call', async ($, e, next) => {
147    ledger.started(e.tool_use_id, e.agentId)
148    try {
149      const answer = await next(e)
150      if ('result' in answer) {
151        await observing(() => recordLaunch($, e.tool, answer.result))
152        await observing(() => recordEnding($, e.tool_use_id, callEnd(answer)))
153      }
154      return answer
155    } finally {
156      ledger.settled(e.tool_use_id)
157    }
158  })
159
160  on('tool.check', async ($, e, next) => {
161    const verdict = await next(e)
162    await observing(() => markAllRun($, ledger.decided(e.tool_use_id, verdict.decision)))
163    if (verdict.decision === 'deny') await observing(() => markRefused($, e.tool_use_id))
164    return verdict
165  })
166
167  on('classic.PermissionDenied', async ($, e, next) => {
168    await observing(() => markRefused($, e.tool_use_id))
169    return next(e)
170  })
171
172  on('ui.render', { component: 'ToolProgress' }, async ($, e, next) => {
173    await markAllRun($, ledger.sharingProgress(e.props.tool_use_id))
174    return next(e)
175  }).catch(($, e, next) => next(e))
176
177  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
178    const fate = await fateOf($, e.props)
179    if (e.surface !== 'terminal' || e.props.isExpanded || e.props.isRunning) return next(e)
180    const call = { ...e.props, ...fate }
181    if (stateOf(call) === 'done') await recordEnd($, stoppedTaskId(call), STOPPED)
182    const taskStatus = await taskStatusOf($, call)
183    const row = describeCall(call, await rootsOf($), taskStatus)
184    return renderToolRow({ kit: $.ui.resolve(e), palette: await themePalette($) }, row, stateOf(call, taskStatus), e.viewport?.columns ?? DEFAULT_COLUMNS)
185  }).catch(($, e, next) => next(e))
186
187  on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
188    if (e.surface !== 'terminal' || e.props.isExpanded) return next(e)
189    const failure = e.props.isErrored ? failureOf(e.props.output) : undefined
190    const content = e.props.isErrored ? undefined : createdContent(e.props.tool, e.props.output)
191    if (failure !== undefined) return renderFailure({ kit: $.ui.resolve(e), palette: await themePalette($) }, failure)
192    if (content !== undefined) return renderWrittenFile({ kit: $.ui.resolve(e), palette: await themePalette($) }, content)
193    return next(e)
194  }).catch(($, e, next) => next(e))
195
196  on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
197    const { calls, isActive, isCallsOnly, isExpanded } = e.props
198    const fates = await Promise.all(calls.map(call => fateOf($, call)))
199    if (e.surface !== 'terminal' || isExpanded || isActive || calls.length === 0) return next(e)
200    const tracked = await Promise.all(calls.map(async (call, index) => ({ ...call, ...fates[index], taskStatus: await taskStatusOf($, call) })))
201    if (isCallsOnly !== true && unsuccessfulState(tracked) === undefined) return next(e)
202    const { row, state } = summariseGroup(tracked, await rootsOf($))
203    return renderToolRow({ kit: $.ui.resolve(e), palette: await themePalette($) }, row, state, e.viewport?.columns ?? DEFAULT_COLUMNS)
204  }).catch(($, e, next) => next(e))
205
206  on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
207    if (!isInterimNotice(e.props.text)) await recordEnd($, e.props.task?.id, e.props.task?.status)
208    if (e.surface !== 'terminal') return next(e)
209    const notice = e.props.task !== undefined && !e.props.isExpanded ? notificationRow(e.props.text, e.props.task) : undefined
210    if (notice !== undefined) return renderToolRow({ kit: $.ui.resolve(e), palette: await themePalette($) }, notice.row, notice.state, e.viewport?.columns ?? DEFAULT_COLUMNS)
211    if (e.props.origin.kind !== 'composer') return next(e)
212    return renderPrompt({ kit: $.ui.resolve(e), palette: await themePalette($) }, e.props.text, e.viewport?.columns ?? DEFAULT_COLUMNS)
213  }).catch(($, e, next) => next(e))
214
215  on('turn.complete', async ($, e, next) => {
216    const result = await next(e)
217    if (e.agentId === undefined) replies.turnEnded()
218    $.ui.invalidate('ui.render')
219    return result
220  })
221
222  on('config.set', { key: 'theme' }, async ($, e, next) => {
223    const result = await next(e)
224    if (result.deny !== undefined) return result
225    forgetTheme()
226    $.ui.invalidate('ui.render')
227    return result
228  })
229}
230
hooks/blocks.tsx 541 lines
1import type { RenderElement, RenderNode } from 'claude-code'
2
3import { code, codeLines } from './code'
4import { heading } from './headings'
5import type { AlertKind, Block, ListItem, Span } from './markdown'
6import { parseBlocks } from './message'
7import type { Palette } from './palette'
8import { laidOut } from './pictures'
9import type { Pictures, Placed } from './pictures'
10import { drawRuns, gapRows, paragraph, spansText, wrappedRows } from './runs'
11import type { Draw, Run } from './runs'
12import { table, tableRows } from './tables'
13import { cellWidth } from './width'
14
15type Context = { done: boolean; nesting: number; gutter: string; owed: number[] }
16
17type Size = { nodes: number; depth: number }
18
19type DrawnBlock = { node: RenderNode; size: Size }
20
21const TOP: Context = { done: false, nesting: 0, gutter: '', owed: [] }
22
23const NESTING_LIMIT = 8
24
25const NODE_BUDGET = 15000
26
27const DEPTH_BUDGET = 26
28
29const ROW_NODES = 1
30
31const QUOTE_INDENT = 2
32
33const QUOTE_BAR = '▎'
34
35const QUOTE_GUTTER = `${QUOTE_BAR} `
36
37const THEMATIC_BREAK = '╌'
38
39const RULE_CELLS = 40
40
41const TITLE_GAP = 2
42
43const SECTION_LEVELS = 2
44
45const WIDE_BLOCKS = new Set<Block['kind']>(['code', 'table'])
46
47const ALERT_LABEL: Record<AlertKind, string> = { note: 'Note', tip: 'Tip', important: 'Important', warning: 'Warning', caution: 'Caution' }
48
49function markerRuns(item: ListItem, palette: Palette): Run[] {
50  if (item.marker === 'done') return [{ text: '- ', look: { color: palette.muted } }, { text: '[x]', look: { color: palette.done } }]
51  if (item.marker === 'todo') return [{ text: '- [ ]', look: { color: palette.muted } }]
52  if (item.marker === 'number') return [{ text: item.number ?? '-', look: { color: palette.muted } }]
53  return [{ text: '-', look: { color: palette.muted } }]
54}
55
56function markerWidth(item: ListItem, numberWidth: number): number {
57  if (item.marker === 'number') return numberWidth
58  return item.marker === 'bullet' ? 1 : 5
59}
60
61type Segment = { blocks: Block[]; isCode: boolean }
62
63function isCode(b: Block): boolean {
64  return b.kind === 'code'
65}
66
67function holds(b: Block, leaf: (b: Block) => boolean): boolean {
68  if (leaf(b)) return true
69  if (b.kind === 'list') return b.items.some(item => item.blocks.some(child => holds(child, leaf)))
70  return b.kind === 'quote' && b.blocks.some(child => holds(child, leaf))
71}
72
73function holdsCode(b: Block): boolean {
74  return holds(b, isCode)
75}
76
77function segments(blocks: Block[], pulled: (b: Block) => boolean = isCode): Segment[] {
78  const found: Segment[] = []
79  for (const child of blocks) {
80    const isCode = pulled(child)
81    const last = found.at(-1)
82    if (last && !isCode && !last.isCode) last.blocks.push(child)
83    else found.push({ blocks: [child], isCode })
84  }
85  return found.length > 0 && !found[0]?.isCode ? found : [{ blocks: [], isCode: false }, ...found]
86}
87
88export function holdsWide(b: Block): boolean {
89  return holds(b, child => WIDE_BLOCKS.has(child.kind))
90}
91
92function itemBody(draw: Draw, blocks: Block[], marker: RenderNode | null, lead: number, width: number, gap: number, loose: boolean, context: Context): RenderNode {
93  const { Box } = draw.kit
94  const room = Math.max(1, width - lead)
95  return (
96    <Box flexDirection="column" paddingLeft={lead} width={room + lead} marginTop={gap}>
97      {marker}
98      {blocks.length === 0 ? <draw.kit.Text> </draw.kit.Text> : null}
99      {blocks.flatMap((child, i) => {
100        const drawn = barredBlock(draw, child, room, context)
101        return spaced(draw, loose && i > 0 ? 1 : 0, context, top => (top > 0 ? <Box marginTop={top}>{drawn}</Box> : drawn))
102      })}
103    </Box>
104  )
105}
106
107function itemMarker(draw: Draw, item: ListItem): RenderNode {
108  const { Box, Text } = draw.kit
109  return (
110    <Box position="absolute" left={0} top={0}>
111      <Text>{drawRuns(draw, markerRuns(item, draw.palette))}</Text>
112    </Box>
113  )
114}
115
116function flushCode(draw: Draw, blocks: Block[], width: number, top: number, context: Context, cue: string): RenderNode {
117  const edge = cellWidth(context.gutter)
118  const margin = edge > 0 ? -edge : undefined
119  const room = width + edge
120  return (
121    <draw.kit.Box marginTop={top} marginLeft={margin} width={margin === undefined ? undefined : room} flexDirection="column">
122      {blocks.map(child => (child.kind === 'code' ? code(draw, child.language, child.source, room, cue) : block(draw, child, room, { ...context, gutter: '' })))}
123    </draw.kit.Box>
124  )
125}
126
127function listItem(draw: Draw, item: ListItem, loose: boolean, numberWidth: number, width: number, context: Context, gap: number): RenderNode[] {
128  const lead = markerWidth(item, numberWidth) + 1
129  const inner = { ...context, done: context.done || item.marker === 'done', nesting: context.nesting + 1 }
130  const indented = { ...inner, gutter: context.gutter + ' '.repeat(lead) }
131  const childGap = loose ? 1 : 0
132  return segments(item.blocks).flatMap((segment, i) =>
133    spaced(draw, i === 0 ? gap : childGap, context, top => {
134      if (segment.isCode) return flushCode(draw, segment.blocks, width, top, inner, indented.gutter)
135      return itemBody(draw, segment.blocks, i === 0 ? itemMarker(draw, item) : null, lead, width, top, loose, indented)
136    }),
137  )
138}
139
140function list(draw: Draw, b: Extract<Block, { kind: 'list' }>, width: number, context: Context): RenderNode {
141  const numberWidth = Math.max(0, ...b.items.filter(i => i.marker === 'number').map(i => cellWidth(i.number ?? '')))
142  return (
143    <draw.kit.Box flexDirection="column">
144      {b.items.flatMap((item, index) => listItem(draw, item, b.loose, numberWidth, width, context, b.loose && index > 0 ? 1 : 0))}
145    </draw.kit.Box>
146  )
147}
148
149function listRows(b: Extract<Block, { kind: 'list' }>, width: number): number {
150  const items = b.items.map(item => item.blocks.reduce((rows, child) => rows + blockRows(child, width), 0) + gapRows(item.blocks.length, 1))
151  return items.reduce((rows, item) => rows + Math.max(1, item), 0) + gapRows(b.items.length, b.loose ? 1 : 0)
152}
153
154export function blockRows(b: Block, width: number): number {
155  return b.kind === 'paragraph' ? ownRows(b, width) : ownRows(b, Math.max(1, width - hangOf(b)))
156}
157
158function ownRows(b: Block, width: number): number {
159  switch (b.kind) {
160    case 'heading':
161      return wrappedRows(spansText(b.spans), width) + (b.level === 1 ? 1 : 0)
162    case 'paragraph':
163      return wrappedRows(spansText(b.spans), width - hangOf(b))
164    case 'list':
165      return listRows(b, width)
166    case 'code':
167      return (b.language === '' ? 0 : 1) + wrappedRows(codeLines(b.source).join('\n'), width)
168    case 'table':
169      return tableRows(b, width)
170    case 'quote':
171      return quoteBarRows(b, width)
172    case 'rule':
173      return 1
174  }
175}
176
177function proseRows(blocks: Block[], width: number): number {
178  return blocks.reduce((rows, child) => rows + blockRows(child, width), 0) + gapRows(blocks.length, 1)
179}
180
181export function quoteBarRows(b: Extract<Block, { kind: 'quote' }>, width: number): number {
182  return (b.alert ? 1 : 0) + proseRows(b.blocks, Math.max(1, width - QUOTE_INDENT))
183}
184
185function quoteChild(draw: Draw, child: Block, width: number, context: Context): RenderNode {
186  return child.kind === 'paragraph' ? paragraph(draw, child.spans, draw.palette.muted) : block(draw, child, width, context)
187}
188
189function barAt(draw: Draw, left: number, rows: number): RenderNode {
190  const { Box, Text } = draw.kit
191  return (
192    <Box position="absolute" left={left} top={0} bottom={0} width={1} overflow="hidden">
193      <Text color={draw.palette.faint}>{Array.from({ length: rows }, () => QUOTE_BAR).join('\n')}</Text>
194    </Box>
195  )
196}
197
198function quoteBar(draw: Draw, rows: number): RenderNode {
199  return barAt(draw, 0, rows)
200}
201
202function owedBars(draw: Draw, owed: number[], edge: number, rows: number): RenderNode[] {
203  return owed.map(column => barAt(draw, column - edge, rows))
204}
205
206function barredGap(draw: Draw, context: Context, rows: number): RenderNode {
207  return (
208    <draw.kit.Box height={rows} width={1}>
209      {owedBars(draw, context.owed, cellWidth(context.gutter), rows)}
210    </draw.kit.Box>
211  )
212}
213
214function spaced(draw: Draw, top: number, context: Context, build: (top: number) => RenderNode): RenderNode[] {
215  if (top === 0 || context.owed.length === 0) return [build(top)]
216  return [barredGap(draw, context, top), build(0)]
217}
218
219type DrawBlock = (draw: Draw, b: Block, width: number, context: Context) => RenderNode
220
221function barredBlock(draw: Draw, child: Block, width: number, context: Context, drawChild: DrawBlock = block): RenderNode {
222  if (context.owed.length === 0 || holdsCode(child)) return drawChild(draw, child, width, context)
223  return (
224    <draw.kit.Box flexDirection="column">
225      {drawChild(draw, child, width, { ...context, owed: [] })}
226      {owedBars(draw, context.owed, cellWidth(context.gutter), blockRows(child, width))}
227    </draw.kit.Box>
228  )
229}
230
231function quoteProse(draw: Draw, blocks: Block[], label: RenderNode | null, width: number, top: number, context: Context, edge: number): RenderNode {
232  const { Box } = draw.kit
233  const inner = Math.max(1, width - QUOTE_INDENT)
234  const rows = (label === null ? 0 : 1) + proseRows(blocks, inner)
235  const unowed = { ...context, owed: [] }
236  const children = blocks.map((child, i) => <Box flexDirection="column" marginTop={i > 0 ? 1 : 0}>{quoteChild(draw, child, inner, unowed)}</Box>)
237  return (
238    <Box flexDirection="column" paddingLeft={QUOTE_INDENT} marginTop={top}>
239      {label}
240      {children}
241      {quoteBar(draw, rows)}
242      {owedBars(draw, context.owed, edge, rows)}
243    </Box>
244  )
245}
246
247function quoteHoldingCode(draw: Draw, blocks: Block[], width: number, top: number, context: Context): RenderNode {
248  const inner = Math.max(1, width - QUOTE_INDENT)
249  return (
250    <draw.kit.Box flexDirection="column" paddingLeft={QUOTE_INDENT} marginTop={top}>
251      {blocks.flatMap((child, i) =>
252        spaced(draw, i > 0 ? 1 : 0, context, top => {
253          if (isCode(child)) return flushCode(draw, [child], inner, top, context, context.gutter)
254          return (
255            <draw.kit.Box flexDirection="column" marginTop={top}>
256              {barredBlock(draw, child, inner, context, quoteChild)}
257            </draw.kit.Box>
258          )
259        }),
260      )}
261    </draw.kit.Box>
262  )
263}
264
265function alertLabel(draw: Draw, b: Extract<Block, { kind: 'quote' }>): RenderNode | null {
266  return b.alert ? <draw.kit.Text bold color={draw.palette.strong}>{ALERT_LABEL[b.alert]}</draw.kit.Text> : null
267}
268
269function quote(draw: Draw, b: Extract<Block, { kind: 'quote' }>, width: number, context: Context): RenderNode {
270  const label = alertLabel(draw, b)
271  const edge = cellWidth(context.gutter)
272  const prose = { ...context, gutter: context.gutter + QUOTE_GUTTER }
273  const owing = { ...prose, owed: [...context.owed, edge] }
274  const parts = segments(b.blocks, holdsCode).filter((segment, i) => segment.blocks.length > 0 || (i === 0 && label !== null))
275  return (
276    <draw.kit.Box flexDirection="column">
277      {parts.flatMap((segment, i) =>
278        spaced(draw, i > 0 ? 1 : 0, { ...context, owed: owing.owed }, top => {
279          if (segment.isCode && segment.blocks.every(isCode)) return flushCode(draw, segment.blocks, width, top, context, prose.gutter)
280          if (segment.isCode) return quoteHoldingCode(draw, segment.blocks, width, top, owing)
281          return quoteProse(draw, segment.blocks, i === 0 ? label : null, width, top, prose, edge)
282        }),
283      )}
284    </draw.kit.Box>
285  )
286}
287
288function levelledItems(item: ListItem): ListItem[] {
289  const own = item.blocks.filter(child => child.kind !== 'list')
290  const deeper = item.blocks.flatMap(child => (child.kind === 'list' ? child.items.flatMap(levelledItems) : []))
291  return [{ ...item, blocks: own }, ...deeper]
292}
293
294function levelled(b: Extract<Block, { kind: 'list' }>): Extract<Block, { kind: 'list' }> {
295  return { ...b, items: b.items.flatMap(levelledItems) }
296}
297
298function levelledBlocks(blocks: Block[]): Block[] {
299  return blocks.flatMap(child => (child.kind === 'quote' ? levelledBlocks(child.blocks) : [child]))
300}
301
302type Paragraph = Extract<Block, { kind: 'paragraph' }>
303
304const PARAGRAPH_BREAK: Span = { text: '\n\n', kind: 'plain' }
305
306function mergedParagraphs(blocks: Block[]): Block[] {
307  return blocks.reduce<Block[]>((merged, child) => {
308    const last = merged.at(-1)
309    if (last?.kind === 'paragraph' && child.kind === 'paragraph') merged[merged.length - 1] = { ...last, spans: [...last.spans, PARAGRAPH_BREAK, ...child.spans] } as Paragraph
310    else merged.push(child)
311    return merged
312  }, [])
313}
314
315function levelledQuote(draw: Draw, b: Extract<Block, { kind: 'quote' }>, width: number, context: Context): RenderNode {
316  const { Box } = draw.kit
317  const label = alertLabel(draw, b)
318  const parts = mergedParagraphs(levelledBlocks(b.blocks))
319  const [only] = parts
320  if (label === null && parts.length === 1 && only) return quoteChild(draw, only, width, context)
321  return (
322    <Box flexDirection="column">
323      {label}
324      {parts.map((child, i) => (
325        <Box flexDirection="column" marginTop={i > 0 ? 1 : 0}>
326          {quoteChild(draw, child, width, context)}
327        </Box>
328      ))}
329    </Box>
330  )
331}
332
333const FOOTNOTE_LEAD = /^\[[^\]]+\] $/
334
335function footnoteLead(b: Paragraph): Span | undefined {
336  const [first] = b.spans
337  return first?.kind === 'footnote' && FOOTNOTE_LEAD.test(first.text) ? first : undefined
338}
339
340function hangOf(b: Block): number {
341  if (b.kind !== 'paragraph') return b.hang ?? 0
342  const lead = footnoteLead(b)
343  return lead === undefined ? (b.hang ?? 0) : cellWidth(lead.text)
344}
345
346function hungParagraph(draw: Draw, b: Paragraph, color: string): RenderNode {
347  const { Box } = draw.kit
348  const hang = hangOf(b)
349  if (hang === 0) return paragraph(draw, b.spans, color)
350  const lead = footnoteLead(b)
351  const body = lead === undefined ? b.spans : b.spans.slice(1)
352  return (
353    <Box flexDirection="column" paddingLeft={hang}>
354      {paragraph(draw, body, color)}
355      {lead === undefined ? null : (
356        <Box position="absolute" left={0} top={0}>
357          {paragraph(draw, [lead], color)}
358        </Box>
359      )}
360    </Box>
361  )
362}
363
364function block(draw: Draw, b: Block, width: number, context: Context = TOP): RenderNode {
365  const hang = b.kind === 'paragraph' ? 0 : hangOf(b)
366  if (hang === 0) return ownBlock(draw, b, width, context)
367  return (
368    <draw.kit.Box flexDirection="column" paddingLeft={hang}>
369      {ownBlock(draw, b, Math.max(1, width - hang), context)}
370    </draw.kit.Box>
371  )
372}
373
374function ownBlock(draw: Draw, b: Block, width: number, context: Context): RenderNode {
375  switch (b.kind) {
376    case 'heading':
377      return heading(draw, b.level, b.spans, width)
378    case 'paragraph':
379      return hungParagraph(draw, b, context.done ? draw.palette.muted : draw.palette.text)
380    case 'list':
381      return list(draw, context.nesting >= NESTING_LIMIT ? levelled(b) : b, width, context)
382    case 'code':
383      return code(draw, b.language, b.source, width)
384    case 'table':
385      return table(draw, b, width)
386    case 'quote':
387      return context.nesting >= NESTING_LIMIT ? levelledQuote(draw, b, width, context) : quote(draw, b, width, { ...context, nesting: context.nesting + 1 })
388    case 'rule':
389      return <draw.kit.Text color={draw.palette.faint}>{THEMATIC_BREAK.repeat(Math.min(width, RULE_CELLS))}</draw.kit.Text>
390  }
391}
392
393const MARKDOWN_MARKER: Partial<Record<ListItem['marker'], string>> = { bullet: '-', todo: '- [ ]', done: '- [x]' }
394
395function quoted(text: string): string {
396  return text
397    .split('\n')
398    .map(line => `> ${line}`)
399    .join('\n')
400}
401
402function itemText(item: ListItem): string {
403  const lead = MARKDOWN_MARKER[item.marker] ?? item.number ?? '-'
404  const body = item.blocks.map(plainText).join('\n')
405  return `${lead} ${body.replace(/\n/g, `\n${' '.repeat(lead.length + 1)}`)}`
406}
407
408export function plainText(b: Block): string {
409  switch (b.kind) {
410    case 'heading':
411      return `${'#'.repeat(b.level)} ${spansText(b.spans)}`
412    case 'paragraph':
413      return spansText(b.spans)
414    case 'list':
415      return b.items.map(itemText).join('\n')
416    case 'code':
417      return b.source.replace(/\n+$/, '')
418    case 'table':
419      return [b.header, ...b.rows].map(row => row.map(spansText).join('  ')).join('\n')
420    case 'quote':
421      return quoted(b.blocks.map(plainText).join('\n\n'))
422    case 'rule':
423      return '---'
424  }
425}
426
427type Extent = { nodes: number; height: number }
428
429const extents = new WeakMap<object, Extent>()
430
431function extentOf(node: RenderNode | null | undefined): Extent {
432  if (node === null || node === undefined) return { nodes: 0, height: 0 }
433  if (typeof node !== 'object') return { nodes: 1, height: 1 }
434  const known = extents.get(node)
435  if (known) return known
436  const children = (node as { children?: RenderNode[] }).children ?? []
437  const extent = children.reduce<Extent>(
438    (sum, child) => {
439      const inner = extentOf(child)
440      return { nodes: sum.nodes + inner.nodes, height: Math.max(sum.height, inner.height + 1) }
441    },
442    { nodes: 1, height: 1 },
443  )
444  extents.set(node, extent)
445  return extent
446}
447
448export function treeSize(node: RenderNode | null | undefined, depth = 1): Size {
449  const { nodes, height } = extentOf(node)
450  return { nodes, depth: depth - 1 + height }
451}
452
453const drawnBlocks = new WeakMap<Block, Map<string, DrawnBlock>>()
454
455const paletteIds = new WeakMap<Palette, number>()
456
457let palettesSeen = 0
458
459function paletteId(palette: Palette): number {
460  const known = paletteIds.get(palette)
461  if (known !== undefined) return known
462  palettesSeen += 1
463  paletteIds.set(palette, palettesSeen)
464  return palettesSeen
465}
466
467export function drawnBlock(draw: Draw, b: Block, width: number): DrawnBlock {
468  const key = `${paletteId(draw.palette)}|${width}`
469  const drawings = drawnBlocks.get(b) ?? new Map<string, DrawnBlock>()
470  drawnBlocks.set(b, drawings)
471  const cached = drawings.get(key)
472  if (cached) return cached
473  const node = block(draw, b, width)
474  const drawn = { node, size: treeSize(node) }
475  drawings.set(key, drawn)
476  return drawn
477}
478
479export function blockGap(previous: Block | undefined, current: Block): number {
480  if (!previous) return 0
481  return current.kind === 'heading' && current.level <= SECTION_LEVELS ? TITLE_GAP : 1
482}
483
484function blockRow(draw: Draw, node: RenderNode, gap: number, width: number | undefined): RenderNode {
485  return (
486    <draw.kit.Box flexDirection="column" marginTop={gap} width={width}>
487      {node}
488    </draw.kit.Box>
489  )
490}
491
492function plainRest(draw: Draw, blocks: Block[]): RenderNode {
493  return <draw.kit.Text color={draw.palette.text}>{blocks.map(plainText).join('\n\n')}</draw.kit.Text>
494}
495
496const PICTURE_NODES = 8
497
498const NO_PICTURES: Pictures = { found: new Map(), node: () => '' }
499
500function pictureKey(used: Set<string>, key: string): string {
501  let unique = key
502  for (let n = 2; used.has(unique); n++) unique = `${key}-${n}`
503  used.add(unique)
504  return unique
505}
506
507function gapBefore(previous: Block | Placed | undefined, current: Block | Placed): number {
508  if (previous === undefined) return 0
509  return previous.kind === 'picture' || current.kind === 'picture' ? 1 : blockGap(previous, current)
510}
511
512export function renderMarkdown(draw: Draw, text: string, columns: number, streaming = false, pictures: Pictures = NO_PICTURES): RenderElement {
513  const blocks = laidOut(parseBlocks(text, { streaming }), pictures.found)
514  const flowWidth = Math.max(1, columns)
515  const rows: RenderNode[] = []
516  const used = new Set<string>()
517  let spent = 1
518  for (const [i, b] of blocks.entries()) {
519    const gap = gapBefore(blocks[i - 1], b)
520    if (b.kind === 'picture') {
521      rows.push(blockRow(draw, pictures.node(b.picture, pictureKey(used, b.picture.key)), gap, undefined))
522      spent += PICTURE_NODES + ROW_NODES
523      continue
524    }
525    const wide = b.kind === 'list' ? holdsWide(b) : WIDE_BLOCKS.has(b.kind)
526    const drawn = drawnBlock(draw, b, flowWidth)
527    if (spent + drawn.size.nodes + ROW_NODES > NODE_BUDGET) {
528      rows.push(blockRow(draw, plainRest(draw, blocks.slice(i).filter((rest): rest is Block => rest.kind !== 'picture')), gap, undefined))
529      break
530    }
531    const node = drawn.size.depth <= DEPTH_BUDGET ? drawn.node : plainRest(draw, [b])
532    spent += (node === drawn.node ? drawn.size.nodes : 2) + ROW_NODES
533    rows.push(blockRow(draw, node, gap, wide ? undefined : flowWidth))
534  }
535  return (
536    <draw.kit.Box flexDirection="column" marginTop={1}>
537      {rows}
538    </draw.kit.Box>
539  )
540}
541
hooks/prompt.tsx 61 lines
1import type { RenderElement, RenderNode } from 'claude-code'
2
3import type { Draw } from './runs'
4import { cellWidth, clipCells, firstLine } from './width'
5
6const PASTE = /<pasted_content[^>]*>([\s\S]*?)<\/pasted_content[^>]*>/g
7
8const PREVIEW_CELLS = 60
9
10const PROMPT_CHROME = 3
11
12type PromptPart = { text: string; isPaste: boolean; preview?: string }
13
14function pasteLabel(body: string): string {
15  const lines = body.split('\n').length
16  return `[pasted ${lines} line${lines === 1 ? '' : 's'}]`
17}
18
19export function collapsePastes(text: string): PromptPart[] {
20  const parts: PromptPart[] = []
21  let last = 0
22  for (const match of text.matchAll(PASTE)) {
23    const index = match.index ?? 0
24    const before = text.slice(last, index).trim()
25    if (before !== '') parts.push({ text: before + ' ', isPaste: false })
26    const body = (match[1] ?? '').trim()
27    parts.push({ text: pasteLabel(body), isPaste: true, preview: firstLine(body) || undefined })
28    last = index + match[0].length
29  }
30  const after = text.slice(last).trim()
31  if (after !== '') parts.push({ text: (parts.length > 0 ? ' ' : '') + after, isPaste: false })
32  return parts
33}
34
35function promptPart(draw: Draw, part: PromptPart, onlyPart: boolean, width: number): RenderNode {
36  const { Text } = draw.kit
37  if (!part.isPaste) return part.text
38  const room = Math.min(PREVIEW_CELLS, width - PROMPT_CHROME - cellWidth(part.text) - 1)
39  const preview = onlyPart && part.preview && room > 1 ? ` ${clipCells(part.preview, room)}` : ''
40  return (
41    <Text>
42      <Text color={draw.palette.muted}>{part.text}</Text>
43      {preview}
44    </Text>
45  )
46}
47
48export function renderPrompt(draw: Draw, text: string, columns: number): RenderElement {
49  const { kit, palette } = draw
50  const { Box, Text } = kit
51  const parts = collapsePastes(text)
52  return (
53    <Box flexDirection="row" marginTop={1} width={columns}>
54      <Box width={1} flexShrink={0} backgroundColor={palette.accent} />
55      <Box flexGrow={1} backgroundColor={palette.panel} paddingX={1}>
56        <Text color={palette.strong}>{parts.map(part => promptPart(draw, part, parts.length === 1, columns))}</Text>
57      </Box>
58    </Box>
59  )
60}
61
hooks/runs.tsx 113 lines
1import type { Elements, RenderNode } from 'claude-code'
2
3import type { Span } from './markdown'
4import type { Palette } from './palette'
5import { cellWidth } from './width'
6
7type Kit = Pick<Elements['terminal'], 'Box' | 'Text' | 'Link'>
8
9export type Draw = { kit: Kit; palette: Palette }
10
11export type Look = { color?: string; background?: string; bold?: boolean; italic?: boolean; strike?: boolean; underline?: boolean; href?: string }
12
13export type Run = { text: string; look: Look }
14
15const CODE_KINDS = new Set<Span['kind']>(['code', 'path', 'line', 'command', 'subcommand', 'flag', 'string', 'number', 'variable', 'operator', 'word', 'space'])
16
17const SPAN_ROLE: Partial<Record<Span['kind'], keyof Palette>> = {
18  code: 'strong',
19  url: 'strong',
20  path: 'strong',
21  line: 'muted',
22  command: 'strong',
23  subcommand: 'strong',
24  flag: 'muted',
25  string: 'strong',
26  number: 'strong',
27  variable: 'strong',
28  operator: 'muted',
29  image: 'muted',
30  footnote: 'muted',
31}
32
33const WHITESPACE = /^\s+$/
34
35function sameLook(a: Look, b: Look): boolean {
36  return a.color === b.color && a.background === b.background && !a.bold === !b.bold && !a.italic === !b.italic && !a.strike === !b.strike && !a.underline === !b.underline && a.href === b.href
37}
38
39function absorbsSpace(look: Look): boolean {
40  return !look.underline && !look.strike && look.href === undefined && look.background === undefined
41}
42
43function isPlain(look: Look): boolean {
44  return look.color === undefined && look.background === undefined && !look.bold && !look.italic && !look.strike && !look.underline && look.href === undefined
45}
46
47export function appendRun(runs: Run[], text: string, look: Look): void {
48  if (text === '') return
49  const previous = runs.at(-1)
50  if (previous && (sameLook(previous.look, look) || (WHITESPACE.test(text) && isPlain(look) && absorbsSpace(previous.look)))) previous.text += text
51  else runs.push({ text, look: { ...look } })
52}
53
54export function spanLook(span: Span, palette: Palette, chip: boolean): Look {
55  const role = SPAN_ROLE[span.kind]
56  const bold = span.bold || span.kind === 'command'
57  const look: Look = {}
58  if (role) look.color = palette[role]
59  if (chip && CODE_KINDS.has(span.kind)) look.background = palette.chip
60  if (bold) look.bold = true
61  if (span.italic) look.italic = true
62  if (span.strike) look.strike = true
63  if (span.underline || span.kind === 'url' || span.href !== undefined) look.underline = true
64  if (span.href !== undefined) look.href = span.href
65  return look
66}
67
68function bindsValue(span: Span | undefined): boolean {
69  return span?.kind === 'flag' && span.text.endsWith('=')
70}
71
72export function flagValues(spans: Span[]): Span[] {
73  return spans.map((span, i) => (bindsValue(spans[i - 1]) && span.kind !== 'space' ? { ...span, kind: 'flag' } : span))
74}
75
76export function spanRuns(spans: Span[], palette: Palette, runs: Run[] = [], chip = false): Run[] {
77  for (const span of flagValues(spans)) appendRun(runs, span.text, span.kind === 'space' && !chip ? {} : spanLook(span, palette, chip))
78  return runs
79}
80
81export function drawRuns({ kit }: Draw, runs: Run[]): RenderNode[] {
82  const { Text, Link } = kit
83  return runs.map(({ text, look }) => {
84    if (isPlain(look)) return text
85    return (
86      <Text color={look.color} backgroundColor={look.background} bold={look.bold} italic={look.italic} strikethrough={look.strike} underline={look.underline}>
87        {look.href === undefined ? text : <Link href={look.href}>{text}</Link>}
88      </Text>
89    )
90  })
91}
92
93export function inline(draw: Draw, spans: Span[]): RenderNode[] {
94  return drawRuns(draw, spanRuns(spans, draw.palette, [], true))
95}
96
97export function paragraph(draw: Draw, spans: Span[], color: string): RenderNode {
98  return <draw.kit.Text color={color}>{inline(draw, spans)}</draw.kit.Text>
99}
100
101export function spansText(spans: Span[]): string {
102  return spans.map(span => span.text).join('')
103}
104
105export function wrappedRows(text: string, width: number): number {
106  const room = Math.max(1, width)
107  return text.split('\n').reduce((rows, line) => rows + Math.max(1, Math.ceil((2 * cellWidth(line)) / room)), 0)
108}
109
110export function gapRows(count: number, gap: number): number {
111  return Math.max(0, count - 1) * gap
112}
113
hooks/tool-rows.tsx 179 lines
1import type { RenderElement, RenderNode } from 'claude-code'
2
3import type { Span } from './markdown'
4import type { Palette } from './palette'
5import { drawRuns, spanRuns } from './runs'
6import type { Draw, Run } from './runs'
7import { cellWidth } from './width'
8import { fitMiddle } from './tools'
9import type { ToolLine, ToolRow, ToolState } from './tools'
10
11const GUTTER = 2
12
13const STATE_LOOK: Record<ToolState, { glyph: string; role: keyof Palette; name: keyof Palette; label: keyof Palette }> = {
14  pending: { glyph: '◌', role: 'muted', name: 'strong', label: 'strong' },
15  running: { glyph: '◐', role: 'progress', name: 'strong', label: 'strong' },
16  started: { glyph: '○', role: 'muted', name: 'muted', label: 'muted' },
17  done: { glyph: '●', role: 'done', name: 'strong', label: 'strong' },
18  failed: { glyph: '✗', role: 'error', name: 'strong', label: 'error' },
19  rejected: { glyph: '⊘', role: 'muted', name: 'muted', label: 'muted' },
20  interrupted: { glyph: '⊘', role: 'muted', name: 'muted', label: 'muted' },
21  cancelled: { glyph: '⊘', role: 'muted', name: 'muted', label: 'muted' },
22}
23
24const BRANCH = '  ⎿  '
25
26const NOTE_GAP = '  '
27
28const QUIET_STATES = new Set<ToolState>(['rejected', 'interrupted', 'cancelled'])
29
30const PREVIEW_LINES = 10
31
32function noteWidth(note: string | undefined): number {
33  return note ? cellWidth(NOTE_GAP + note) : 0
34}
35
36function detailRoom(columns: number, indent: number, label: string, note: string | undefined): number {
37  return Math.max(0, columns - indent - cellWidth(label) - 1 - noteWidth(note))
38}
39
40function plainLinks(runs: Run[]): Run[] {
41  return runs.map(run => (run.look.href === undefined ? run : { ...run, look: { ...run.look, underline: false } }))
42}
43
44function quietRuns(runs: Run[], color: string): Run[] {
45  return runs.map(run => ({ ...run, look: { ...run.look, color, bold: false } }))
46}
47
48function inTextColor(runs: Run[], color: string): Run[] {
49  return runs.map(run => (run.look.color === undefined ? { ...run, look: { ...run.look, color } } : run))
50}
51
52function toolDetail(draw: Draw, spans: Span[], room: number, quiet: boolean): RenderNode[] {
53  const runs = inTextColor(plainLinks(spanRuns(fitMiddle(spans, room), draw.palette)), draw.palette.text)
54  return drawRuns(draw, quiet ? quietRuns(runs, draw.palette.muted) : runs)
55}
56
57function toolText(draw: Draw, label: RenderNode, detail: Span[], note: string | undefined, room: number, quiet = false): RenderNode {
58  const { Text } = draw.kit
59  return (
60    <Text wrap="truncate-end">
61      {label}
62      {detail.length > 0 ? ' ' : ''}
63      {toolDetail(draw, detail, room, quiet)}
64      {note ? <Text color={draw.palette.muted}>{NOTE_GAP + note}</Text> : null}
65    </Text>
66  )
67}
68
69function branch(draw: Draw, first: boolean, line: ToolLine, columns: number): RenderNode {
70  const { Box, Text } = draw.kit
71  const state = line.state ?? 'done'
72  const label = <Text color={draw.palette[STATE_LOOK[state].label]}>{line.label}</Text>
73  return (
74    <Box flexDirection="row">
75      <Box width={BRANCH.length} flexShrink={0}>
76        <Text color={draw.palette.muted}>{first ? BRANCH : ' '.repeat(BRANCH.length)}</Text>
77      </Box>
78      {toolText(draw, label, line.detail, line.note, detailRoom(columns, BRANCH.length, line.label, line.note), QUIET_STATES.has(state))}
79    </Box>
80  )
81}
82
83function stateGlyph({ kit, palette }: Draw, state: ToolState): RenderNode {
84  const look = STATE_LOOK[state]
85  return <kit.Text color={palette[look.role]}>{look.glyph}</kit.Text>
86}
87
88export function renderToolRow(draw: Draw, row: ToolRow, state: ToolState, columns: number): RenderElement {
89  const { kit, palette } = draw
90  const { Box, Text } = kit
91  const look = STATE_LOOK[state]
92  const name = (
93    <Text bold color={palette[look.name]}>
94      {row.name}
95    </Text>
96  )
97  return (
98    <Box flexDirection="column" marginTop={1}>
99      <Box flexDirection="row">
100        <Box width={GUTTER} flexShrink={0}>
101          {stateGlyph(draw, state)}
102        </Box>
103        {toolText(draw, name, row.detail, row.note, detailRoom(columns, GUTTER, row.name, row.note), QUIET_STATES.has(state))}
104      </Box>
105      {(row.lines ?? []).map((line, i) => branch(draw, i === 0, line, columns))}
106    </Box>
107  )
108}
109
110function contentLines(content: string): string[] {
111  const lines = content.split('\n')
112  return lines.at(-1) === '' ? lines.slice(0, -1) : lines
113}
114
115function previewLine(draw: Draw, number: number, digits: number, text: string): RenderNode {
116  const { Box, Text } = draw.kit
117  return (
118    <Box flexDirection="row" paddingLeft={BRANCH.length + 1}>
119      <Text wrap="truncate-end">
120        <Text color={draw.palette.muted}>{String(number).padStart(digits)} </Text>
121        <Text color={draw.palette.text}>{text}</Text>
122      </Text>
123    </Box>
124  )
125}
126
127export function renderFailure(draw: Draw, reason: ReadonlyArray<string>): RenderElement {
128  const { Box, Text } = draw.kit
129  const [first = '', ...rest] = reason
130  const shown = rest.slice(0, PREVIEW_LINES - 1)
131  const hidden = rest.length - shown.length
132  return (
133    <Box flexDirection="column">
134      <Text wrap="truncate-end">
135        <Text color={draw.palette.muted}>{BRANCH}</Text>
136        <Text color={draw.palette.error}>{first}</Text>
137      </Text>
138      {shown.map((line, i) => (
139        <Box key={String(i)} paddingLeft={BRANCH.length}>
140          <Text wrap="truncate-end" color={draw.palette.muted}>
141            {line}
142          </Text>
143        </Box>
144      ))}
145      {hidden > 0 ? (
146        <Box paddingLeft={BRANCH.length}>
147          <Text color={draw.palette.muted}>
148            … +{hidden} line{hidden === 1 ? '' : 's'} (ctrl+o to expand)
149          </Text>
150        </Box>
151      ) : null}
152    </Box>
153  )
154}
155
156export function renderWrittenFile(draw: Draw, content: string): RenderElement {
157  const { Box, Text } = draw.kit
158  const lines = contentLines(content)
159  const shown = lines.slice(0, PREVIEW_LINES)
160  const digits = String(shown.length).length
161  const hidden = lines.length - shown.length
162  return (
163    <Box flexDirection="column">
164      <Text color={draw.palette.muted}>
165        {BRANCH}
166        {lines.length} line{lines.length === 1 ? '' : 's'}
167      </Text>
168      {shown.map((text, i) => previewLine(draw, i + 1, digits, text))}
169      {hidden > 0 ? (
170        <Box paddingLeft={BRANCH.length + 1}>
171          <Text color={draw.palette.muted}>
172            … +{hidden} line{hidden === 1 ? '' : 's'} (ctrl+o to expand)
173          </Text>
174        </Box>
175      ) : null}
176    </Box>
177  )
178}
179
hooks/live.ts 38 lines
1const REMEMBERED = 20_000
2
3export class ReplyStream {
4  private live = false
5  private readonly seenWhileLive = new Set<string>()
6  private readonly finished = new Set<string>()
7
8  turnStarted(): void {
9    this.live = true
10  }
11
12  turnEnded(): void {
13    this.live = false
14    for (const id of this.seenWhileLive) this.finish(id)
15    this.seenWhileLive.clear()
16  }
17
18  stored(id: string): void {
19    this.finish(id)
20  }
21
22  isStreaming(id: string): boolean {
23    if (this.finished.has(id)) return false
24    if (!this.live) {
25      this.finish(id)
26      return false
27    }
28    this.seenWhileLive.add(id)
29    return true
30  }
31
32  private finish(id: string): void {
33    this.finished.delete(id)
34    this.finished.add(id)
35    if (this.finished.size > REMEMBERED) this.finished.delete(this.finished.values().next().value ?? '')
36  }
37}
38
hooks/permits.ts 41 lines
1export type Verdict = 'allow' | 'ask' | 'deny'
2
3const MAIN_LOOP = 'main'
4
5type Call = { loop: string; verdict?: Verdict }
6
7export class CallLedger {
8  private readonly calls = new Map<string, Call>()
9
10  started(id: string | undefined, agentId?: string): void {
11    if (id !== undefined) this.calls.set(id, { loop: agentId ?? MAIN_LOOP })
12  }
13
14  settled(id: string | undefined): void {
15    if (id !== undefined) this.calls.delete(id)
16  }
17
18  decided(id: string | undefined, verdict: Verdict): string[] {
19    const call = id === undefined ? undefined : this.calls.get(id)
20    if (call === undefined) return []
21    call.verdict = verdict
22    const batch = this.batchOf(call.loop)
23    if (batch.some(([, sibling]) => sibling.verdict === undefined || sibling.verdict === 'ask')) return []
24    return allowedIds(batch)
25  }
26
27  sharingProgress(id: string): string[] {
28    const call = this.calls.get(id)
29    const siblings = call === undefined ? [] : allowedIds(this.batchOf(call.loop))
30    return [id, ...siblings.filter(sibling => sibling !== id)]
31  }
32
33  private batchOf(loop: string): Array<[string, Call]> {
34    return [...this.calls].filter(([, call]) => call.loop === loop)
35  }
36}
37
38function allowedIds(batch: ReadonlyArray<[string, Call]>): string[] {
39  return batch.filter(([, call]) => call.verdict === 'allow').map(([id]) => id)
40}
41
hooks/message.ts 285 lines
1import type { Token } from './vendor/marked.js'
2
3import { settledText, settledTokens, withoutPendingPreClose } from './holdback'
4import { lexBlocks } from './lexer'
5import { codePiece, footnoteLabels, pieceOf, withFootnoteBodies } from './markdown'
6import type { Block, Footnotes, OutlineEntry, Piece } from './markdown'
7
8export type ParseOptions = { streaming?: boolean }
9
10type Parse = {
11  text: string
12  streaming: boolean
13  bodyStart: number
14  stable: Piece[]
15  stableEnd: number
16  hasDefinitions: boolean
17  open: Map<number, Piece>
18  pieces: Piece[]
19  blocks: Block[]
20  outline?: OutlineEntry[]
21}
22
23type Front = { piece: Piece; bodyStart: number }
24
25const CACHED_CHARS = 4_000_000
26
27const RECENT = 4
28
29const OPEN_BLOCKS = 2
30
31const FRONT_MATTER_LINES = 200
32
33const FRONT_MATTER_KEY = /^[a-z_][a-z\d_-]*:(?:[ \t]+(.*))?$/
34
35const FRONT_MATTER_LINE = /^(?:[ \t]|- |#|$)/
36
37const FRONT_MATTER_FENCE = /^---[ \t]*$/
38
39const SENTENCE = /\s.*[.!?]$/
40
41const DEFINITION = /^ {0,3}\[[^\]\n]+\]:/m
42
43const cache = new Map<string, Parse>()
44
45let cachedChars = 0
46
47let recent: Parse[] = []
48
49export function parseBlocks(text: string, options: ParseOptions = {}): Block[] {
50  return parsed(text, options.streaming === true).blocks
51}
52
53export function blocksOf(text: string, options: ParseOptions = {}): OutlineEntry[] {
54  const parse = parsed(text, options.streaming === true)
55  parse.outline ??= locatedOutline(parse, text)
56  return parse.outline
57}
58
59export function parseAfresh(text: string, options: ParseOptions = {}): { blocks: Block[]; outline: OutlineEntry[] } {
60  const parse = parseMessage(normalised(text), options.streaming === true, () => undefined)
61  return { blocks: parse.blocks, outline: locatedOutline(parse, text) }
62}
63
64function locatedOutline(parse: Parse, original: string): OutlineEntry[] {
65  const toOriginal = originalOffsets(original)
66  const outline: OutlineEntry[] = []
67  let offset = 0
68  for (const piece of parse.pieces) {
69    outline.push(...locatedPiece(piece, parse.text, offset).map(entry => ({ ...entry, start: toOriginal(entry.start ?? 0), end: toOriginal(entry.end ?? 0) })))
70    offset += piece.source.length
71  }
72  return outline
73}
74
75function locatedPiece(piece: Piece, text: string, offset: number): OutlineEntry[] {
76  const cursors: number[] = [offset]
77  return piece.outline.map(entry => {
78    if (entry.depth === 0) {
79      const found = text.indexOf(entry.source, cursors[0])
80      const start = found === -1 ? offset : found
81      cursors.length = 0
82      cursors.push(start + entry.source.length, start)
83      return { ...entry, start, end: start + entry.source.length }
84    }
85    const [start, end] = linesSpan(text, entry.source, cursors[entry.depth] ?? offset)
86    cursors[entry.depth] = end
87    cursors[entry.depth + 1] = start
88    return { ...entry, start, end }
89  })
90}
91
92function linesSpan(text: string, source: string, from: number): [number, number] {
93  let start: number | undefined
94  let end = from
95  for (const line of source.split('\n')) {
96    const content = line.trim()
97    if (content === '') continue
98    const at = contentAt(text, content, end)
99    if (at === -1) continue
100    start ??= at
101    end = at + content.length
102  }
103  return [start ?? from, end]
104}
105
106const CONTAINER_PREFIX = /^[ \t]*(?:(?:>|[-*+]|\d{1,9}[.)])[ \t]*)*(?:\[[ xX]\][ \t]*)?$/
107
108const BLANK = /^[ \t]*$/
109
110function contentAt(text: string, content: string, from: number): number {
111  for (let at = text.indexOf(content, from); at !== -1; at = text.indexOf(content, at + 1)) if (isLineContent(text, at, content.length)) return at
112  return text.indexOf(content, from)
113}
114
115function isLineContent(text: string, at: number, length: number): boolean {
116  const lineStart = text.lastIndexOf('\n', at - 1) + 1
117  const lineEnd = text.indexOf('\n', at + length)
118  return CONTAINER_PREFIX.test(text.slice(lineStart, at)) && BLANK.test(text.slice(at + length, lineEnd === -1 ? undefined : lineEnd))
119}
120
121function originalOffsets(original: string): (offset: number) => number {
122  const mark = original.startsWith(BYTE_ORDER_MARK) ? BYTE_ORDER_MARK.length : 0
123  if (!original.includes('\r')) return offset => offset + mark
124  const removed: number[] = []
125  for (const match of original.matchAll(/\r\n/g)) removed.push((match.index ?? 0) - removed.length)
126  return offset => offset + mark + countBelow(removed, offset + mark)
127}
128
129function countBelow(sorted: number[], value: number): number {
130  let low = 0
131  let high = sorted.length
132  while (low < high) {
133    const middle = (low + high) >> 1
134    if ((sorted[middle] ?? 0) < value) low = middle + 1
135    else high = middle
136  }
137  return low
138}
139
140const BYTE_ORDER_MARK = '\uFEFF'
141
142function normalised(text: string): string {
143  const unmarked = text.startsWith(BYTE_ORDER_MARK) ? text.slice(BYTE_ORDER_MARK.length) : text
144  return unmarked.includes('\r') ? unmarked.replace(/\r\n?/g, '\n') : unmarked
145}
146
147function parsed(text: string, streaming: boolean): Parse {
148  const key = `${streaming ? 's' : 'f'}${text}`
149  const hit = cache.get(key)
150  if (hit) {
151    cache.delete(key)
152    cache.set(key, hit)
153    return hit
154  }
155  const parse = parseMessage(normalised(text), streaming, (body, bodyStart) => baseFor(body, bodyStart, streaming))
156  remember(key, parse)
157  return parse
158}
159
160function remember(key: string, parse: Parse): void {
161  cache.set(key, parse)
162  cachedChars += key.length
163  while (cachedChars > CACHED_CHARS && cache.size > 1) {
164    const [oldest] = cache.keys()
165    if (oldest === undefined) break
166    cache.delete(oldest)
167    cachedChars -= oldest.length
168  }
169  recent = [parse, ...recent.filter(other => other !== parse)].slice(0, RECENT)
170}
171
172function baseFor(text: string, bodyStart: number, streaming: boolean): Parse | undefined {
173  let best: Parse | undefined
174  for (const candidate of recent) {
175    if (candidate.streaming !== streaming || candidate.bodyStart !== bodyStart || candidate.text.length > text.length || !text.startsWith(candidate.text)) continue
176    if (!best || candidate.stableEnd > best.stableEnd) best = candidate
177  }
178  return best
179}
180
181function parseMessage(text: string, streaming: boolean, findBase: (text: string, bodyStart: number) => Parse | undefined): Parse {
182  const front = frontMatterOf(text)
183  const bodyStart = front?.bodyStart ?? 0
184  const body = bodyStart === 0 ? text : text.slice(bodyStart)
185  const base = findBase(text, bodyStart)
186  const resumeAt = base && !base.hasDefinitions ? base.stableEnd : 0
187  const hasDefinitions = DEFINITION.test(resumeAt === 0 ? body : body.slice(resumeAt))
188  const from = hasDefinitions ? 0 : resumeAt
189  const tokens = lexBlocks(body.slice(from))
190  const split = hasDefinitions ? { settled: [], rest: tokens, length: 0 } : splitSettled(tokens, body.length - from)
191  const pieces = new Pieces(hasDefinitions ? undefined : base?.open, footnotesOf(body, hasDefinitions))
192  const stable = [...(from === 0 ? [] : (base?.stable ?? [])), ...pieces.of(split.settled, from)]
193  const stableEnd = from + split.length
194  const tail = streaming ? shownPieces(body, stableEnd, pieces) : pieces.of(split.rest, stableEnd)
195  const all = [...(front ? [front.piece] : []), ...stable, ...tail]
196  return { text, streaming, bodyStart, stable, stableEnd, hasDefinitions, open: pieces.made, pieces: all, blocks: all.flatMap(piece => piece.blocks) }
197}
198
199function footnotesOf(body: string, hasDefinitions: boolean): Footnotes | undefined {
200  return hasDefinitions ? footnoteLabels(body) : undefined
201}
202
203class Pieces {
204  readonly made = new Map<number, Piece>()
205
206  constructor(
207    private readonly known: Map<number, Piece> | undefined,
208    private readonly footnotes: Footnotes | undefined,
209  ) {}
210
211  of(tokens: Token[], start: number): Piece[] {
212    let offset = start
213    return withFootnoteBodies(tokens).map(token => {
214      const prior = this.known?.get(offset)
215      const piece = prior?.source === token.raw ? prior : pieceOf(token, this.footnotes)
216      this.made.set(offset, piece)
217      offset += token.raw.length
218      return piece
219    })
220  }
221}
222
223function splitSettled(tokens: Token[], length: number): { settled: Token[]; rest: Token[]; length: number } {
224  const lengths = tokens.map(token => token.raw.length)
225  if (lengths.reduce((sum, raw) => sum + raw, 0) !== length) return { settled: [], rest: tokens, length: 0 }
226  const cut = openBlocksStart(tokens)
227  return { settled: tokens.slice(0, cut), rest: tokens.slice(cut), length: lengths.slice(0, cut).reduce((sum, raw) => sum + raw, 0) }
228}
229
230function openBlocksStart(tokens: Token[]): number {
231  let open = 0
232  for (let i = tokens.length - 1; i >= 0; i--) {
233    if (tokens[i]?.type === 'space') continue
234    open++
235    if (open === OPEN_BLOCKS) return i
236  }
237  return 0
238}
239
240function shownPieces(body: string, stableEnd: number, pieces: Pieces): Piece[] {
241  const { shown, tokens } = shownTail(settledText(body).slice(stableEnd))
242  return shown === '' ? [] : pieces.of(tokens, stableEnd).map(trimmedTo(shown))
243}
244
245function shownTail(settled: string): { shown: string; tokens: Token[] } {
246  const tokens = settledTokens(settled)
247  const shown = withoutPendingPreClose(settled, tokens)
248  return shown === settled ? { shown, tokens } : { shown, tokens: settledTokens(shown) }
249}
250
251function trimmedTo(shown: string): (piece: Piece, index: number, pieces: Piece[]) => Piece {
252  return (piece, index, pieces) => {
253    const before = pieces.slice(0, index).reduce((sum, other) => sum + other.source.length, 0)
254    const source = shown.slice(before, before + piece.source.length)
255    if (source === piece.source) return piece
256    return { ...piece, source, outline: piece.outline.map(entry => (entry.depth === 0 ? { ...entry, source } : entry)) }
257  }
258}
259
260function frontMatterOf(text: string): Front | undefined {
261  if (!text.startsWith('---\n')) return undefined
262  const lines: string[] = []
263  let start = 4
264  while (lines.length < FRONT_MATTER_LINES) {
265    const end = text.indexOf('\n', start)
266    const line = text.slice(start, end === -1 ? undefined : end)
267    if (FRONT_MATTER_FENCE.test(line)) return frontOf(text, lines, end === -1 ? text.length : end + 1)
268    if (end === -1) return undefined
269    lines.push(line)
270    start = end + 1
271  }
272  return undefined
273}
274
275function frontOf(text: string, lines: string[], bodyStart: number): Front | undefined {
276  if (!FRONT_MATTER_KEY.test(lines[0] ?? '') || !lines.every(isFrontMatterLine)) return undefined
277  return { piece: codePiece(text.slice(0, bodyStart), 'yaml', lines.join('\n')), bodyStart }
278}
279
280function isFrontMatterLine(line: string): boolean {
281  const key = FRONT_MATTER_KEY.exec(line)
282  if (key) return !SENTENCE.test(key[1] ?? '')
283  return FRONT_MATTER_LINE.test(line)
284}
285
hooks/pictures.tsx 120 lines
1import type { Elements, EngineInterface, RenderNode } from 'claude-code'
2
3import type { Block, Span } from './markdown'
4import type { Palette } from './palette'
5
6export type Picture = { source: { png: string }; columns: number; rows: number; alt: string; path: string; name: string; caption: string; open: string; key: string }
7
8export type Placed = { kind: 'picture'; picture: Picture }
9
10export type Pictures = { found: ReadonlyMap<string, Picture>; node: (picture: Picture, key: string) => RenderNode }
11
12export type Asking = { requestId?: string; columns: number; isFullscreen?: boolean }
13
14type Kit = Pick<Elements['terminal'], 'Box' | 'Text' | 'Image' | 'Button'>
15
16declare module 'claude-code' {
17  interface EngineInterface {
18    peek: { picture: (args: { path: string } & Asking) => Promise<unknown>; open: (args: { path: string }) => Promise<unknown> }
19  }
20}
21
22const SCHEME = /^[a-z][a-z0-9+.-]*:/i
23
24export function isLocalImage(src: string): boolean {
25  return !SCHEME.test(src) || src.toLowerCase().startsWith('file:')
26}
27
28function localSources(spans: Span[]): string[] {
29  return spans.flatMap(span => (span.kind === 'image' && span.src !== undefined && isLocalImage(span.src) ? [span.src] : []))
30}
31
32export function imageSources(blocks: Block[]): string[] {
33  return [...new Set(blocks.flatMap(b => (b.kind === 'paragraph' ? localSources(b.spans) : [])))]
34}
35
36function isText(value: unknown): value is string {
37  return typeof value === 'string'
38}
39
40export function isPicture(value: unknown): value is Picture {
41  if (typeof value !== 'object' || value === null) return false
42  const p = value as Partial<Picture>
43  const strings = [p.alt, p.path, p.name, p.caption, p.open, p.key, p.source?.png]
44  return strings.every(isText) && Number.isInteger(p.columns) && Number.isInteger(p.rows)
45}
46
47async function asked($: EngineInterface, path: string, asking: Asking): Promise<Picture | undefined> {
48  try {
49    const answer = await $.peek.picture({ path, ...asking })
50    return isPicture(answer) ? answer : undefined
51  } catch {
52    return undefined
53  }
54}
55
56export async function picturesOf($: EngineInterface, sources: string[], asking: Asking): Promise<Map<string, Picture>> {
57  const answers = await Promise.all(sources.map(async src => [src, await asked($, src, asking)] as const))
58  return new Map(answers.flatMap(([src, picture]) => (picture === undefined ? [] : [[src, picture] as const])))
59}
60
61function trimmed(spans: Span[], side: 'start' | 'end'): Span[] {
62  const edge = side === 'start' ? 0 : spans.length - 1
63  const span = spans[edge]
64  if (span === undefined || span.kind === 'image') return spans
65  const text = side === 'start' ? span.text.trimStart() : span.text.trimEnd()
66  const rest = side === 'start' ? spans.slice(1) : spans.slice(0, -1)
67  if (text === '') return trimmed(rest, side)
68  return side === 'start' ? [{ ...span, text }, ...rest] : [...rest, { ...span, text }]
69}
70
71function paragraphOf(spans: Span[], hang: number | undefined): Block[] {
72  const kept = trimmed(trimmed(spans, 'start'), 'end')
73  if (kept.length === 0) return []
74  return [hang === undefined ? { kind: 'paragraph', spans: kept } : { kind: 'paragraph', spans: kept, hang }]
75}
76
77function splitParagraph(b: Extract<Block, { kind: 'paragraph' }>, found: ReadonlyMap<string, Picture>): (Block | Placed)[] {
78  const laid: (Block | Placed)[] = []
79  let run: Span[] = []
80  for (const span of b.spans) {
81    const picture = span.kind === 'image' && span.src !== undefined ? found.get(span.src) : undefined
82    if (picture === undefined) run.push(span)
83    else {
84      laid.push(...paragraphOf(run, b.hang), { kind: 'picture', picture })
85      run = []
86    }
87  }
88  return [...laid, ...paragraphOf(run, b.hang)]
89}
90
91export function laidOut(blocks: Block[], found: ReadonlyMap<string, Picture>): (Block | Placed)[] {
92  if (found.size === 0) return blocks
93  return blocks.flatMap(b => (b.kind === 'paragraph' && localSources(b.spans).some(src => found.has(src)) ? splitParagraph(b, found) : [b]))
94}
95
96function openButton(kit: Kit, palette: Palette, picture: Picture, key: string, open: () => void): RenderNode {
97  return (
98    <kit.Button key={`lucid-open-${key}`} plain label={picture.open} onPress={open}>
99      <kit.Text color={palette.muted} hover={{ color: palette.text, underline: true }}>
100        {picture.open}
101      </kit.Text>
102    </kit.Button>
103  )
104}
105
106export function thumbnail(kit: Kit, palette: Palette, picture: Picture, key: string, open: () => void): RenderNode {
107  return (
108    <kit.Box key={`picture-${key}`} flexDirection="row">
109      <kit.Image key={key} source={picture.source} columns={picture.columns} rows={picture.rows} alt={picture.alt} />
110      <kit.Box key="side" flexDirection="column" paddingLeft={1} flexShrink={1}>
111        <kit.Text color={palette.muted} wrap="truncate-end">
112          {picture.name}
113        </kit.Text>
114        <kit.Text color={palette.muted}>{picture.caption}</kit.Text>
115        <kit.Box height={1}>{openButton(kit, palette, picture, key, open)}</kit.Box>
116      </kit.Box>
117    </kit.Box>
118  )
119}
120
hooks/theme.ts 43 lines
1import type { EngineInterface } from 'claude-code'
2
3import { toneFor } from './palette'
4import type { Palette, Tone } from './palette'
5
6type ThemeReader = Pick<EngineInterface, 'config' | 'env'>
7
8let resolved: Promise<Tone> | undefined
9
10let drawn: Tone | undefined
11
12async function themeName($: Pick<EngineInterface, 'config'>): Promise<unknown> {
13  const rows = await $.config.list().catch(() => [])
14  return rows.find(row => row.key === 'theme')?.value
15}
16
17async function terminalColours($: Pick<EngineInterface, 'env'>): Promise<string | undefined> {
18  return $.env.get('COLORFGBG').catch(() => undefined)
19}
20
21async function readTone($: ThemeReader): Promise<Tone> {
22  const [theme, colorFgBg] = await Promise.all([themeName($), terminalColours($)])
23  drawn = toneFor(theme, colorFgBg)
24  return drawn
25}
26
27function themeTone($: ThemeReader): Promise<Tone> {
28  resolved ??= readTone($)
29  return resolved
30}
31
32export function forgetTheme(): void {
33  resolved = undefined
34}
35
36export function drawnTone(): Tone | undefined {
37  return drawn
38}
39
40export async function themePalette($: ThemeReader): Promise<Palette> {
41  return (await themeTone($)).palette
42}
43
hooks/tools.ts 881 lines
1import { inlineSpans, shellSpans, spansWidth } from './markdown'
2import type { Span } from './markdown'
3import { ELLIPSIS, cellWidth, clipCells, firstLine, sliceCells } from './width'
4
5export type ToolState = 'pending' | 'running' | 'started' | 'done' | 'failed' | 'rejected' | 'interrupted' | 'cancelled'
6
7export type ToolLine = { label: string; detail: Span[]; note?: string; state?: ToolState }
8
9export type ToolRow = { name: string; detail: Span[]; note?: string; lines?: ToolLine[] }
10
11export type ToolCall = { tool: string; input: unknown; isRunning: boolean; isErrored: boolean; isInterrupted: boolean; output?: unknown; hasRun?: boolean; refused?: boolean }
12
13export type TrackedCall = ToolCall & { taskStatus?: string }
14
15export type Roots = { cwd: string; home?: string }
16
17type Outcome = Pick<ToolCall, 'isRunning' | 'isErrored' | 'isInterrupted' | 'output' | 'hasRun' | 'refused'> & { tool?: string }
18
19type Verb = { done: string; running: string; base: string; gerund: string }
20
21type Tally = { verb: string; base: string; running: string; noun: string; targets: (call: ToolCall, roots: Roots, index: number) => string[]; started?: string }
22
23type Detail = (input: Record<string, unknown>, roots: Roots, output?: unknown) => Span[]
24
25const MAX_DETAIL = 160
26
27const MAX_ARG = 40
28
29const MAX_ARGS = 2
30
31const MAX_TOOL_NAMES = 60
32
33const SEPARATOR = ' · '
34
35const NAME_SEPARATOR = ', '
36
37const WATCH_VERB = /^watch(es|ing)?\s+(?=\S)/i
38
39const ERROR_TAG = /<\/?tool_use_error>/g
40
41const ERROR_PREFIX = /^Error:\s*/
42
43const USER_REJECTION = /^(Error: )?User rejected\b/
44
45const PERMISSION_DENIAL = /^(Error: )?Permission\b.*\b(was|has been) denied\b/
46
47const INTERRUPTION = /^(Error: )?Interrupted\b/
48
49const DIALOG_DECLINE = /^(Error: )?The user doesn't want to proceed with this tool use\b/
50
51const CUT_BEFORE_START = /^(Error: )?(The user doesn't want to take this action right now|Interrupted\b|Cancelled: )/
52
53const TASK_AT_WORK = new Set(['pending', 'running', 'waiting'])
54
55const TASK_END: Record<string, ToolState> = { failed: 'failed', killed: 'interrupted' }
56
57export const TASK_STILL_RUNNING = 'running'
58
59export const TASK_UNTRACKED = 'untracked'
60
61const BACKGROUND_NOTE = 'background'
62
63const NOTIFICATION_STATE: Record<string, ToolState> = { completed: 'done', failed: 'failed', killed: 'interrupted' }
64
65const WAITING_ON_OWN_WORK = /\bwaiting on its own background work\b/i
66
67const DEFAULT_AGENT = 'general-purpose'
68
69const DEFAULT_GREP_MODE = 'files_with_matches'
70
71const TEXT_KEYS = ['subject', 'description', 'query', 'skill', 'prompt']
72
73const SCALARS = new Set(['string', 'number', 'boolean'])
74
75const UNSUCCESSFUL: ReadonlyArray<ToolState> = ['failed', 'rejected', 'interrupted', 'cancelled']
76
77const OUTCOME_ORDER: ReadonlyArray<ToolState> = ['done', 'running', ...UNSUCCESSFUL]
78
79const BRIEF_FROM_KINDS = 3
80
81const ASIDE_WORD: Partial<Record<ToolState, string>> = { running: 'still running', failed: 'failed', rejected: 'denied', interrupted: 'stopped', cancelled: 'cancelled' }
82
83const LSP_TARGET: Record<string, string> = {
84  goToDefinition: 'definition',
85  findReferences: 'references',
86  hover: 'hover info',
87  documentSymbol: 'symbols',
88  workspaceSymbol: 'workspace symbols',
89  goToImplementation: 'implementation',
90  prepareCallHierarchy: 'call hierarchy',
91  incomingCalls: 'incoming calls',
92  outgoingCalls: 'outgoing calls',
93}
94
95function pluralWord(word: string): string {
96  if (/[^aeiou]y$/.test(word)) return word.slice(0, -1) + 'ies'
97  return /(s|sh|ch|x)$/.test(word) ? word + 'es' : word + 's'
98}
99
100export function plural(count: number, word: string): string {
101  return `${count} ${count === 1 ? word : pluralWord(word)}`
102}
103
104function isUnder(path: string, root: string): boolean {
105  return root !== '' && (path === root || path.startsWith(root + '/'))
106}
107
108function trimSlash(path: string | undefined): string {
109  return (path ?? '').replace(/\/+$/, '')
110}
111
112export function relativePath(path: string, roots: Roots): string {
113  const cwd = trimSlash(roots.cwd)
114  const home = trimSlash(roots.home)
115  if (isUnder(path, cwd)) return trimSlash(path) === cwd ? '.' : path.slice(cwd.length + 1)
116  if (isUnder(path, home)) return '~' + path.slice(home.length)
117  return path
118}
119
120export function fileHref(path: string, roots: Roots): string {
121  const absolute = path.startsWith('/') ? path : `${trimSlash(roots.cwd)}/${path}`
122  return 'file://' + absolute.split('/').map(encodeURIComponent).join('/')
123}
124
125function clip(text: string, max = MAX_DETAIL): string {
126  return clipCells(firstLine(text), max)
127}
128
129function str(input: Record<string, unknown>, key: string): string | undefined {
130  const value = input[key]
131  return typeof value === 'string' && value.trim() !== '' ? value : undefined
132}
133
134function num(input: Record<string, unknown>, key: string): number | undefined {
135  const value = input[key]
136  return typeof value === 'number' && Number.isFinite(value) ? value : undefined
137}
138
139function inputOf(raw: unknown): Record<string, unknown> {
140  return (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
141}
142
143function listOf(raw: unknown): Record<string, unknown>[] {
144  return Array.isArray(raw) ? raw.map(inputOf) : []
145}
146
147function filePath(input: Record<string, unknown>): string | undefined {
148  return str(input, 'file_path') ?? str(input, 'notebook_path') ?? str(input, 'path') ?? str(input, 'filePath')
149}
150
151function humanise(name: string): string {
152  return name.replace(/[_-]+/g, ' ').trim()
153}
154
155function withoutServer(tool: string, server: string): string {
156  const words = humanise(tool).split(/\s+/)
157  const rest = words.filter(word => word.toLowerCase() !== server.toLowerCase())
158  return (rest.length > 0 ? rest : words).join(' ')
159}
160
161export function toolName(tool: string): string {
162  const mcp = /^mcp__(.+?)__(.+)$/.exec(tool)
163  if (!mcp) return tool
164  const server = humanise((mcp[1] ?? '').replace(/^plugin_[^_]+_/, '').replace(/^claude_ai_/, ''))
165  return `${server} › ${withoutServer(mcp[2] ?? '', server)}`
166}
167
168function commandNote(command: string): string | undefined {
169  const extra = command.trim().split('\n').length - 1
170  return extra > 0 ? `+${plural(extra, 'line')}` : undefined
171}
172
173function pathSpan(path: string, roots: Roots): Span {
174  return { text: relativePath(path, roots), kind: 'path', href: fileHref(path, roots) }
175}
176
177function muted(text: string): Span {
178  return { text, kind: 'line' }
179}
180
181function plain(text: string): Span {
182  return { text, kind: 'plain' }
183}
184
185function quoted(text: string): Span {
186  return { text: JSON.stringify(text), kind: 'string' }
187}
188
189function lineRange(input: Record<string, unknown>): Span[] {
190  const offset = num(input, 'offset')
191  const limit = num(input, 'limit')
192  const start = offset !== undefined && offset > 1 ? offset : 1
193  if (limit === 1) return [muted(`:${start}`)]
194  if (limit !== undefined && limit > 1) return [muted(`:${start}-${start + limit - 1}`)]
195  return start > 1 ? [muted(` from line ${start}`)] : []
196}
197
198function grepFlag(input: Record<string, unknown>, flag: string): string | undefined {
199  if (input[flag] === true) return flag
200  const count = num(input, flag)
201  return count === undefined ? undefined : `${flag === 'context' ? '-C' : flag} ${count}`
202}
203
204function grepFlags(input: Record<string, unknown>): Span[] {
205  const mode = str(input, 'output_mode')
206  const limit = num(input, 'head_limit')
207  const flags = [
208    mode && mode !== DEFAULT_GREP_MODE ? mode.replace(/_/g, ' ') : undefined,
209    ...['-i', '-n', 'multiline', '-A', '-B', '-C', 'context'].map(flag => grepFlag(input, flag)),
210    limit !== undefined ? `first ${limit}` : undefined,
211  ]
212  return flags.filter((flag): flag is string => flag !== undefined).map(flag => muted(` ${flag}`))
213}
214
215function searchDetail(input: Record<string, unknown>, roots: Roots): Span[] {
216  const path = str(input, 'path')
217  const glob = str(input, 'glob')
218  const type = str(input, 'type')
219  const detail: Span[] = [quoted(str(input, 'pattern') ?? '')]
220  if (path && relativePath(path, roots) !== '.') detail.push(plain(' in '), pathSpan(path, roots))
221  if (glob) detail.push(muted(` glob ${glob}`))
222  if (type) detail.push(muted(` type ${type}`))
223  return [...detail, ...grepFlags(input)]
224}
225
226function shellDetail(command: string): Span[] {
227  const continues = commandNote(command) !== undefined
228  return [...shellSpans(firstLine(command)), ...(continues ? [muted(ELLIPSIS)] : [])]
229}
230
231function lspDetail(input: Record<string, unknown>, roots: Roots): Span[] {
232  const operation = str(input, 'operation') ?? ''
233  const target = plain(`${LSP_TARGET[operation] ?? humanise(operation)} `)
234  const query = str(input, 'query')
235  if (operation === 'workspaceSymbol') return [target, ...(query ? [quoted(query)] : [])]
236  const path = filePath(input)
237  const line = num(input, 'line')
238  return [target, ...(path ? [pathSpan(path, roots)] : []), ...(line !== undefined ? [muted(`:${line}`)] : [])]
239}
240
241function todoDetail(input: Record<string, unknown>): Span[] {
242  const todos = listOf(input.todos)
243  if (todos.length === 0) return []
244  const finished = todos.filter(todo => todo.status === 'completed').length
245  return [plain(plural(todos.length, 'todo')), muted(`${SEPARATOR}${finished} done`)]
246}
247
248function questionDetail(input: Record<string, unknown>): Span[] {
249  const [first] = listOf(input.questions)
250  const question = first ? str(first, 'question') : undefined
251  return question ? inlineSpans(clip(question)) : []
252}
253
254function planDetail(input: Record<string, unknown>): Span[] {
255  const plan = str(input, 'plan')
256  return plan ? inlineSpans(clip(plan).replace(/^#+\s*/, '')) : []
257}
258
259export function loadedTools(tool: string | undefined, output: unknown): string[] | undefined {
260  const matches = inputOf(output).matches
261  if (tool !== 'ToolSearch' || !Array.isArray(matches)) return undefined
262  return matches.filter((name): name is string => typeof name === 'string' && name.trim() !== '')
263}
264
265function namesWithin(names: ReadonlyArray<string>, budget: number): string[] {
266  const kept: string[] = []
267  for (const name of names) {
268    if (kept.length > 0 && cellWidth([...kept, name].join(NAME_SEPARATOR)) > budget) break
269    kept.push(name)
270  }
271  return kept
272}
273
274function toolNames(names: ReadonlyArray<string>): Span[] {
275  const all = names.map(name => toolName(name.trim())).filter(name => name !== '')
276  const shown = namesWithin(all, MAX_TOOL_NAMES)
277  if (shown.length === 0) return []
278  const rest = all.length - shown.length
279  return [plain(clip(shown.join(NAME_SEPARATOR))), ...(rest > 0 ? [muted(` +${rest}`)] : [])]
280}
281
282function selectedNames(query: string): string[] | undefined {
283  const selected = /^select:(.*)$/.exec(query)
284  return selected ? (selected[1] ?? '').split(',').map(name => name.trim()).filter(name => name !== '') : undefined
285}
286
287function selectedFrom(loaded: string[], selected: string[] | undefined): string[] {
288  const wanted = new Set((selected ?? []).map(name => name.toLowerCase()))
289  const picked = loaded.filter(name => wanted.has(name.toLowerCase()))
290  return picked.length > 0 ? picked : loaded
291}
292
293function searchedTools(input: Record<string, unknown>, output?: unknown): string[] | undefined {
294  const loaded = loadedTools('ToolSearch', output)
295  const selected = selectedNames(str(input, 'query') ?? '')
296  if (loaded !== undefined && loaded.length > 0) return selectedFrom(loaded, selected)
297  return selected
298}
299
300function toolSearchDetail(input: Record<string, unknown>, _roots: Roots, output?: unknown): Span[] {
301  const named = searchedTools(input, output)
302  if (named !== undefined) return toolNames(named)
303  const query = str(input, 'query') ?? ''
304  return query ? [quoted(query)] : []
305}
306
307function cronDetail(input: Record<string, unknown>): Span[] {
308  const cron = str(input, 'cron')
309  const prompt = str(input, 'prompt')
310  return [...(cron ? [{ text: cron, kind: 'code' as const }] : []), ...(prompt ? [plain(' '), ...inlineSpans(clip(prompt))] : [])]
311}
312
313function monitorDetail(input: Record<string, unknown>): Span[] {
314  const command = str(input, 'command')
315  if (command) return shellDetail(command)
316  const watched = str(input, 'description')?.replace(WATCH_VERB, '') ?? str(inputOf(input.ws), 'url')
317  return watched ? inlineSpans(clip(decapitalise(watched))) : []
318}
319
320function idDetail(...keys: string[]): Detail {
321  return input => {
322    const id = keys.map(key => str(input, key)).find(value => value !== undefined)
323    return id ? [muted(id)] : []
324  }
325}
326
327function nothing(): Span[] {
328  return []
329}
330
331function quotedDetail(key: string): Detail {
332  return input => {
333    const value = str(input, key)
334    return value ? [quoted(clip(value))] : []
335  }
336}
337
338function agentDetail(input: Record<string, unknown>): Span[] {
339  const description = str(input, 'description')
340  const prompt = str(input, 'prompt')
341  if (description) return [quoted(clip(description))]
342  return prompt ? inlineSpans(clip(prompt)) : []
343}
344
345const DETAIL: Record<string, Detail> = {
346  Grep: searchDetail,
347  Glob: searchDetail,
348  LSP: lspDetail,
349  TodoWrite: todoDetail,
350  AskUserQuestion: questionDetail,
351  ExitPlanMode: planDetail,
352  EnterPlanMode: nothing,
353  ToolSearch: toolSearchDetail,
354  Monitor: monitorDetail,
355  CronCreate: cronDetail,
356  CronDelete: idDetail('id'),
357  CronList: nothing,
358  TaskStop: idDetail('task_id', 'shell_id'),
359  KillShell: idDetail('shell_id', 'task_id'),
360  WebSearch: quotedDetail('query'),
361  Agent: agentDetail,
362}
363
364function scalarArgs(input: Record<string, unknown>): Span[] {
365  const scalars = Object.entries(input).filter(([, value]) => SCALARS.has(typeof value) && String(value).trim() !== '')
366  return scalars.slice(0, MAX_ARGS).flatMap(([key, value], i) => [...(i > 0 ? [muted(SEPARATOR)] : []), muted(`${key}: `), plain(clip(String(value), MAX_ARG))])
367}
368
369function textOf(input: Record<string, unknown>): string | undefined {
370  return TEXT_KEYS.map(key => str(input, key)).find(text => text !== undefined) ?? str(input, 'command')
371}
372
373export function summarise(tool: string, raw: unknown, roots: Roots, output?: unknown): ToolRow {
374  const input = inputOf(raw)
375  const name = toolName(tool)
376  const path = filePath(input)
377  const command = str(input, 'command')
378  const detail = DETAIL[tool]
379
380  if (tool === 'Bash' && command) return { name, detail: shellDetail(command), note: commandNote(command) }
381
382  if (detail) return { name, detail: detail(input, roots, output) }
383
384  if (path) {
385    const note = input.replace_all === true ? 'all occurrences' : undefined
386    return { name, detail: [pathSpan(path, roots), ...(tool === 'Read' ? lineRange(input) : [])], note }
387  }
388
389  const url = str(input, 'url')
390  if (url) return { name, detail: [{ text: clip(url), kind: 'url' }] }
391
392  const text = textOf(input)
393  return { name, detail: text ? inlineSpans(clip(text)) : scalarArgs(input) }
394}
395
396function outputLine(output: unknown): string {
397  return typeof output === 'string' ? firstLine(output) : ''
398}
399
400export function backgroundTaskId(tool: string, output: unknown): string | undefined {
401  const record = inputOf(output)
402  if (tool === 'Agent') return record.status === 'async_launched' ? str(record, 'agentId') : undefined
403  if (tool === 'Bash') return str(record, 'backgroundTaskId')
404  if (tool === 'Monitor') return str(record, 'taskId')
405  return undefined
406}
407
408export function stoppedTaskId(call: Pick<ToolCall, 'tool' | 'input' | 'output'>): string | undefined {
409  if (call.tool !== 'TaskStop' && call.tool !== 'KillShell') return undefined
410  const input = inputOf(call.input)
411  return str(inputOf(call.output), 'task_id') ?? str(input, 'task_id') ?? str(input, 'shell_id')
412}
413
414export function createdContent(tool: string, output: unknown): string | undefined {
415  const record = inputOf(output)
416  if (tool !== 'Write' || record.type !== 'create' || record.staged === true) return undefined
417  return typeof record.content === 'string' ? record.content : undefined
418}
419
420function loadedNothing(call: Outcome): boolean {
421  return loadedTools(call.tool, call.output)?.length === 0
422}
423
424function isRejection(line: string): boolean {
425  return USER_REJECTION.test(line) || PERMISSION_DENIAL.test(line)
426}
427
428export type CallEnd = 'ran' | 'declined'
429
430export function callEnd(answer: { result?: unknown; isError?: boolean }): CallEnd | undefined {
431  if (answer.isError !== true) return undefined
432  const line = outputLine(answer.result)
433  if (DIALOG_DECLINE.test(line) || isRejection(line)) return 'declined'
434  return CUT_BEFORE_START.test(line) ? undefined : 'ran'
435}
436
437function rejectionState(call: Outcome, line: string): ToolState {
438  if (call.hasRun === true) return 'interrupted'
439  return call.refused === true || PERMISSION_DENIAL.test(line) ? 'rejected' : 'cancelled'
440}
441
442export function stateOf(call: Outcome, taskStatus?: string): ToolState {
443  const line = outputLine(call.output)
444  if ((call.isErrored || call.isInterrupted) && isRejection(line)) return rejectionState(call, line)
445  if (call.isInterrupted) return 'interrupted'
446  if (call.isErrored || loadedNothing(call)) return 'failed'
447  if (call.isRunning) return 'running'
448  if (call.output === undefined) return 'pending'
449  if (taskStatus === TASK_UNTRACKED) return 'started'
450  if (taskStatus !== undefined && TASK_AT_WORK.has(taskStatus)) return 'running'
451  return (taskStatus !== undefined && TASK_END[taskStatus]) || 'done'
452}
453
454export function unsuccessfulLines(calls: ReadonlyArray<TrackedCall>, roots: Roots): ToolLine[] {
455  return calls
456    .filter(call => UNSUCCESSFUL.includes(trackedState(call)))
457    .map(call => {
458      const row = describeCall(call, roots, call.taskStatus)
459      return { label: row.name, detail: row.detail, note: row.note, state: trackedState(call) }
460    })
461}
462
463export function unsuccessfulState(calls: ReadonlyArray<Outcome & { taskStatus?: string }>): ToolState | undefined {
464  const states = new Set(calls.map(call => stateOf(call, call.taskStatus)))
465  return UNSUCCESSFUL.find(state => states.has(state))
466}
467
468function verb(done: string, running: string, base: string): Verb {
469  return { done, running, base, gerund: running.charAt(0).toLowerCase() + running.slice(1) }
470}
471
472const VERBS: Record<string, Verb> = {
473  Bash: verb('Ran', 'Running', 'run'),
474  Read: verb('Read', 'Reading', 'read'),
475  Edit: verb('Edited', 'Editing', 'edit'),
476  MultiEdit: verb('Edited', 'Editing', 'edit'),
477  NotebookEdit: verb('Edited', 'Editing', 'edit'),
478  Write: verb('Wrote', 'Writing', 'write'),
479  Grep: verb('Searched', 'Searching', 'search'),
480  Glob: verb('Listed', 'Listing', 'list'),
481  WebFetch: verb('Fetched', 'Fetching', 'fetch'),
482  WebSearch: verb('Searched web', 'Searching web', 'search web'),
483  Skill: verb('Loaded skill', 'Loading skill', 'load skill'),
484  ToolSearch: verb('Loaded tools', 'Loading tools', 'load tools'),
485  TaskCreate: verb('Added task', 'Adding task', 'add task'),
486  TaskUpdate: verb('Updated task', 'Updating task', 'update task'),
487  TodoWrite: verb('Updated todos', 'Updating todos', 'update todos'),
488  SendMessage: verb('Sent message', 'Sending message', 'send message'),
489  AskUserQuestion: verb('Asked', 'Asking', 'ask'),
490  Monitor: verb('Watched', 'Watching', 'watch'),
491  LSP: verb('Looked up', 'Looking up', 'look up'),
492  ExitPlanMode: verb('Proposed plan', 'Proposing plan', 'propose plan'),
493  EnterPlanMode: verb('Entered plan mode', 'Entering plan mode', 'enter plan mode'),
494  CronCreate: verb('Scheduled', 'Scheduling', 'schedule'),
495  CronDelete: verb('Unscheduled', 'Unscheduling', 'unschedule'),
496  CronList: verb('Listed schedules', 'Listing schedules', 'list schedules'),
497  TaskStop: verb('Stopped task', 'Stopping task', 'stop task'),
498  KillShell: verb('Stopped shell', 'Stopping shell', 'stop shell'),
499}
500
501function namedAgent(input: unknown): string | undefined {
502  const type = str(inputOf(input), 'subagent_type')
503  return type === DEFAULT_AGENT ? undefined : type
504}
505
506function agentVerb(input: unknown): Verb {
507  const type = namedAgent(input)
508  if (type === undefined) return verb('Delegated', 'Delegating', 'delegate')
509  return verb(`Delegated to ${type}`, `Delegating to ${type}`, `delegate to ${type}`)
510}
511
512function fallbackVerb(tool: string): Verb {
513  const name = toolName(tool)
514  if (/^mcp__/.test(tool)) return verb(`Called ${name}`, `Calling ${name}`, `call ${name}`)
515  return { done: name, running: name, base: `use ${name}`, gerund: `using ${name}` }
516}
517
518const ONE_TOOL = verb('Loaded', 'Loading', 'load')
519
520function toolSearchVerb(input: unknown, output: unknown): Verb {
521  const named = searchedTools(inputOf(input), output)
522  return named?.length === 1 ? ONE_TOOL : VERBS.ToolSearch
523}
524
525function verbOf(tool: string, raw?: unknown, output?: unknown): Verb {
526  if (tool === 'Agent') return agentVerb(raw)
527  if (tool === 'ToolSearch') return toolSearchVerb(raw, output)
528  return VERBS[tool] ?? fallbackVerb(tool)
529}
530
531function capitalise(text: string): string {
532  return text.charAt(0).toUpperCase() + text.slice(1)
533}
534
535const PHRASE: Record<ToolState, (verb: Verb) => string> = {
536  pending: v => `Wants to ${v.base}`,
537  running: v => v.running,
538  started: v => `Started ${v.gerund}`,
539  done: v => v.done,
540  failed: v => `Couldn't ${v.base}`,
541  rejected: v => `Not allowed to ${v.base}`,
542  interrupted: v => `Stopped ${v.gerund}`,
543  cancelled: v => `Cancelled ${v.gerund}`,
544}
545
546export function verbFor(tool: string, state: ToolState, input?: unknown, output?: unknown): string {
547  return PHRASE[state](verbOf(tool, input, output))
548}
549
550function joinNotes(...notes: Array<string | undefined>): string | undefined {
551  const present = notes.filter((note): note is string => note !== undefined)
552  return present.length > 0 ? present.join(SEPARATOR) : undefined
553}
554
555function backgroundNote(call: ToolCall, state: ToolState): string | undefined {
556  if (backgroundTaskId(call.tool, call.output) === undefined) return undefined
557  return state === 'running' || state === 'started' ? BACKGROUND_NOTE : undefined
558}
559
560export function describeCall(call: ToolCall, roots: Roots, taskStatus?: string): ToolRow {
561  const row = summarise(call.tool, call.input, roots, call.output)
562  const state = stateOf(call, taskStatus)
563  return { ...row, name: verbFor(call.tool, state, call.input, call.output), note: joinNotes(row.note, backgroundNote(call, state)) }
564}
565
566function spansKey(spans: ReadonlyArray<Span>): string {
567  return spans.map(span => span.text).join('')
568}
569
570function distinct<T>(items: ReadonlyArray<T>, key: (item: T) => string): Array<{ item: T; count: number }> {
571  const seen = new Map<string, { item: T; count: number }>()
572  for (const item of items) {
573    const entry = seen.get(key(item))
574    if (entry) entry.count += 1
575    else seen.set(key(item), { item, count: 1 })
576  }
577  return [...seen.values()]
578}
579
580function repeats(count: number, detail: ReadonlyArray<Span>): Span[] {
581  if (count < 2) return []
582  return [muted(`${spansKey(detail) === '' ? '' : ' '}×${count}`)]
583}
584
585function trackedState(call: TrackedCall): ToolState {
586  return stateOf(call, call.taskStatus)
587}
588
589function commandLines(calls: ReadonlyArray<TrackedCall>, roots: Roots): ToolLine[] {
590  const rows = distinct(
591    calls.map(call => ({ call, row: summarise(call.tool, call.input, roots, call.output) })),
592    ({ call, row }) => trackedState(call) + (call.taskStatus ?? '') + spansKey(row.detail) + (row.note ?? ''),
593  )
594  return rows.map(({ item: { call, row }, count }) => ({
595    label: verbFor('Bash', trackedState(call)),
596    detail: [...row.detail, ...repeats(count, row.detail)],
597    note: joinNotes(row.note, backgroundNote(call, trackedState(call))),
598    state: trackedState(call),
599  }))
600}
601
602function targetLine(tool: string, calls: ReadonlyArray<TrackedCall>, roots: Roots): ToolLine {
603  const targets = distinct(
604    calls.map(call => summarise(call.tool, call.input, roots, call.output).detail),
605    spansKey,
606  )
607  const separator = tool === 'ToolSearch' ? plain(NAME_SEPARATOR) : muted(SEPARATOR)
608  const detail = targets.flatMap(({ item, count }, i) => [...(i > 0 ? [separator] : []), ...item, ...repeats(count, item)])
609  const [first] = calls
610  const state = first ? trackedState(first) : 'done'
611  const sample = calls.length === 1 || tool !== 'ToolSearch' ? first : undefined
612  return { label: verbFor(tool, state, sample?.input, sample?.output), detail, note: first ? backgroundNote(first, state) : undefined, state }
613}
614
615function byKey<T>(items: ReadonlyArray<T>, key: (item: T) => string): Map<string, T[]> {
616  const groups = new Map<string, T[]>()
617  for (const item of items) groups.set(key(item), [...(groups.get(key(item)) ?? []), item])
618  return groups
619}
620
621function isAtWork(call: TrackedCall): boolean {
622  return trackedState(call) === 'running'
623}
624
625function lineKey(call: TrackedCall): string {
626  if (call.tool === 'Bash') return call.tool
627  const agent = call.tool === 'Agent' ? `:${str(inputOf(call.input), 'subagent_type') ?? DEFAULT_AGENT}` : ''
628  return `${call.tool}${agent}:${trackedState(call)}:${call.taskStatus ?? ''}`
629}
630
631function linesOfKind(calls: ReadonlyArray<TrackedCall>, roots: Roots): ToolLine[] {
632  return [...byKey(calls, lineKey)].flatMap(([key, own]) => (key === 'Bash' ? commandLines(own, roots) : [targetLine(own[0]?.tool ?? key, own, roots)]))
633}
634
635function groupLines(calls: ReadonlyArray<TrackedCall>, roots: Roots): ToolLine[] {
636  return kindsInOrder(calls).flatMap(([, own]) => linesOfKind(own, roots))
637}
638
639function targetKey(call: ToolCall, roots: Roots): string {
640  return filePath(inputOf(call.input)) ?? spansKey(summarise(call.tool, call.input, roots, call.output).detail)
641}
642
643function byTarget(call: ToolCall, roots: Roots): string[] {
644  return [targetKey(call, roots)]
645}
646
647function bySearch(call: ToolCall, roots: Roots): string[] {
648  return [spansKey(summarise(call.tool, call.input, roots, call.output).detail)]
649}
650
651function byCall(_call: ToolCall, _roots: Roots, index: number): string[] {
652  return [`#${index}`]
653}
654
655function byLoadedTool(call: ToolCall, roots: Roots): string[] {
656  const loaded = loadedTools(call.tool, call.output) ?? []
657  return loaded.length > 0 ? loaded : byTarget(call, roots)
658}
659
660const OTHER: Tally = { verb: 'used', base: 'use', running: 'using', noun: 'tool', targets: byCall }
661
662const TALLY: Record<string, Tally> = {
663  Read: { verb: 'read', base: 'read', running: 'reading', noun: 'file', targets: byTarget },
664  Edit: { verb: 'edited', base: 'edit', running: 'editing', noun: 'file', targets: byTarget },
665  MultiEdit: { verb: 'edited', base: 'edit', running: 'editing', noun: 'file', targets: byTarget },
666  NotebookEdit: { verb: 'edited', base: 'edit', running: 'editing', noun: 'notebook', targets: byTarget },
667  Write: { verb: 'wrote', base: 'write', running: 'writing', noun: 'file', targets: byTarget },
668  Grep: { verb: 'searched for', base: 'search for', running: 'searching for', noun: 'pattern', targets: bySearch },
669  Glob: { verb: 'listed', base: 'list', running: 'listing', noun: 'pattern', targets: bySearch },
670  Bash: { verb: 'ran', base: 'run', running: 'running', noun: 'command', targets: byCall },
671  WebFetch: { verb: 'fetched', base: 'fetch', running: 'fetching', noun: 'page', targets: byTarget },
672  WebSearch: { verb: 'searched web for', base: 'search web for', running: 'searching web for', noun: 'query', targets: byTarget },
673  Monitor: { verb: 'watched', base: 'watch', running: 'watching', noun: 'stream', targets: byCall, started: 'started watching' },
674  Skill: { verb: 'loaded', base: 'load', running: 'loading', noun: 'skill', targets: byTarget },
675  ToolSearch: { verb: 'loaded', base: 'load', running: 'loading', noun: 'tool', targets: byLoadedTool },
676  Agent: { verb: 'delegated', base: 'delegate', running: 'delegating', noun: 'task', targets: byCall },
677}
678
679function mcpServer(tool: string): string | undefined {
680  return /^mcp__/.test(tool) ? toolName(tool).split(' › ')[0] : undefined
681}
682
683function tallyKey(call: TrackedCall): string {
684  if (TALLY[call.tool] || VERBS[call.tool]) return call.tool
685  const server = mcpServer(call.tool)
686  return server === undefined ? 'other' : `mcp:${server}`
687}
688
689function decapitalise(text: string): string {
690  return /^\p{Lu}\p{Ll}/u.test(text) ? text.charAt(0).toLowerCase() + text.slice(1) : text
691}
692
693function tallyOf(key: string, calls: ReadonlyArray<TrackedCall>): Tally {
694  if (key.startsWith('mcp:')) return { verb: 'called', base: 'call', running: 'calling', noun: `${key.slice(4)} tool`, targets: byCall }
695  const kinds = new Set(calls.map(call => call.tool))
696  const [only] = kinds
697  const known = only !== undefined && kinds.size === 1 ? VERBS[only] : undefined
698  return TALLY[key] ?? (known ? { verb: decapitalise(known.done), base: known.base, running: known.gerund, noun: '', targets: byCall } : OTHER)
699}
700
701function isUntracked(call: TrackedCall): boolean {
702  return trackedState(call) === 'started'
703}
704
705function isFailed(call: TrackedCall): boolean {
706  return UNSUCCESSFUL.includes(trackedState(call))
707}
708
709function outcomeOf(call: TrackedCall): ToolState {
710  if (isAtWork(call)) return 'running'
711  return isFailed(call) ? trackedState(call) : 'done'
712}
713
714function better(known: ToolState | undefined, next: ToolState): ToolState {
715  return known !== undefined && OUTCOME_ORDER.indexOf(known) <= OUTCOME_ORDER.indexOf(next) ? known : next
716}
717
718function targetOutcomes(tally: Tally, calls: ReadonlyArray<TrackedCall>, roots: Roots): ToolState[] {
719  const outcomes = new Map<string, ToolState>()
720  calls.forEach((call, index) => {
721    for (const target of tally.targets(call, roots, index)) outcomes.set(target, better(outcomes.get(target), outcomeOf(call)))
722  })
723  return [...outcomes.values()]
724}
725
726function amount(tally: Tally, count: number): string {
727  if (tally.noun !== '') return plural(count, tally.noun)
728  return count > 1 ? `×${count}` : ''
729}
730
731function countOf(outcomes: ReadonlyArray<ToolState>, ...states: ToolState[]): number {
732  return outcomes.filter(outcome => states.includes(outcome)).length
733}
734
735function asides(outcomes: ReadonlyArray<ToolState>): string {
736  const notes = OUTCOME_ORDER.filter(state => state !== 'done')
737    .map(state => ({ word: ASIDE_WORD[state] ?? state, count: countOf(outcomes, state) }))
738    .filter(({ count }) => count > 0)
739    .map(({ word, count }) => `${count} ${word}`)
740  return notes.length > 0 ? ` (${notes.join(', ')})` : ''
741}
742
743function phrase(...words: string[]): string {
744  return words.filter(word => word !== '').join(' ')
745}
746
747function unsettledPhrase(tally: Tally, outcomes: ReadonlyArray<ToolState>): string {
748  const running = countOf(outcomes, 'running')
749  const failed = countOf(outcomes, 'failed')
750  const denied = countOf(outcomes, 'rejected')
751  const stopped = countOf(outcomes, 'interrupted')
752  const cancelled = countOf(outcomes, 'cancelled')
753  return [
754    running > 0 ? phrase(tally.running, amount(tally, running)) : '',
755    failed > 0 ? phrase(`couldn't ${tally.base}`, amount(tally, failed)) : '',
756    denied > 0 ? phrase(`not allowed to ${tally.base}`, amount(tally, denied)) : '',
757    stopped > 0 ? phrase(`stopped ${tally.running}`, amount(tally, stopped)) : '',
758    cancelled > 0 ? phrase(`cancelled ${tally.running}`, amount(tally, cancelled)) : '',
759  ]
760    .filter(part => part !== '')
761    .join(', ')
762}
763
764function partialPhrase(tally: Tally, outcomes: ReadonlyArray<ToolState>, brief: boolean): string {
765  const total = outcomes.length
766  const counted = tally.noun === '' ? phrase(tally.verb, amount(tally, total)) : `${tally.verb} ${countOf(outcomes, 'done')} of ${plural(total, tally.noun)}`
767  return brief ? counted : counted + asides(outcomes)
768}
769
770function settledPhrase(tally: Tally, outcomes: ReadonlyArray<ToolState>, brief: boolean): string {
771  const well = countOf(outcomes, 'done')
772  if (well === outcomes.length) return phrase(tally.verb, amount(tally, well))
773  return well === 0 ? unsettledPhrase(tally, outcomes) : partialPhrase(tally, outcomes, brief)
774}
775
776function startedPhrase(tally: Tally, calls: ReadonlyArray<TrackedCall>, roots: Roots): string {
777  return phrase(tally.started ?? 'started', amount(tally, targetOutcomes(tally, calls, roots).length))
778}
779
780function kindPhrases(tally: Tally, calls: ReadonlyArray<TrackedCall>, roots: Roots, brief: boolean): string[] {
781  const untracked = calls.filter(isUntracked)
782  const tracked = calls.filter(call => !isUntracked(call))
783  const settled = tracked.length > 0 ? [settledPhrase(tally, targetOutcomes(tally, tracked, roots), brief)] : []
784  const started = untracked.length > 0 ? [startedPhrase(tally, untracked, roots)] : []
785  return [...settled, ...started]
786}
787
788function kindRank(calls: ReadonlyArray<TrackedCall>): number {
789  if (calls.some(isAtWork)) return 0
790  return calls.every(isFailed) ? 2 : 1
791}
792
793function kindsInOrder(calls: ReadonlyArray<TrackedCall>): Array<[string, TrackedCall[]]> {
794  return [...byKey(calls, tallyKey)].sort(([, a], [, b]) => kindRank(a) - kindRank(b))
795}
796
797export function headline(calls: ReadonlyArray<TrackedCall>, roots: Roots): string {
798  const kinds = kindsInOrder(calls)
799  const brief = kinds.length >= BRIEF_FROM_KINDS
800  return capitalise(kinds.flatMap(([key, own]) => kindPhrases(tallyOf(key, own), own, roots, brief)).join(', '))
801}
802
803function groupOutcomes(calls: ReadonlyArray<TrackedCall>, roots: Roots): ToolState[] {
804  const tracked = calls.filter(call => !isUntracked(call))
805  return [...byKey(tracked, tallyKey)].flatMap(([key, own]) => targetOutcomes(tallyOf(key, own), own, roots))
806}
807
808function settledState(calls: ReadonlyArray<TrackedCall>): ToolState {
809  if (calls.some(isAtWork)) return 'running'
810  return calls.some(isUntracked) ? 'started' : 'done'
811}
812
813export function failureOf(output: unknown): string[] | undefined {
814  const line = outputLine(output)
815  if (isRejection(line) || INTERRUPTION.test(line)) return undefined
816  const reason = errorReason(output)
817  return reason.length > 0 ? reason : undefined
818}
819
820function errorReason(output: unknown): string[] {
821  const text = typeof output === 'string' ? output.replace(ERROR_TAG, '') : ''
822  const lines = text.split('\n').map(line => line.trimEnd()).filter(line => line.trim() !== '')
823  const [first, ...rest] = lines
824  return first === undefined ? [] : [first.trim().replace(ERROR_PREFIX, ''), ...rest]
825}
826
827export function summariseGroup(calls: ReadonlyArray<TrackedCall>, roots: Roots): { row: ToolRow; state: ToolState } {
828  const [only] = calls
829  if (calls.length === 1 && only) return { row: describeCall(only, roots, only.taskStatus), state: trackedState(only) }
830  const outcomes = groupOutcomes(calls, roots)
831  const state = UNSUCCESSFUL.find(outcome => outcomes.includes(outcome)) ?? settledState(calls)
832  return { row: { name: headline(calls, roots), detail: [], lines: groupLines(calls, roots) }, state }
833}
834
835function takeSpans(spans: ReadonlyArray<Span>, budget: number, fromEnd: boolean): Span[] {
836  const ordered = fromEnd ? [...spans].reverse() : [...spans]
837  const kept: Span[] = []
838  let left = budget
839  for (const span of ordered) {
840    if (left <= 0) break
841    const text = sliceCells(span.text, left, fromEnd)
842    if (text !== '') kept.push({ ...span, text })
843    left -= cellWidth(text)
844    if (text !== span.text) break
845  }
846  return fromEnd ? kept.reverse() : kept
847}
848
849export function fitMiddle(spans: ReadonlyArray<Span>, width: number): Span[] {
850  if (spansWidth([...spans]) <= width) return [...spans]
851  if (width < 1) return []
852  const room = width - 1
853  const head = Math.ceil(room / 2)
854  return [...takeSpans(spans, head, false), muted(ELLIPSIS), ...takeSpans(spans, room - head, true)]
855}
856
857export function durationText(ms: number | undefined): string | undefined {
858  if (ms === undefined || !Number.isFinite(ms) || ms < 0) return undefined
859  const seconds = Math.round(ms / 1000)
860  if (seconds < 60) return `${seconds}s`
861  const minutes = Math.floor(seconds / 60)
862  if (minutes < 60) return seconds % 60 === 0 ? `${minutes}m` : `${minutes}m ${seconds % 60}s`
863  return minutes % 60 === 0 ? `${Math.floor(minutes / 60)}h` : `${Math.floor(minutes / 60)}h ${minutes % 60}m`
864}
865
866export function isInterimNotice(text: string): boolean {
867  return WAITING_ON_OWN_WORK.test(firstLine(text))
868}
869
870function noticeState(text: string, status: string | undefined): ToolState | undefined {
871  const state = NOTIFICATION_STATE[status ?? '']
872  return state === 'done' && isInterimNotice(text) ? 'started' : state
873}
874
875export function notificationRow(text: string, task: { status?: string; durationMs?: number }): { row: ToolRow; state: ToolState } | undefined {
876  const name = firstLine(text)
877  const state = noticeState(text, task.status)
878  if (name === '' || state === undefined) return undefined
879  return { row: { name, detail: [], note: durationText(task.durationMs) }, state }
880}
881
hooks/code.tsx 283 lines
1import type { RenderNode } from 'claude-code'
2
3import { highlightFrom } from './highlight'
4import type { CodeRole, HighlightState } from './highlight'
5import { shellSpans } from './markdown'
6import type { Palette } from './palette'
7import { appendRun, drawRuns, spanRuns } from './runs'
8import type { Draw, Look, Run } from './runs'
9import { cellWidth, sliceCells } from './width'
10
11const SHELL_LANGUAGES = new Set(['sh', 'bash', 'zsh', 'shell', 'console', 'terminal'])
12
13const DIFF_LANGUAGES = new Set(['diff', 'patch'])
14
15const SHELL_PROMPT = /^(\s*\$\s)/
16
17const CONTINUATION_MARK = '↪'
18
19const CONTINUATION_GUTTER = 2
20
21const TAB = /\t/g
22
23const TAB_STOP = 8
24
25const TAB_CELLS = ' '.repeat(TAB_STOP)
26
27const MARKED_WIDTH = CONTINUATION_GUTTER + 2
28
29type Stretch = { runs: Run[][]; wraps: boolean }
30
31type Prepared = { language: string; palette: Palette; lines: string[]; runs: Run[][]; cells: number[]; states: HighlightState[] }
32
33const PREPARED_KEPT = 4
34
35const STRETCH_LINES = 64
36
37let prepared: Prepared[] = []
38
39const drawnStretches = new WeakMap<Run[], Map<string, { stretch: Stretch; node: RenderNode }>>()
40
41function codeLook(role: CodeRole, palette: Palette): Look {
42  if (role === 'keyword') return { bold: true }
43  if (role === 'comment') return { color: palette.muted, italic: true }
44  return {}
45}
46
47function diffLook(line: string, palette: Palette): Look {
48  if (line.startsWith('+++') || line.startsWith('---')) return { bold: true }
49  if (line.startsWith('+')) return { color: palette.added }
50  if (line.startsWith('-')) return { color: palette.removed }
51  if (line.startsWith('@@') || line.startsWith('\\')) return { color: palette.muted }
52  return {}
53}
54
55function shellRuns(line: string, palette: Palette): Run[] {
56  const prompt = SHELL_PROMPT.exec(line)?.[1] ?? ''
57  return spanRuns(shellSpans(line.slice(prompt.length)), palette, prompt === '' ? [] : [{ text: prompt, look: { color: palette.muted } }])
58}
59
60function linewiseRuns(language: string, palette: Palette): ((line: string) => Run[]) | undefined {
61  if (DIFF_LANGUAGES.has(language)) return line => [{ text: line, look: diffLook(line, palette) }]
62  if (SHELL_LANGUAGES.has(language)) return line => shellRuns(line, palette)
63  return undefined
64}
65
66function sharedLines(a: string[], b: string[]): number {
67  const limit = Math.min(a.length, b.length)
68  let i = 0
69  while (i < limit && a[i] === b[i]) i++
70  return i
71}
72
73function closestPrepared(language: string, palette: Palette, lines: string[]): { kept: Prepared | undefined; shared: number } {
74  return prepared
75    .filter(entry => entry.language === language && entry.palette === palette)
76    .reduce<{ kept: Prepared | undefined; shared: number }>((best, entry) => {
77      const shared = sharedLines(entry.lines, lines)
78      return shared > best.shared ? { kept: entry, shared } : best
79    }, { kept: undefined, shared: 0 })
80}
81
82function freshLines(language: string, lines: string[], palette: Palette, carried: HighlightState): Pick<Prepared, 'runs' | 'states'> {
83  const linewise = linewiseRuns(language, palette)
84  if (lines.join('') === '' || linewise) return { runs: lines.map(line => (line === '' ? [] : (linewise?.(line) ?? []))), states: lines.map(() => carried) }
85  const { tokens, states } = highlightFrom(language, lines, carried)
86  return { runs: tokens.map(line => line.map(token => ({ text: token.text, look: codeLook(token.role, palette) }))), states }
87}
88
89function preparedLines(language: string, lines: string[], palette: Palette): Prepared {
90  const { kept, shared } = closestPrepared(language, palette, lines)
91  if (kept && shared === lines.length && kept.lines.length === lines.length) return kept
92  const tail = lines.slice(shared)
93  const fresh = freshLines(language, tail, palette, kept?.states[shared - 1])
94  const entry: Prepared = {
95    language,
96    palette,
97    lines,
98    runs: [...(kept?.runs.slice(0, shared) ?? []), ...fresh.runs],
99    cells: [...(kept?.cells.slice(0, shared) ?? []), ...tail.map(lineCells)],
100    states: [...(kept?.states.slice(0, shared) ?? []), ...fresh.states],
101  }
102  prepared = [entry, ...prepared.filter(other => other !== kept)].slice(0, PREPARED_KEPT)
103  return entry
104}
105
106function joinedRuns(lines: Run[][]): Run[] {
107  const runs: Run[] = []
108  lines.forEach((line, i) => {
109    if (i > 0) appendRun(runs, '\n', {})
110    for (const run of line) appendRun(runs, run.text, run.look)
111  })
112  return runs
113}
114
115export function codeLines(source: string): string[] {
116  return source.replace(/\n+$/, '').split('\n')
117}
118
119function lineCells(line: string): number {
120  return cellWidth(line.replace(TAB, TAB_CELLS))
121}
122
123function wrapsAt(cells: number, width: number): boolean {
124  return width >= MARKED_WIDTH && cells > width
125}
126
127export function wrapsInCode(line: string, width: number): boolean {
128  return wrapsAt(lineCells(line), width)
129}
130
131function stretches(cells: number[], runs: Run[][], width: number): Stretch[] {
132  const found: Stretch[] = []
133  cells.forEach((cellCount, i) => {
134    const wraps = wrapsAt(cellCount, width)
135    const last = found.at(-1)
136    if (last && !wraps && !last.wraps && last.runs.length < STRETCH_LINES) last.runs.push(runs[i] ?? [])
137    else found.push({ runs: [runs[i] ?? []], wraps })
138  })
139  return found
140}
141
142function continuationMarks(cells: number, room: number): string {
143  const rows = Math.ceil((2 * cells) / room) + 1
144  return Array.from({ length: rows }, () => CONTINUATION_MARK).join('\n')
145}
146
147function cutRuns(runs: Run[], at: number): [Run[], Run[]] {
148  const head: Run[] = []
149  const rest: Run[] = []
150  let seen = 0
151  for (const run of runs) {
152    const take = Math.max(0, Math.min(run.text.length, at - seen))
153    if (take > 0) head.push({ text: run.text.slice(0, take), look: run.look })
154    if (take < run.text.length) rest.push({ text: run.text.slice(take), look: run.look })
155    seen += run.text.length
156  }
157  return [head, rest]
158}
159
160type Head = { head: Run[]; rest: Run[]; cells: number }
161
162function glyphCells(glyph: string, column: number): number {
163  return glyph === '\t' ? TAB_STOP - (column % TAB_STOP) : cellWidth(glyph)
164}
165
166const GRAPHEMES = new Intl.Segmenter('en', { granularity: 'grapheme' })
167
168function graphemes(text: string): string[] {
169  return Array.from(GRAPHEMES.segment(text), ({ segment }) => segment)
170}
171
172function headGlyphs(text: string): { taken: number; cells: number; spill: number } | undefined {
173  let taken = 0
174  let cells = 0
175  for (const glyph of graphemes(text)) {
176    if (cells >= CONTINUATION_GUTTER) break
177    const width = glyphCells(glyph, cells)
178    if (width === 0) return undefined
179    taken += glyph.length
180    cells += width
181  }
182  if (cells < CONTINUATION_GUTTER) return undefined
183  const spill = text[taken - 1] === '\t' ? cells - CONTINUATION_GUTTER : 0
184  return { taken, cells: cells - spill, spill }
185}
186
187function expandedTabs(runs: Run[]): Run[] {
188  let column = 0
189  return runs.map(run => {
190    const text = graphemes(run.text)
191      .map(glyph => {
192        const width = glyphCells(glyph, column)
193        column += width
194        return glyph === '\t' ? ' '.repeat(width) : glyph
195      })
196      .join('')
197    return { text, look: run.look }
198  })
199}
200
201function lineHead(runs: Run[]): Head | undefined {
202  const found = headGlyphs(runs.map(run => run.text).join(''))
203  if (found === undefined) return undefined
204  const [head, rest] = cutRuns(runs, found.taken)
205  const expanded = expandedTabs(head)
206  const shown = cutRuns(expanded, sliceCells(expanded.map(run => run.text).join(''), found.cells).length)[0]
207  return { head: shown, rest: found.spill > 0 ? [{ text: ' '.repeat(found.spill), look: {} }, ...rest] : rest, cells: found.cells }
208}
209
210function sourceText(draw: Draw, runs: Run[]): RenderNode {
211  return <draw.kit.Text color={draw.palette.text}>{drawRuns(draw, runs)}</draw.kit.Text>
212}
213
214function wrappedLine(draw: Draw, runs: Run[], width: number): RenderNode {
215  const { kit, palette } = draw
216  const split = lineHead(runs)
217  if (split === undefined) return sourceText(draw, runs)
218  const { head, rest, cells: gutter } = split
219  const room = width - gutter
220  const cells = lineCells(rest.map(run => run.text).join(''))
221  return (
222    <kit.Box flexDirection="column" width={width} paddingLeft={gutter}>
223      {sourceText(draw, rest)}
224      <kit.Box position="absolute" left={0} top={0} width={gutter} height={1}>
225        {sourceText(draw, head)}
226      </kit.Box>
227      <kit.Box position="absolute" left={0} top={1} bottom={0} width={1} overflow="hidden">
228        <kit.Text color={palette.faint}>{continuationMarks(cells, room)}</kit.Text>
229      </kit.Box>
230    </kit.Box>
231  )
232}
233
234function freshStretchNode(draw: Draw, stretch: Stretch, width: number): RenderNode {
235  const [first = []] = stretch.runs
236  if (stretch.wraps) return wrappedLine(draw, first, width)
237  const runs = joinedRuns(stretch.runs)
238  return <draw.kit.Text color={draw.palette.text}>{runs.length === 0 ? ' ' : drawRuns(draw, runs)}</draw.kit.Text>
239}
240
241function sameStretch(a: Stretch, b: Stretch): boolean {
242  return a.wraps === b.wraps && a.runs.length === b.runs.length && a.runs.every((runs, i) => runs === b.runs[i])
243}
244
245function stretchNode(draw: Draw, stretch: Stretch, width: number): RenderNode {
246  const [first] = stretch.runs
247  if (first === undefined) return freshStretchNode(draw, stretch, width)
248  const key = `${width}|${draw.palette.text}`
249  const drawn = drawnStretches.get(first) ?? new Map<string, { stretch: Stretch; node: RenderNode }>()
250  drawnStretches.set(first, drawn)
251  const cached = drawn.get(key)
252  if (cached && sameStretch(cached.stretch, stretch)) return cached.node
253  const node = freshStretchNode(draw, stretch, width)
254  drawn.set(key, { stretch, node })
255  return node
256}
257
258function label(draw: Draw, language: string, cue: string): RenderNode {
259  const { Box, Text } = draw.kit
260  const bars = cue.trimEnd()
261  return (
262    <Box paddingLeft={bars === '' ? 0 : cellWidth(bars) + 1}>
263      <Text color={draw.palette.muted}>{language}</Text>
264      {bars === '' ? null : (
265        <Box position="absolute" left={0} top={0}>
266          <Text color={draw.palette.faint}>{bars}</Text>
267        </Box>
268      )}
269    </Box>
270  )
271}
272
273export function code(draw: Draw, language: string, source: string, width: number, cue = ''): RenderNode {
274  const { kit, palette } = draw
275  const { runs, cells } = preparedLines(language, codeLines(source), palette)
276  return (
277    <kit.Box flexDirection="column">
278      {language === '' ? null : label(draw, language, cue)}
279      {stretches(cells, runs, width).map(stretch => stretchNode(draw, stretch, width))}
280    </kit.Box>
281  )
282}
283