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

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.

The same turn without lucid, for comparison:

/plugin marketplace add vvojciech/claude-kit
/plugin install lucid@vvojciech
To get the default look back, uninstall it from the /plugin menu.
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.
Ran, Read, Edited, Wrote, Searched, Fetched, Watched or Loaded skill.Wants to run, Wants to write and so on. A failed call reads Couldn't run, Couldn't read and so on.◌, running ◐, started ○, done ●, failed ✗, and ⊘ for stopped, not allowed or cancelled.~, link to the file, and cut in the middle so the file name stays.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 note, until their task ends. They read as started when this session never saw the end.Called server › tool, with the server name humanised.Delegated, or Delegated to <type> for a named agent type./theme and redraws when you change it.COLORFGBG, and falls back to dark.- [ ] and - [x], and done items are greyed.header: value lines.↪ beside each continuation row.:line suffix.[!NOTE], [!TIP], [!IMPORTANT], [!WARNING], [!CAUTION]) get a label.[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.yaml block. Thematic breaks draw as a short faint rule.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.
image in a reply draws as a thumbnail where it sits, with the file name, pixel size and a ⤢ open button beside it.[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.[pasted N lines]. A prompt that is only a paste also shows its first line.# marker and are followed by a blank line, so they paste as their own paragraph.For the markdown source, use copy-that.
** or a backtick show as written until they close, then the span restyles. Claude Code's own renderer does the same.None. lucid starts drawing as soon as it loads, and follows /theme from then on.
N background agents launched row is Claude Code's own. A mod can't reach it./theme, or start Claude Code with COLORFGBG='0;15' set (a background of 7 or 15 reads as light).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.$.peek.picture, which does the reading, converting and sizing.isRunning, isActive, isExpanded), and groups that hold anything other than tool calls (isCallsOnly false).hooks/register.tsx 230 lines1import 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}
230hooks/blocks.tsx 541 lines1import 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}
541hooks/prompt.tsx 61 lines1import 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}
61hooks/runs.tsx 113 lines1import 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}
113hooks/tool-rows.tsx 179 lines1import 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}
179hooks/live.ts 38 lines1const 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}
38hooks/permits.ts 41 lines1export 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}
41hooks/message.ts 285 lines1import 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}
285hooks/pictures.tsx 120 lines1import 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}
120hooks/theme.ts 43 lines1import 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}
43hooks/tools.ts 881 lines1import { 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}
881hooks/code.tsx 283 lines1import 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