Restyles the transcript: prompts and replies as chat bubbles, each finished turn folded to one summary line under its prompt (tools, file changes, skills…

<!-- mod-transcript / README.md, by v-amorim --> <!-- Generated from the root README, plugin.json and showcase.json; edit those. -->
Part of [moonlight-claude-mods][doc-root].
Chat bubbles for you and Claude, each finished turn folded to one line you can open, Bash commands in shell colors.
Inside Claude Code:
/plugin marketplace add v-amorim/moonlight-claude-mods
/plugin install mod-transcript@claude-mods
/reload-plugins
/plugin install asks where to install the mod. Keep Install for you (user scope) and press Enter, then press Esc to leave the plugin screen before /reload-plugins.
To install every mod at once, see [Install][doc-install]. For sharper icons, see [Icons and Nerd Fonts][doc-icons].
Automatic.
| Folded | Open | Copy a reply |
|---|---|---|
| ![][img-transcript-folded] | ![][img-transcript-open] | ![][img-transcript-copy] |
| The colors a reply names |
|---|
| ![][img-transcript-colors] |
| Unfolding a turn |
|---|
| https://github.com/user-attachments/assets/187f612b-2783-4df1-8b77-57d38846460d |
~/.claude/mods-data/mod-transcript/bridge/<session id>.jsonl (under $CLAUDE_CONFIG_DIR when that is set), and the mod reads the file every 250 ms. A record can hold the session id, the working folder and the transcript path, and for Stop-type events the last assistant message. SessionEnd deletes the session's file, and SessionStart deletes files older than 7 days. The hooks need a POSIX shell (Git Bash on Windows). Without one they write nothing, and the mod falls back to what it can find on its own. Some plans block plugin hooks on these events; the mod then falls back the same way.Change them with /mod-transcript settings or the gear pill.
| Setting | Default | What it does |
|---|---|---|
| Fold finished turns | on | Turns start folded to one line. The line sits under its prompt. Off, they start open and the line folds them |
| Fold tool rows | on | Each tool row folds to one line. Finished rows have their own toggle. Off, tool rows draw as Claude Code draws them |
| Show times | on | Prompt send time, tool duration. Each prompt shows when you sent it, each tool line when it started and how long it took. Off, rows show no times |
| Show the latest turn in full | on | Stays open until your next prompt, with every tool row and result as Claude Code draws them. Off, finished tool calls fold right away |
| Copy button | on | On each finished reply's edge. Point at the copy button to pick reply, code, text, table or links, then click. The reply marks what the choice under the pointer copies. Off, the edge stays plain |
| Prompt numbers | on | #N before each of your prompts. It matches the number the Prompts and Heads up panels show |
| Animations | on | Fold and kind chips fade when the pointer leaves, a running command's dot blinks, and the tabs and settings fade. Off, they switch at once and the dot stays lit |
| Code theme | empty | The VS Code color theme that colors code in replies and in Read, Write and Edit previews. Empty follows the theme VS Code is set to. Otherwise type a theme's label exactly, such as Moonlight. It looks in the .vscode, .vscode-insiders and .cursor extension folders. Without a match, the mod's own colors draw |
| Icons | auto | auto picks Nerd Font or safe glyphs. It uses Nerd Font icons when the terminal's font is a Nerd Font (read from Windows Terminal, iTerm2, VS Code, Ghostty, kitty, WezTerm, Alacritty, GNOME Terminal or Konsole) and safe Unicode glyphs elsewhere. nerd and safe force one |
Made by [Vinicius Amorim][doc-author], [MIT license][doc-license].
[doc-author]: https://github.com/v-amorim [doc-icons]: https://github.com/v-amorim/moonlight-claude-mods/blob/main/README.md#icons-and-nerd-fonts [doc-install]: https://github.com/v-amorim/moonlight-claude-mods/blob/main/README.md#install [doc-license]: https://github.com/v-amorim/moonlight-claude-mods/blob/main/LICENSE [doc-root]: https://github.com/v-amorim/moonlight-claude-mods/blob/main/README.md
[img-transcript-colors]: https://github.com/user-attachments/assets/81943904-ff5e-4c37-9916-276958925cf1 [img-transcript-copy]: https://github.com/user-attachments/assets/d288094d-2af1-4da7-8ae8-38f140028871 [img-transcript-folded]: https://github.com/user-attachments/assets/8f89e862-2566-4bd3-ad95-57959014e88b [img-transcript-open]: https://github.com/user-attachments/assets/7cad8bc5-02a1-4f36-8db7-4b5ad8f6ec97
hooks/register.tsx 1742 lines1// mod-transcript / hooks/register.tsx, by v-amorim
2// Registers the transcript's drawing, commands, settings pane and click handling with the engine.
3import type { ConfigValue, EngineInterface, ModelApiError, Register, RenderElement } from 'claude-code'
4
5import { cells, cut, fit, wrapWords } from './cells'
6import type { BarMessage, BarProps } from './bar'
7import type { Chip, ChipsMessage, ChipsProps } from './chips'
8import { copyWidth, type CopyHoverMessage, type CopyMessage, type CopyProps, type CopyTipMessage } from './copy'
9import type { EdgeProps } from './edge'
10import type { DotProps } from './dot'
11import type { FoldProps } from './fold'
12import type { LabelPart, LabelProps } from './label'
13import { pillWidth, type NumberProps } from './number'
14import { codeCard, calloutCard, headingRow } from './blocks'
15import { cutPieces, languageForPath, previewPieces, useTheme, type Piece as TokenPiece } from './hilite'
16import { PathLinks, pathTokens } from './links'
17import { fileResult, resultCard } from './results'
18import { activeLabel, listThemes, loadTheme, type ThemeHost } from './vstheme'
19import { themeRows, type ThemesMessage, type ThemesProps } from './themes'
20import { entryCells, isTipMessage, tipText, type EntryMessage, type EntryProps, type Tip, type TipMessage } from './entry'
21import { cardFill, detectNerd, FOOTER_ROWS, lookFor, paneFooter, section, type IconMode, withMark } from './look'
22import { pillRow, splitRow, tabRow, tipLine } from './pills'
23import { actionReveals, settingChoice, settingsBody, type Action, type Previews } from './settings'
24import style from './style'
25import text from './text'
26import { TOAST_MS, ToastList, toastCards } from './toasts'
27import { announceView, viewing } from './view'
28import { tokenizeLine, type Role } from './shell'
29import { detectWedge, FILLED, OUTLINE, type Wedge } from './wedge'
30import { CAPS, fill, MARK, mix, statusColor, SYNTAX, THEME, WASH } from './theme'
31import { copyChoices, copyText, type CopyChoice } from './clip'
32import { markPieces, paintOf, type Piece } from './mark'
33import { proseBlock, type Inline } from './prose'
34import { richBlocks, tableBlock, tableMinWidth, unsupported, type Block } from './table'
35import { callTime, commandOf, diffStat, elapsed, isPrompt, rowKey, timeOfDay, toolLabel, turnKinds, turnLine, turnThoughts, TurnLog, type Turn } from './turns'
36import { apiErrorAction, completeLines, hexColors, questionsOf, reportOf, senderTaskOf, statusesOf, withNotice, type Notice, type Report } from './rows'
37import { bridgeOn, endBridge, startBridge, type BridgeRecord } from './bridge'
38import { bytesOf, CHUNK_BYTES, chunkArgv, utf8Length } from './tail'
39
40let transcript = ''
41let log = new TurnLog()
42let isLoaded = false
43// How many bytes of the transcript are fed, and whether its last line was still unfinished at the last read.
44let fedBytes = 0
45let isPartial = false
46// The session the log belongs to. A /resume or /clear keeps the process and changes the id, so the old log must never draw the new rows.
47let logSession = ''
48// Until the transcript is read, turns come from the in-memory messages: a row that folds later off screen keeps its height and eats the scroll.
49let guessing: Promise<void> | null = null
50const guess = ($: EngineInterface) => {
51 const session = logSession
52 return (guessing ??= $.session.messages().then(messages => {
53 if (!isLoaded && session === logSession) log = TurnLog.fromMessages(messages)
54 }).catch(() => {
55 // Without the messages, rows draw as before: unfolded until the transcript is read.
56 }))
57}
58let working = false
59// The engine's expanded view (ctrl+o, --verbose) draws every row in full, so the rows pass through there.
60let isFullView = false
61// Calls a hook or the person refused (PermissionDenied): an error the person chose, drawn neutral.
62const declined = new Set<string>()
63type ApiFailure = { error: ModelApiError; details: string; text: string }
64// The API error that ended the last turn (StopFailure); the reply row with its text draws as one line.
65let apiFailure: ApiFailure | null = null
66// The person's settings (userConfig), fixed per activation: a change reloads the mod.
67let foldTurns = true
68let foldTools = true
69let showTimes = true
70let expandLive = true
71let showCopy = true
72let showNumbers = true
73// The animations setting, off: chips switch hover colors at once and the running dot stays lit.
74let still = false
75let iconMode: IconMode = 'auto'
76// The code_theme setting: a VS Code theme label, or empty to follow the editor's own.
77let codeTheme = ''
78// What the session's disk said about the paths replies name; a reply draws a path's link once the path is known to exist.
79const paths = new PathLinks()
80// The installed theme labels and the one VS Code uses, scanned each time the Settings pane opens.
81let themeList: Promise<{ labels: string[]; active: string | undefined }> | null = null
82let isThemesOpen = false
83let look = lookFor(false)
84let wedge: Wedge = FILLED
85type Kind = 'turns' | 'tools'
86// For /mod-transcript debug: each drawn row's component and id, and whether it landed in a turn.
87const seen = new Map<string, boolean>()
88// Each task's notice, and the tasks with a report drawn; see taskRow.
89const notices = new Map<string, Notice>()
90const reported = new Set<string>()
91
92const PANE = 'toggle-settings'
93let actions: Action[] = []
94let previews: Previews | undefined
95let note = ''
96
97// The control under the pointer, from its tip post: the band's tip row and the Settings footer show it.
98let hovered: TipMessage | null = null
99const toasts = new ToastList('transcript')
100const hoveredTip = (): Tip | null => (hovered?.id && hovered.label ? { glyph: hovered.glyph ?? '', label: hovered.label, text: hovered.text ?? '' } : null)
101
102const isPaneOpen = async ($: EngineInterface) => (await $.ui.panes()).some(pane => pane.id === PANE && pane.isPlaced)
103
104// `/mod-transcript settings` toggles the panel; an unplaced pane is waiting, not open.
105async function toggleSettings($: EngineInterface) {
106 if (await isPaneOpen($)) {
107 await $.ui.close({ id: PANE })
108 await $.state.set({ plugin: 'mod-transcript', key: 'pane' }, false)
109 return
110 }
111 note = ''
112 themeList = null
113 const showcase = await $.fs.read(`${$.plugin.root}/showcase.json`).then(text => JSON.parse(String(text))).catch(() => null)
114 previews = showcase?.previews
115 actions = (Array.isArray(showcase?.actions) ? showcase.actions : []).map((action: Action) => ({ ...action, isLocal: action.command === 'mod-transcript' }))
116 const result = await $.ui.open({ id: PANE, title: text.panePlacedTitle, closeOnEscape: true })
117 if (!result.isPlaced) $.ui.toast(fill(text.paneWaiting, { reason: String(result.reason) }))
118 await $.state.set({ plugin: 'mod-transcript', key: 'pane' }, result.isPlaced)
119}
120
121// One fs.read takes a file up to this size.
122const WHOLE_READ_BYTES = 4 * 1024 * 1024
123
124// The text from byte `from` on ('' for none, null when unreadable); past one fs.read, only the first read streams the whole file.
125async function readTranscript($: EngineInterface, from: number): Promise<{ text: string | null; size: number }> {
126 const size = transcript ? ((await $.fs.stat(transcript).catch(() => null))?.size ?? 0) : 0
127 if (!size || size <= from) return { text: '', size }
128 if (size <= WHOLE_READ_BYTES) {
129 const whole = String(await $.fs.read(transcript).catch(() => ''))
130 if (whole) return { text: from ? new TextDecoder().decode(new TextEncoder().encode(whole).subarray(from)) : whole, size }
131 }
132 if (from > 0) {
133 const parts: Uint8Array[] = []
134 for (let at = from; at < size; at += CHUNK_BYTES) {
135 const { stdout } = await $.process.run(chunkArgv(transcript, at, Math.min(CHUNK_BYTES, size - at)))
136 parts.push(bytesOf(stdout))
137 }
138 const bytes = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0))
139 parts.reduce((at, part) => (bytes.set(part, at), at + part.length), 0)
140 return { text: new TextDecoder().decode(bytes), size }
141 }
142 const isWindows = /^[A-Za-z]:|\\/.test(transcript)
143 // cmd's type rejects forward slashes ("The syntax of the command is incorrect").
144 const argv = isWindows ? ['cmd', '/c', 'type', transcript.replace(/\//g, '\\')] : ['cat', transcript]
145 let text = ''
146 for await (const chunk of $.process.spawn({ argv })) {
147 if (chunk.stream === 'stdout') text += chunk.text
148 }
149 return { text: text || null, size }
150}
151
152function startLog(session: string, path = '') {
153 logSession = session
154 transcript = path
155 log = new TurnLog()
156 isLoaded = false
157 fedBytes = 0
158 isPartial = false
159 guessing = null
160 working = false
161 endedPrompt = ''
162 apiFailure = null
163 seen.clear()
164}
165
166// Asking the id on every row slowed scrolling, so rows ask after a session.end, or once this long has passed.
167const SESSION_CHECK_MS = 500
168let sessionCheckedAt = 0
169
170// A new session's rows can draw before its SessionStart arrives, so rows check the id before using the log.
171async function followSession($: EngineInterface) {
172 const now = Date.now()
173 if (logSession && now - sessionCheckedAt < SESSION_CHECK_MS) return
174 sessionCheckedAt = now
175 const asked = logSession
176 const session = await $.session.id().catch(() => logSession)
177 // A SessionStart that switched the log while the id was on its way knows better than this answer.
178 if (session === logSession || asked !== logSession) return
179 startLog(session)
180 // The session's SessionStart may never come, so its transcript is looked up by id.
181 $.clock.after(0, () => void load($))
182}
183
184// A transcript path is kept with the time it was named; an older bare string counts as a path with no age yet.
185const pathOf = (kept: unknown) => {
186 const path = typeof kept === 'object' && kept !== null ? (kept as { path?: unknown }).path : kept
187 return typeof path === 'string' ? path : ''
188}
189
190const TRANSCRIPT_KEEP_MS = 30 * 24 * 60 * 60 * 1000
191
192// Entries untouched for 30 days go; a bare string from an older version starts its clock now.
193async function pruneTranscripts($: EngineInterface) {
194 try {
195 const now = await $.clock.now()
196 for (const key of await $.store.keys()) {
197 if (!key.startsWith('transcript:')) continue
198 const kept = await $.store.get(key)
199 const at = typeof kept === 'object' && kept !== null ? Number((kept as { at?: unknown }).at) : NaN
200 if (Number.isFinite(at)) {
201 if (at < now - TRANSCRIPT_KEEP_MS) await $.store.delete(key)
202 } else if (typeof kept === 'string') await $.store.set(key, { path: kept, at: now })
203 }
204 } catch {
205 // Old entries stay until the next start.
206 }
207}
208
209// One read at a time: two at once would feed the same lines twice.
210let reading: Promise<void> = Promise.resolve()
211const load = ($: EngineInterface) => (reading = reading.then(() => readMore($)).catch(() => {}))
212
213// A startup hook waits this long at most, so a long session's read never holds up the start; rows fold from the guess meanwhile.
214const LOAD_WAIT_MS = 300
215const loadAWhile = ($: EngineInterface) => Promise.race([load($), $.clock.sleep(LOAD_WAIT_MS).catch(() => {})])
216
217// Later reads feed only the text after what was fed; a failed read leaves the log unloaded for the next turn.
218async function readMore($: EngineInterface) {
219 await followSession($)
220 if (isLoaded && !isPartial) return
221 const session = logSession
222 const path = transcript || pathOf(await $.store.get(`transcript:${session}`).catch(() => '')) || (await findTranscript($))
223 // A switch while this read waited makes it another session's.
224 if (session !== logSession) return
225 transcript = path
226 const readAt = Date.now()
227 lastRead = `reading ${path}`
228 const failed = (error: Error) => {
229 lastRead = `read failed after ${Date.now() - readAt} ms: ${error?.message ?? error}`
230 return { text: null, size: 0 }
231 }
232 let from = isLoaded ? fedBytes : 0
233 let read = await readTranscript($, from).catch(failed)
234 // A file shorter than what was fed was rewritten, so it is read again from its start.
235 if (read.text !== null && read.size < fedBytes) {
236 isLoaded = false
237 isPartial = false
238 from = 0
239 read = await readTranscript($, 0).catch(failed)
240 }
241 const fresh = read.text
242 if (fresh === null || session !== logSession || path !== transcript) return
243 const fedAt = Date.now()
244 const { lines, rest } = completeLines(fresh)
245 // A line unfinished through two reads is taken as it stands: feed drops it when it is not JSON.
246 const isStale = isPartial && rest.length > 0
247 // The first read builds its log aside, so rows keep drawing from the guess until it is whole.
248 const target = isLoaded ? log : new TurnLog()
249 const fed = isStale ? [...lines, rest] : lines
250 for (let at = 0; at < fed.length; at += FEED_SLICE) {
251 for (const line of fed.slice(at, at + FEED_SLICE)) target.feed(line)
252 // A long session has tens of thousands of rows; feeding them in one go stalled every row draw meanwhile.
253 if (at + FEED_SLICE < fed.length) {
254 await $.clock.sleep(0)
255 if (session !== logSession || path !== transcript) return
256 }
257 }
258 log = target
259 fedBytes = from + utf8Length(fresh) - (isStale ? 0 : utf8Length(rest))
260 isPartial = rest.length > 0 && !isPartial
261 isLoaded = true
262 lastRead = `last read: ${fresh.length} chars from byte ${from} in ${fedAt - readAt} ms, ${lines.length} lines fed in ${Date.now() - fedAt} ms`
263 $.ui.invalidate('ui.render')
264}
265
266const FEED_SLICE = 2000
267
268// Only the SessionStart record names the transcript, and the bridge delivers it; the store keeps it per session so a reload finds it again.
269async function sessionStarted($: EngineInterface, record: BridgeRecord) {
270 const session = String(record.session_id ?? '')
271 const path = String(record.transcript_path ?? '')
272 if (!session) return
273 // A compact keeps the same session and file, which the log already holds; so does a read that found the file by its id before this record came, in slashes of its own.
274 const isNew = session !== logSession || path.replace(/\\/g, '/') !== transcript.replace(/\\/g, '/') || !isLoaded
275 if (isNew) startLog(session, path)
276 await $.store.set(`transcript:${session}`, { path, at: await $.clock.now() }).catch(() => {})
277 if (isNew) await loadAWhile($)
278 // SessionStart comes between turns, so whatever the transcript holds has ended.
279 markEnded()
280}
281
282// A replay after a reload hands over the last record again; a set holds the id once.
283function permissionDenied($: EngineInterface, record: BridgeRecord) {
284 if (typeof record.tool_use_id !== 'string' || !record.tool_use_id) return
285 declined.add(record.tool_use_id)
286 $.ui.invalidate('ui.render')
287}
288
289// The reply row draws the failure only where its text matches, so a replayed record changes nothing.
290function stopFailed($: EngineInterface, record: BridgeRecord) {
291 apiFailure = { error: record.error as ModelApiError, details: String(record.error_details ?? ''), text: String(record.last_assistant_message ?? '').trim() }
292 $.ui.invalidate('ui.render')
293}
294
295// For /mod-transcript debug: how long the last transcript read and feed took.
296let lastRead = ''
297
298async function configDir($: EngineInterface) {
299 const set = ((await $.env.get('CLAUDE_CONFIG_DIR')) ?? '').replace(/\\/g, '/').replace(/\/+$/, '')
300 if (set) return set
301 const home = ((await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '').replace(/\\/g, '/')
302 return `${home}/.claude`
303}
304
305// When no SessionStart named the transcript, it is `<session id>.jsonl` in one of the config folder's projects folders.
306async function findTranscript($: EngineInterface) {
307 const projects = `${await configDir($)}/projects`
308 const file = `${await $.session.id()}.jsonl`
309 for (const entry of await $.fs.list(projects).catch(() => [])) {
310 const path = `${projects}/${entry.name}/${file}`
311 if (entry.kind === 'dir' && (await $.fs.exists(path).catch(() => false))) return path
312 }
313 return ''
314}
315
316// The turns list holds the turns flipped away from what fold_turns sets for all of them.
317const isTurnOpen = (flipped: string[], turn: Turn) => flipped.includes(turn.prompt) === foldTurns
318
319// The latest turn draws in full until the next prompt; a row the transcript doesn't hold yet belongs to it.
320const isLive = (turn: Turn | undefined) => expandLive && (!turn || !isDone(turn))
321
322// The prompt of the last turn known to have ended. A flag set by turn.start would read idle after a mod reload mid-turn.
323let endedPrompt = ''
324
325// With expand_live off, the latest turn folds as soon as it ends instead of at the next prompt.
326const isDone = (turn: Turn) =>
327 turn !== log.turns[log.turns.length - 1] || (!expandLive && (turn.durationMs !== undefined || turn.prompt === endedPrompt))
328
329const markEnded = () => {
330 endedPrompt = log.turns[log.turns.length - 1]?.prompt ?? ''
331}
332
333const listOf = (value: unknown): string[] => (Array.isArray(value) ? value : [])
334
335// The flipped lists, copied from $.state: reading the state on every row cost a round trip per row while scrolling. A change redraws every row.
336const flips: Record<Kind, string[]> = { turns: [], tools: [] }
337
338function setFlips($: EngineInterface, kind: Kind, value: unknown) {
339 flips[kind] = listOf(value)
340 $.ui.invalidate('ui.render')
341}
342
343async function readFlips($: EngineInterface) {
344 setFlips($, 'turns', (await $.state.get({ plugin: 'mod-transcript', key: 'turns' })).value)
345 setFlips($, 'tools', (await $.state.get({ plugin: 'mod-transcript', key: 'tools' })).value)
346}
347
348// Writes a list computed from its latest value, only while no other write landed since the read; a lost race reads again.
349async function changeList($: EngineInterface, kind: Kind, change: (list: string[]) => string[]) {
350 for (let tries = 0; tries < 5; tries++) {
351 if (kind === 'turns') {
352 const { value, version } = await $.state.get({ plugin: 'mod-transcript', key: 'turns' })
353 const next = change(listOf(value))
354 if ((await $.state.set({ plugin: 'mod-transcript', key: 'turns' }, next, { ifVersion: version })).isSet) return setFlips($, kind, next)
355 } else {
356 const { value, version } = await $.state.get({ plugin: 'mod-transcript', key: 'tools' })
357 const next = change(listOf(value))
358 if ((await $.state.set({ plugin: 'mod-transcript', key: 'tools' }, next, { ifVersion: version })).isSet) return setFlips($, kind, next)
359 }
360 }
361}
362
363async function flip($: EngineInterface, kind: Kind, id: string) {
364 await changeList($, kind, list => (list.includes(id) ? list.filter(open => open !== id) : [...list, id]))
365}
366
367// `11:32 · 6m 12s` with its two caps and a space: the widest start-and-duration a tool line shows.
368const WHEN_WIDTH = 18
369
370const widthOf =(e: { viewport?: { columns: number } }) => Math.max(20, (e.viewport?.columns ?? 100) - 6)
371
372// The head line's cells around its label: the card's edges and padding, the bar and its padding, the fold chip and its gap, and one spare.
373const HEAD_CHROME = 12
374
375// How a turn's row draws: hidden while its turn is folded, inside the card while unfolded, as is in the live turn or as the reply.
376async function placeOf($: EngineInterface, turn: Turn | undefined, requestId: string) {
377 if (!turn || !isDone(turn) || turn.command || rowKey(requestId) === turn.reply) return 'plain'
378 return isTurnOpen(flips.turns, turn) ? 'card' : 'hidden'
379}
380
381type Elements = { Box: (props: any) => RenderElement }
382
383// One row's share of a turn's card; stacked rows make one tinted block with a bar down its left edge.
384const slice = ({ Box, Client }: Bars, key: string, bar: string, body: unknown, fold: BarProps) => (
385 <Box key={key} flexDirection="row" backgroundColor={THEME.surface1}>
386 <Box key={`${key}-bar`} width={1} flexShrink={0} backgroundColor={bar}>
387 <Client key={`${key}-strip`} module="./bar.tsx" width={1} height="100%" props={fold} />
388 </Box>
389 <Box key={`${key}-body`} flexDirection="column" flexGrow={1} paddingX={1}>
390 {body}
391 </Box>
392 </Box>
393)
394
395// A strip is clickable, so drawing one takes a Client.
396type Bars = Elements & { Client: (props: any) => RenderElement }
397
398const edge = ({ Box, Client }: Bars, key: string, props: EdgeProps) =>
399 props.kind === 'top' || props.kind === 'bottom' ? (
400 <Box key={key} height={1}>
401 <Client key={`${key}-edge`} module="./edge.tsx" width="100%" height={1} props={props} />
402 </Box>
403 ) : (
404 // Absolute so its lines never stretch the row; clipped so lines left from a taller row never paint below it.
405 <Box key={key} width={1} flexShrink={0}>
406 <Box key={`${key}-pin`} position="absolute" top={0} left={0} width={1} height="100%" overflow="hidden">
407 <Client key={`${key}-edge`} module="./edge.tsx" width={1} height="100%" props={props} />
408 </Box>
409 </Box>
410 )
411
412// A finished turn with tools opens its bubble at the head under the prompt; its card rows and its reply carry on inside.
413const hasHead = (turn: Turn) => isDone(turn) && turn.tools.length > 0
414
415function frame({ Box, Client }: Bars, key: string, body: unknown, isTop: boolean, isBottom: boolean, isTight = false, turn?: Turn) {
416 const parts = { Box, Client }
417 return (
418 <Box key={key} flexDirection="column" flexGrow={1}>
419 {isTop ? edge(parts, `${key}-top`, { kind: 'top', color: THEME.accent, wedge: wedge.left }) : null}
420 <Box key={`${key}-sides`} flexDirection="row">
421 {edge(parts, `${key}-left`, { kind: 'side', color: THEME.accent })}
422 <Box key={`${key}-body`} flexDirection="column" flexGrow={1} paddingX={isTight ? 0 : 1}>{body}</Box>
423 {edge(parts, `${key}-right`, { kind: 'side', color: THEME.accent })}
424 </Box>
425 {isBottom ? closing(parts, `${key}-bottom`, turn) : null}
426 </Box>
427 )
428}
429
430const cardRow = (parts: Bars, body: unknown, fold: BarProps) => frame(parts, 'toggle-frame', slice(parts, 'toggle-card', THEME.accent, body, fold), false, false)
431
432// Rows draw in no fixed order, so the prompt and duration rows carry the edges; a finished turn's reply closes it.
433function bubble(parts: Bars, key: string, turn: Turn, body: unknown, isTight = false) {
434 return frame(parts, key, body, false, isDone(turn), isTight, turn)
435}
436
437// Claude Code sizes a markdown table to the whole terminal, so a block holding one drops the bubble's padding to fit it.
438const hasTable = (text: string) => /^\s*\|.*\|\s*$/m.test(text) && /^\s*\|?\s*:?-{3,}/m.test(text)
439
440// A turn has ended once a later prompt follows it, its duration is stored, or Claude went idle after it.
441const hasEnded = (turn: Turn) => turn !== log.turns[log.turns.length - 1] || turn.durationMs !== undefined || turn.prompt === endedPrompt
442
443function copyPill({ Box, Client }: Bars, turn: Turn) {
444 const choices = copyChoices(turn.replyText)
445 if (!showCopy || !hasEnded(turn) || !choices.length) return null
446 const props: CopyProps = { id: turn.prompt, choices: choices.map(what => ({ what, label: text.copyChoice[what] })), icon: look.glyph.copy, copied: text.copied, bg: THEME.surface3, nerd: look.nerd, lead: { char: MARK.rule, color: THEME.accent } }
447 return (
448 <Box key="toggle-copy" flexShrink={0}>
449 <Client key="toggle-copy-pill" module="./copy.tsx" width={copyWidth(props)} height={1} props={props} />
450 </Box>
451 )
452}
453
454// Claude's bubble opens under the prompt: as the head of a finished turn with tools, else as a bare top edge.
455const opening = (parts: Bars) => edge(parts, 'toggle-bubble-open', { kind: 'top', color: THEME.accent, wedge: wedge.left })
456
457// The bubble's bottom edge, under the newest output, carries the copy pill near its corner once the turn has ended.
458function closing(parts: Bars, key: string, turn: Turn | undefined) {
459 const { Box } = parts
460 const copy = turn ? copyPill(parts, turn) : null
461 if (!copy) return edge(parts, key, { kind: 'bottom', color: THEME.accent })
462 return (
463 <Box key={`${key}-row`} flexDirection="row" height={1}>
464 <Box key={`${key}-run`} flexGrow={1}>{edge(parts, key, { kind: 'bottom', color: THEME.accent, open: 'end' })}</Box>
465 {copy}
466 <Box key={`${key}-corner`} width={2} flexShrink={0}>{edge(parts, `${key}-end`, { kind: 'bottom', color: THEME.accent, open: 'start' })}</Box>
467 </Box>
468 )
469}
470
471const bare = (glyph: string) => glyph.replace(/[[\]\s]/g, '')
472
473// Chip and label are both Clients, since a Button posts no tip; without caps the chip's ends are a space each, so it takes three cells either way.
474const toggleLine = ({ Box, Client }: Elements & { Client: (props: any) => RenderElement }, key: string, chip: string, isOpen: boolean, label: string, fold: BarProps, extra: unknown = null, parts?: LabelPart[]) => {
475 const shown = parts ?? [{ text: label }]
476 return (
477 <Box key={key} flexDirection="row" columnGap={1}>
478 <Box key={`${key}-chip-pill`} flexShrink={0}>
479 <Client key={`${key}-chip`} module="./fold.tsx" width={3} height={1} props={{ ...fold, color: chip, isOpen, nerd: look.nerd, still, glyphs: { open: bare(look.glyph.down), closed: bare(look.glyph.up) } } satisfies FoldProps} />
480 </Box>
481 <Box key={`${key}-label`} flexShrink={1}>
482 <Client key={`${key}-colored`} module="./label.tsx" width={partsWidth(shown)} height={1} props={{ ...fold, parts: shown, isDim: !isOpen, hover: THEME.accent } satisfies LabelProps} />
483 </Box>
484 {extra}
485 </Box>
486 )
487}
488
489const FILE_TOOLS = new Set(['Read', 'Write', 'Edit', 'MultiEdit'])
490
491// Blast-radius's Moonlight roles for a shell command: the command word bold, then options, quoted text, variables, numbers and operators.
492const ROLE_LOOK: Partial<Record<Role, Omit<LabelPart, 'text'>>> = {
493 command: { color: SYNTAX.command, bold: true },
494 option: { color: SYNTAX.option },
495 text: { color: SYNTAX.text },
496 variable: { color: SYNTAX.variable },
497 number: { color: SYNTAX.number },
498 operator: { color: SYNTAX.operator },
499}
500
501const commandParts = (command: string): LabelPart[] => tokenizeLine(command).map(span => ({ text: span.text, ...ROLE_LOOK[span.role] }))
502
503const partsWidth = (parts: LabelPart[]) => parts.reduce((sum, part) => sum + cells(part.text), 0)
504
505function labelParts(name: string, input: any, width: number): LabelPart[] {
506 const label = fit(toolLabel(name, input, 0, 0), width)
507 if (name !== 'Bash' || !label.startsWith('Bash(')) return [{ text: label }]
508 const isClosed = label.endsWith(')')
509 return [{ text: 'Bash(' }, ...commandParts(label.slice('Bash('.length, isClosed ? -1 : undefined)), ...(isClosed ? [{ text: ')' }] : [])]
510}
511
512// A Bash call under the Ran chip: the fold glyph and the colored command, spaced like the plain call lines beside it.
513function callParts(call: Turn['tools'][number], isOpen: boolean, width: number): LabelPart[] {
514 const glyph = isOpen ? look.glyph.expanded : look.glyph.collapsed
515 const isLifted = look.nerd && isOpen
516 const parts = [{ text: isLifted ? `${glyph} ` : ` ${glyph} ` }, ...labelParts(call.name, call.input, width - 28), ...(isLifted ? [] : [{ text: ' ' }])]
517 return isOpen ? parts.map(part => ({ ...part, color: THEME.ok })) : parts
518}
519
520// The open call's pill fill, like an On switch: ok washed into the surface.
521const OPEN_CALL = mix(THEME.ok, THEME.surface1, WASH)
522
523// Claude Code's own Bash row, drawn here so the command keeps its colors; the blank first line matches the one Claude Code starts each row with.
524function bashRow({ Box, Client, Text }: Rows, input: any, isRunning: boolean, isFailed: boolean) {
525 const command = String(input?.command ?? '')
526 const dot = isRunning
527 ? <Client key="bash-dot" module="./dot.tsx" width={1} height={1} props={{ color: THEME.accent, still } satisfies DotProps} />
528 : <Text key="bash-dot" color={isFailed ? THEME.alert : THEME.good}>{MARK.stopOpen}</Text>
529 return (
530 <Box key="bash-row" flexDirection="column">
531 <Text key="bash-blank"> </Text>
532 <Box key="bash-head" flexDirection="row" columnGap={1}>
533 {dot}
534 <Text key="bash-description" bold>{String(input?.description || 'Bash')}</Text>
535 </Box>
536 {command.split('\n').map((line, n) => (
537 <Box key={`bash-line-${n}`} flexDirection="row" paddingLeft={2}>
538 <Text key="bash-lead" color={THEME.muted}>{n === 0 ? '└ $ ' : ' '}</Text>
539 <Box key="bash-command" flexShrink={1}>
540 <Text>
541 {commandParts(line).map((part, at) => (
542 <Text key={`part-${at}`} color={part.color ?? THEME.onSurface} bold={part.bold}>{part.text}</Text>
543 ))}
544 </Text>
545 </Box>
546 </Box>
547 ))}
548 </Box>
549 )
550}
551
552// The bubble's padding and the reply's gutter, so a table shrinks to what is left, and one that cannot goes back to Claude Code's.
553const TABLE_INDENT = 6
554
555// The code each code block's copy pill copies, by block id; set as the block draws.
556const codes = new Map<string, string>()
557const COPY_CODE = 'copy-code:'
558// Blocks whose pill says "copied code" for a moment, in place of a toast that would push the prompt down.
559const justCopied = new Set<string>()
560const COPIED_MS = 1500
561
562// The pill pinned on a code card's top edge, rounded where the font has caps.
563function copyBlock({ Box, Client, Text }: Rows, id: string, code: string) {
564 codes.set(id, code)
565 const isCopied = justCopied.has(id)
566 const props: EntryProps = { id: `${COPY_CODE}${id}`, label: isCopied ? fill(text.copied, { what: text.copyChoice.code }) : text.codeCopy, lead: look.glyph.copy, leadColor: THEME.secondary, isOpen: false, bg: THEME.surface3, idle: isCopied ? THEME.muted : THEME.secondary, nerd: look.nerd, still, tip: { glyph: look.glyph.copy, label: text.tipCodeCopyLabel, text: text.tipCodeCopy } }
567 return (
568 <Box key={`toggle-copy-code-${id}`} flexDirection="row" flexShrink={0}>
569 {look.nerd ? <Text color={THEME.surface3}>{CAPS.left}</Text> : null}
570 <Client key={`toggle-copy-code-pill-${id}`} module="./entry.tsx" width={entryCells(props)} height={1} props={props} />
571 {look.nerd ? <Text color={THEME.surface3}>{CAPS.right}</Text> : null}
572 </Box>
573 )
574}
575
576// A reply the mod draws in part: tables, code, headings and callouts in the standard's look, paragraphs with hex pills, the rest as Claude Code's markdown; gutter and margin as markedReply.
577function richReply(parts: Rows & { Markdown: (props: any) => RenderElement }, blocks: Block[], inlineLook: Inline, isFirst: boolean, requestId: string, width: number) {
578 const { Box, Markdown, Text } = parts
579 return (
580 <Box key="toggle-tables" flexDirection="row" marginTop={isFirst ? 1 : 0}>
581 <Box key="toggle-tables-gutter" width={2} flexShrink={0}>
582 <Text>{isFirst ? MARK.stopOpen : ' '}</Text>
583 </Box>
584 <Box key="toggle-tables-body" flexDirection="column" flexGrow={1} flexShrink={1}>
585 {blocks.map((block, n) => {
586 const id = `${requestId}:${n}`
587 const isOpen = flips.tools.includes(`code:${id}`)
588 return (
589 <Box key={`toggle-block-${n}`} marginTop={n ? 1 : 0} backgroundColor={block.kind === 'markdown' ? inlineLook.paint?.wash('text') : undefined}>
590 {block.kind === 'markdown' ? (
591 <Markdown text={block.text} dimColor={inlineLook.paint ? !inlineLook.paint.on('text') : undefined} />
592 ) : block.kind === 'prose' ? (
593 proseBlock({ Box, Text }, block.text, inlineLook, `toggle-prose-${n}`)
594 ) : block.kind === 'code' ? (
595 codeCard({ Box, Text }, block, { isOpen, paint: inlineLook.paint, copy: code => copyBlock(parts, id, code), more: (open, hidden, bg) => moreToggle(parts, `code:${id}`, open, hidden, bg) }, width, `toggle-code-${n}`)
596 ) : block.kind === 'heading' ? (
597 headingRow({ Box, Text }, block.text, inlineLook, width, `toggle-heading-${n}`)
598 ) : block.kind === 'callout' ? (
599 calloutCard({ Box, Text }, block, inlineLook, width, `toggle-callout-${n}`)
600 ) : (
601 tableBlock({ Box, Text }, block.table, inlineLook, `toggle-table-${n}`, width - TABLE_INDENT)
602 )}
603 </Box>
604 )
605 })}
606 </Box>
607 </Box>
608 )
609}
610
611// What a hovered copy choice takes, on the same wash as the mod's own drawing; the rest of the reply dims.
612const MARKED = mix(THEME.bg, THEME.accent, 0.15)
613
614// A reply that is Claude Code's own, in pieces while a copy choice is hovered; gutter and top margin match Claude Code's row, since a row that shrinks moves the pill from under the pointer.
615function markedReply({ Box, Markdown, Text }: Pick<Rows, 'Box' | 'Text'> & { Markdown: (props: any) => RenderElement }, pieces: Piece[], isFirst: boolean) {
616 return (
617 <Box key="toggle-marked" flexDirection="row" marginTop={isFirst ? 1 : 0}>
618 <Box key="toggle-marked-gutter" width={2} flexShrink={0}>
619 <Text>{isFirst ? MARK.stopOpen : ' '}</Text>
620 </Box>
621 <Box key="toggle-marked-body" flexDirection="column" flexGrow={1} flexShrink={1}>
622 {pieces.map((piece, n) =>
623 piece.kind === 'markdown' ? (
624 <Box key={`toggle-piece-${n}`} marginTop={n ? 1 : 0} {...(piece.isMarked ? { backgroundColor: MARKED } : {})}>
625 <Markdown text={piece.text} dimColor={!piece.isMarked} />
626 </Box>
627 ) : (
628 <Box key={`toggle-piece-${n}`} marginTop={n ? 1 : 0}>
629 <Text color={THEME.onSurface}>
630 {piece.parts.map((part, at) =>
631 part.isMarked ? (
632 <Text key={`part-${at}`} backgroundColor={MARKED} color={THEME.onSurface}>{part.text}</Text>
633 ) : (
634 <Text key={`part-${at}`} dimColor>{part.text}</Text>
635 ),
636 )}
637 </Text>
638 </Box>
639 ),
640 )}
641 </Box>
642 </Box>
643 )
644}
645
646const nested = ({ Box, Client }: Bars, key: string, body: unknown, fold: BarProps) => (
647 <Box key={key} flexDirection="row">
648 <Box key={`${key}-bar`} width={1} flexShrink={0} backgroundColor={THEME.border}>
649 <Client key={`${key}-strip`} module="./bar.tsx" width={1} height="100%" props={fold} />
650 </Box>
651 <Box key={`${key}-body`} flexDirection="column" flexGrow={1} paddingLeft={1}>
652 {body}
653 </Box>
654 </Box>
655)
656
657type Rows = Bars & { Text: (props: any) => RenderElement }
658
659// Lines added and removed as one pill on the card; plain colored text where the font has no caps.
660function countsPill({ Box, Text }: Pick<Rows, 'Box' | 'Text'>, key: string, added: number, removed: number) {
661 if (!added && !removed) return null
662 const parts = [added ? <Text key={`${key}-added`} color={THEME.good}>{`+${added}`}</Text> : null, removed ? <Text key={`${key}-removed`} color={THEME.alert}>{`${added ? ' ' : ''}−${removed}`}</Text> : null]
663 if (!look.nerd) return <Box key={key} flexDirection="row" flexShrink={0} height={1} alignSelf="flex-start">{parts}</Box>
664 // One row tall whatever the line's height: a filled body stretched by a taller row painted the row below.
665 return (
666 <Box key={key} flexDirection="row" flexShrink={0} height={1} alignSelf="flex-start">
667 <Text color={THEME.surface3}>{CAPS.left}</Text>
668 <Box key={`${key}-body`} flexDirection="row" height={1} backgroundColor={THEME.surface3}>{parts}</Box>
669 <Text color={THEME.surface3}>{CAPS.right}</Text>
670 </Box>
671 )
672}
673
674function questionRows({ Box, Text }: Pick<Rows, 'Box' | 'Text'>, input: unknown, output: unknown, width: number) {
675 return (
676 <Box key="toggle-questions" flexDirection="column">
677 {questionsOf(input, output).map((each, n) => (
678 <Text key={`toggle-question-${n}`} color={each.answer ? THEME.onSurface : THEME.muted} wrap="truncate-end">
679 {cut(each.answer ? `${look.glyph.mark.search} ${each.question} ${MARK.stage} ${each.answer}` : `${MARK.stop} ${fill(text.askedYou, { question: each.question })}`, width)}
680 </Text>
681 ))}
682 </Box>
683 )
684}
685
686const REPORT_LINES = 6
687
688// The expand standard's id prefix: an entry click with it flips the item under the rest of the id, in the open tool ids.
689const MORE = 'more:'
690
691// The expand control: the arrow, then a muted label, on the fill it sits on; a click flips `id`.
692function moreToggle({ Client }: Pick<Rows, 'Client'>, id: string, isOpen: boolean, hidden: number, bg: string) {
693 const label = isOpen ? text.showLess : hidden > 0 ? fill(text.previewMore, { n: hidden }) : text.showAll
694 const arrow = isOpen ? look.glyph.less : look.glyph.more
695 return (
696 <Client
697 key={`toggle-more-${id}`}
698 module="./entry.tsx"
699 width={cells(label) + 4}
700 height={1}
701 props={{ id: `${MORE}${id}`, label, lead: arrow, leadColor: THEME.accent, isOpen: false, bg, idle: THEME.muted, nerd: look.nerd, still, tip: { glyph: arrow, label, text: isOpen ? text.tipShowLess : text.tipShowAll } } satisfies EntryProps}
702 />
703 )
704}
705
706function rowWithMore({ Box }: Pick<Rows, 'Box'>, key: string, body: unknown, control: unknown) {
707 return (
708 <Box key={key} flexDirection="row">
709 <Box key={`${key}-body`} flexShrink={1}>{body}</Box>
710 <Box key={`${key}-gap`} flexGrow={1} />
711 <Box key={`${key}-control`} flexShrink={0}>{control}</Box>
712 </Box>
713 )
714}
715
716type TaskProps = { text: string; origin: { kind: string }; from?: { name: string }; task?: { id?: string; status?: string; type?: string; durationMs?: number; toolUseId?: string } }
717
718// A subagent's or teammate's report: a head line with its sender, status, time and first line, then a card of its fields and first lines.
719function reportRow(parts: Rows, props: TaskProps, report: Report, width: number, key: string, fold?: { id: string; isOpen: boolean }) {
720 const { Box, Client, Text } = parts
721 const all = props.text.replace(/\s+$/, '').split('\n')
722 const first = all.findIndex(line => line.trim())
723 const summary = first < 0 ? '' : all[first]!.trim()
724 const time = report.durationMs !== undefined ? elapsed(report.durationMs) : ''
725 const head = [report.name || text.reportFallback, report.status, time, summary].filter(Boolean).join(` ${MARK.sep} `)
726 const label = `${look.glyph.mark.agent} ${head}`
727 const headRoom = fold ? width - 4 : width
728 // A summary the head shows whole is not repeated in the card; a cut one stays there in full.
729 const lines = cut(label, headRoom) === label ? all.slice(first + 1) : all
730 const fields = [
731 props.task?.id ? fill(text.reportTask, { id: props.task.id }) : '',
732 props.task?.type ? fill(text.reportType, { type: props.task.type }) : '',
733 props.task?.toolUseId ? fill(text.reportTool, { id: props.task.toolUseId }) : '',
734 ].filter(Boolean)
735 const room = width - 4
736 // A word wider than the card (a path, a URL) breaks across rows rather than losing its end.
737 const split = (row: string) => (cells(row) <= room ? [row] : Array.from({ length: Math.ceil(row.length / room) }, (_, n) => row.slice(n * room, (n + 1) * room)))
738 const rows = lines.flatMap(line => (line.trim() ? wrapWords(line, room).flatMap(split) : [line]))
739 // Past six rows the rest opens on a click, kept with the open tool ids under this row's own id.
740 const moreId = `report:${key}`
741 const isAll = flips.tools.includes(moreId)
742 const shown = isAll ? rows : rows.slice(0, REPORT_LINES)
743 const more = rows.length - REPORT_LINES
744 // With no lines past the head, a card of ids alone would repeat the head, so none draws.
745 const isOpen = (fold?.isOpen ?? true) && shown.some(line => line.trim())
746 return (
747 <Box key="toggle-report" flexDirection="column">
748 {fold ? (
749 toggleLine(parts, 'toggle-report-fold', THEME.onSurface, fold.isOpen, cut(label, headRoom), { kind: 'turns', id: fold.id })
750 ) : (
751 <Text key="toggle-report-head" color={THEME.onSurface} wrap="truncate-end">{cut(label, headRoom)}</Text>
752 )}
753 {isOpen ? (
754 <Box key="toggle-report-card" flexDirection="column" borderStyle="round" borderColor={THEME.border} backgroundColor={cardFill()} paddingX={1}>
755 {fields.map((field, n) => (
756 <Text key={`toggle-report-field-${n}`} color={THEME.muted} wrap="truncate-end">{field}</Text>
757 ))}
758 {shown.map((line, n) => {
759 const body = <Text key={`toggle-report-line-${n}`} color={THEME.onSurface} wrap="truncate-end">{line || ' '}</Text>
760 // The control sits on the last text row, pinned right.
761 return more > 0 && n === shown.length - 1 ? rowWithMore(parts, 'toggle-report-more', body, moreToggle(parts, moreId, isAll, more, cardFill())) : body
762 })}
763 </Box>
764 ) : null}
765 </Box>
766 )
767}
768
769const isSameNotice = (known: Notice | undefined, notice: Notice) => known !== undefined && known.status === notice.status && known.durationMs === notice.durationMs
770
771// Either may draw first, so whichever comes second redraws the row that shows the other.
772function learnNotice($: EngineInterface, id: string, notice: Notice) {
773 if (isSameNotice(notices.get(id), notice)) return
774 notices.set(id, notice)
775 if (reported.has(id)) $.ui.invalidate('ui.render')
776}
777
778function learnReport($: EngineInterface, id: string) {
779 if (reported.has(id)) return
780 reported.add(id)
781 if (notices.has(id)) $.ui.invalidate('ui.render')
782}
783
784// A notice draws nothing once its report draws; a report takes the notice's status and time.
785async function taskRow(parts: Rows, $: EngineInterface, requestId: string, props: TaskProps, width: number) {
786 const { Box } = parts
787 if (props.origin.kind === 'task-notification') {
788 const id = props.task?.id
789 if (id) learnNotice($, id, { status: props.task?.status, durationMs: props.task?.durationMs })
790 if (id && reported.has(id)) return <Box key="toggle-hidden" />
791 return reportRow(parts, props, reportOf(props.from, props.task), width, requestId)
792 }
793 const id = senderTaskOf(props.origin)
794 if (id) learnReport($, id)
795 const report = withNotice(reportOf(props.from, props.task), id ? notices.get(id) : undefined)
796 const turn = log.turnAt(requestId)
797 if (!turn || !isDone(turn)) return reportRow(parts, props, report, width, requestId)
798 const isOpen = isTurnOpen(flips.turns, turn)
799 return reportRow(parts, props, report, width, requestId, { id: turn.prompt, isOpen })
800}
801
802// An API error that ended the reply, as one line: red when the person must act, muted when Claude Code retries.
803function apiErrorRow({ Box, Text }: Pick<Rows, 'Box' | 'Text'>, failure: ApiFailure, width: number) {
804 const action = apiErrorAction(failure.error)
805 const detail = failure.details ? ` ${MARK.sep} ${failure.details}` : ''
806 return (
807 <Box key="toggle-api-error" flexDirection="row">
808 <Text color={action === 'act' ? THEME.alert : THEME.muted} bold={action === 'act'} wrap="truncate-end">
809 {fit(`${MARK.warn} ${text.apiWhat[failure.error]} ${MARK.sep} ${text.apiAction[action]}${detail}`, width)}
810 </Text>
811 </Box>
812 )
813}
814
815// One open chip per turn: picking another closes the first, picking the open one closes it.
816async function pickChip($: EngineInterface, turnId: string, kind: string) {
817 const key = `${turnId}:${kind}`
818 await changeList($, 'tools', list => {
819 const others = list.filter(open => !open.startsWith(`${turnId}:`))
820 return list.includes(key) ? others : [...others, key]
821 })
822}
823
824// An open turn's work under its head: a line of kind chips (Thought first, then each kind in order of first use) and, under the picked one, its calls or its thinking.
825// A click on a call's line opens or closes it: its own row then shows under the list, in the turn's order.
826function chipSection({ Box, Client, Link, Text }: Rows & { Link: (props: any) => RenderElement }, turn: Turn, openTools: string[], width: number) {
827 // Claude Code's drawing reaches a mod sealed, so the thinking comes from the session's messages, without its time.
828 const thoughts = turnThoughts(turn)
829 const kinds = turnKinds(turn)
830 const isOpen = (kind: string) => openTools.includes(`${turn.prompt}:${kind}`)
831 const chips: Chip[] = [
832 ...(thoughts.length ? [{ kind: 'thinking' as const, mark: look.glyph.mark.thinking as string, label: text.thought, count: thoughts.length, added: 0, removed: 0, isOpen: isOpen('thinking') }] : []),
833 ...kinds.map(group => ({ kind: group.kind, mark: look.glyph.mark[group.kind] as string, label: text.kindChip[group.kind], count: group.calls.length, added: group.added, removed: group.removed, isOpen: isOpen(group.kind) })),
834 ]
835 const picked = chips.find(chip => chip.isOpen)
836 const bar = picked ? style.colors.kinds[picked.kind].hue : THEME.border
837 const line = (key: string, body: unknown, time = '') => (
838 <Box key={key} flexDirection="row">
839 <Box key={`${key}-line`} flexDirection="row" columnGap={1} flexGrow={1} flexShrink={1}>{body}</Box>
840 {showTimes && time ? timePill({ Box, Text }, `${key}-time`, time) : null}
841 </Box>
842 )
843 const thoughtLines = thoughts.filter(Boolean).join('\n\n').split('\n').flatMap(paragraph => wrapWords(paragraph, width - 8))
844 // An open call's preview under its line: diff lines on Claude Code's add and remove backgrounds, any other output muted.
845 const preview = (call: Turn['tools'][number], n: number) => {
846 // A file tool's record draws the same card the result row does; any shape it does not know keeps the preview below.
847 const result = fileResult(call.name, call.file)
848 const href = result ? paths.address(result.path, result.line) : undefined
849 if (result && href) {
850 const id = `call:${call.id}`
851 const parts = { Box, Client, Text }
852 return (
853 <Box key={`toggle-call-${n}-preview`} flexDirection="column" paddingLeft={3}>
854 {resultCard(
855 { Box, Text },
856 result,
857 { isOpen: flips.tools.includes(`code:${id}`), copy: code => copyBlock(parts, id, code), more: (open, hidden, bg) => moreToggle(parts, `code:${id}`, open, hidden, bg), Link, nerd: look.nerd, href, label: fileLabel(result.path, result.line) },
858 // The card sits inside the turn card's edge, the picked bar and its padding, and this preview's own padding.
859 width - 8,
860 'toggle-call-card',
861 )}
862 </Box>
863 )
864 }
865 const more = call.preview?.more ?? 0
866 const moreId = `preview:${call.id}`
867 const lineOf = (previewLine: { kind: string; text: string }) => `${previewLine.kind === 'add' ? '+ ' : previewLine.kind === 'del' ? '- ' : previewLine.kind === 'same' ? ' ' : ''}${previewLine.text}`
868 // A line wider than the card counts as hidden too: its control opens every line wrapped in full.
869 const isCut = more > 0 || (call.preview?.lines ?? []).some(previewLine => cells(lineOf(previewLine)) > width - 10)
870 const isAll = isCut && flips.tools.includes(moreId)
871 const shown = [...(call.preview?.lines ?? []), ...(isAll ? (call.preview?.rest ?? []) : [])]
872 const language = FILE_TOOLS.has(call.name) ? languageForPath(String(call.input?.file_path ?? '')) : undefined
873 const colored = previewPieces(language, shown, call.name === 'Read')
874 // The call line cuts a long command, so the open call shows it whole first.
875 const command = call.name === 'Bash' ? String(call.input?.command ?? '').split('\n').flatMap(commandLine => wrapWords(commandLine, width - 14)) : []
876 // A SendMessage's result only says it was sent, so the open call shows the message itself first.
877 const sent = call.name === 'SendMessage' ? String(call.input?.message ?? '').split('\n').flatMap(sentLine => wrapWords(sentLine, width - 12)) : []
878 return (
879 <Box key={`toggle-call-${n}-preview`} flexDirection="column" paddingLeft={3}>
880 {command.map((commandLine, at) => (
881 <Text key={`command-${at}`}>
882 <Text color={THEME.muted}>{at === 0 ? '$ ' : ' '}</Text>
883 {commandParts(commandLine).map((part, index) => (
884 <Text key={`part-${index}`} color={part.color ?? THEME.onSurface} bold={part.bold}>{part.text}</Text>
885 ))}
886 </Text>
887 ))}
888 {sent.map((sentLine, at) => <Text key={`sent-${at}`} color={THEME.onSurface}>{sentLine || ' '}</Text>)}
889 {shown.length === 0 ?<Text key="none" color={THEME.muted}>{text.previewEmpty}</Text> : null}
890 {shown.map((previewLine, at) => {
891 const background = previewLine.kind === 'add' ? { backgroundColor: THEME.addBg } : previewLine.kind === 'del' ? { backgroundColor: THEME.delBg } : {}
892 const parts = colored?.[at]
893 const row: TokenPiece[] = parts ? [{ text: parts.lead, color: previewLine.kind === 'out' ? THEME.muted : THEME.onSurface }, ...parts.code] : []
894 const body = (
895 <Text key={`line-${at}`} color={previewLine.kind === 'out' ? THEME.muted : THEME.onSurface} wrap={isAll ? 'wrap' : 'truncate-end'} {...background}>
896 {parts
897 ? (isAll ? row : cutPieces(row, width - 10)).map((piece, index) => (
898 <Text key={`piece-${index}`} color={piece.color} bold={piece.bold} italic={piece.italic} underline={piece.underline}>{piece.text}</Text>
899 ))
900 : (isAll ? lineOf(previewLine) : cut(lineOf(previewLine), width - 10)) || ' '}
901 </Text>
902 )
903 // The control sits on the last preview row, pinned right.
904 return isCut && at === shown.length - 1 ? rowWithMore({ Box }, `more-row-${call.id}`, body, moreToggle({ Client }, moreId, isAll, more, THEME.surface1)) : body
905 })}
906 </Box>
907 )
908 }
909 const calls = (kind: string) =>
910 kinds.find(group => group.kind === kind)?.calls.flatMap((call, n) => [
911 line(
912 `toggle-call-${n}`,
913 [
914 // The open call is a selected pill whose ends take the spaces around the label, so lines keep one width; the others stay dim until the pointer lights them.
915 <Box key={`toggle-call-${n}-line`} flexDirection="row" flexShrink={1}>
916 {look.nerd && openTools.includes(call.id) ? <Text color={OPEN_CALL}>{CAPS.left}</Text> : null}
917 <Box key={`toggle-call-${n}-pick`} flexShrink={1} {...(openTools.includes(call.id) ? { backgroundColor: OPEN_CALL } : {})}>
918 <Client
919 key={`toggle-call-${n}-colored`}
920 module="./label.tsx"
921 height={1}
922 width={partsWidth(callParts(call, openTools.includes(call.id), width))}
923 props={{ kind: 'tools', id: call.id, parts: callParts(call, openTools.includes(call.id), width), isDim: !openTools.includes(call.id), hover: THEME.accent } satisfies LabelProps}
924 />
925 </Box>
926 {look.nerd && openTools.includes(call.id) ? <Text color={OPEN_CALL}>{CAPS.right}</Text> : null}
927 </Box>,
928 countsPill({ Box, Text }, `toggle-call-${n}-counts`, call.added, call.removed),
929 ],
930 call.startedAt !== undefined && call.endedAt !== undefined ? elapsed(call.endedAt - call.startedAt) : '',
931 ),
932 ...(openTools.includes(call.id) ? [preview(call, n)] : []),
933 ]) ?? []
934 const body = !picked
935 ? null
936 : picked.kind === 'thinking'
937 ? thoughtLines.some(Boolean)
938 ? thoughtLines.map((thoughtLine, n) => <Text key={`toggle-thought-${n}`} color={THEME.muted}>{thoughtLine || ' '}</Text>)
939 : <Text key="toggle-thought-none" color={THEME.muted}>{text.thoughtNoText}</Text>
940 : calls(picked.kind)
941 return [
942 <Box key="toggle-chips" height={1}>
943 <Client key="toggle-chips-line" module="./chips.tsx" height={1} props={{ id: turn.prompt, chips, nerd: look.nerd, still } satisfies ChipsProps} />
944 </Box>,
945 body ? (
946 <Box key="toggle-picked" flexDirection="row">
947 <Box key="toggle-picked-bar" width={1} flexShrink={0} backgroundColor={bar} />
948 <Box key="toggle-picked-body" flexDirection="column" flexGrow={1} paddingLeft={1}>{body}</Box>
949 </Box>
950 ) : null,
951 ]
952}
953
954// The reply row marks the characters of this turn's choice while the pill hovers or has just copied it; null clears it.
955async function markCopy($: EngineInterface, id: string, what: string | null) {
956 await $.state.set({ plugin: 'mod-transcript', key: 'copy-hover' }, what ? `${id}\n${what}` : '')
957}
958
959// What `/mod-transcript <args>` does; the panel's own actions call it directly, since $.command.run skips the calling plugin.
960async function answer($: EngineInterface, args: string) {
961 if (args.trim() === 'settings') {
962 await toggleSettings($)
963 return ''
964 }
965 if (args.trim() === 'debug') {
966 const temp = ((await $.env.get('TEMP')) ?? (await $.env.get('TMPDIR')) ?? '/tmp').replace(/\\/g, '/')
967 const rows = [...seen].map(([row, isPlaced]) => `${isPlaced ? 'placed ' : 'unplaced'} ${row}`)
968 await $.fs.write(`${temp}/mod-transcript-ids.txt`, `${log.turns.length} turns\n${lastRead}\n${rows.join('\n')}\n`)
969 return `${rows.length} drawn rows written to ${temp}/mod-transcript-ids.txt`
970 }
971 const done = log.turns.filter(isDone)
972 let isAnyOpen = false
973 // Folding all clears the flips when turns fold by default, and flips every turn when they open by default.
974 await changeList($, 'turns', list => {
975 isAnyOpen = done.some(turn => isTurnOpen(list, turn))
976 return isAnyOpen !== foldTurns ? done.map(turn => turn.prompt) : []
977 })
978
979 return isAnyOpen ? text.noteFolded : text.noteUnfolded
980}
981
982async function detectLook($: EngineInterface) {
983 if (iconMode !== 'auto') return lookFor(iconMode === 'nerd')
984 const env = {
985 WT_SESSION: await $.env.get('WT_SESSION'),
986 TERM_PROGRAM: await $.env.get('TERM_PROGRAM'),
987 ITERM_PROFILE: await $.env.get('ITERM_PROFILE'),
988 KITTY_WINDOW_ID: await $.env.get('KITTY_WINDOW_ID'),
989 ALACRITTY_WINDOW_ID: await $.env.get('ALACRITTY_WINDOW_ID'),
990 GNOME_TERMINAL_SCREEN: await $.env.get('GNOME_TERMINAL_SCREEN'),
991 KONSOLE_VERSION: await $.env.get('KONSOLE_VERSION'),
992 }
993 const host = {
994 localAppData: ((await $.env.get('LOCALAPPDATA')) ?? '').replace(/\\/g, '/'),
995 home: ((await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '').replace(/\\/g, '/'),
996 read: (path: string) => $.fs.read(path).then(String),
997 run: async (argv: string[]) => {
998 const result = await $.process.run(argv, { timeoutMs: 5000 })
999 if (result.exitCode !== 0) throw new Error(result.stderr)
1000 return result.stdout
1001 },
1002 }
1003 return lookFor(await detectNerd(env, host))
1004}
1005
1006// Reading the terminal's settings takes a while, so on auto the last answer, kept in this mod's store, draws until it returns.
1007async function savedLook($: EngineInterface) {
1008 if (iconMode !== 'auto') return look
1009 try {
1010 const nerd = await $.store.get('look-nerd')
1011 return typeof nerd === 'boolean' ? lookFor(nerd) : look
1012 } catch {
1013 return look
1014 }
1015}
1016
1017async function saveLook($: EngineInterface) {
1018 try {
1019 await $.store.set('look-nerd', look.nerd)
1020 } catch {
1021 // Without a store the mod still draws; it only starts from safe again next time.
1022 }
1023}
1024
1025// The last answer draws until the check returns, so the first frame already has the right wedge.
1026async function loadWedge($: EngineInterface) {
1027 try {
1028 const isOutline = await $.store.get('wedge-outline')
1029 if (typeof isOutline === 'boolean') wedge = isOutline ? OUTLINE : FILLED
1030 } catch {
1031 // Without a store the wedge starts filled until the check returns.
1032 }
1033 const found = await detectWedge(await $.env.get('TERM_PROGRAM'), async argv => {
1034 const result = await $.process.run(argv, { timeoutMs: 5000 })
1035 if (result.exitCode !== 0) throw new Error(result.stderr)
1036 return result.stdout
1037 })
1038 const isChanged = found.right !== wedge.right
1039 wedge = found
1040 try {
1041 await $.store.set('wedge-outline', found === OUTLINE)
1042 } catch {
1043 // Without a store the check just runs again next time.
1044 }
1045 if (isChanged) $.ui.invalidate('ui.render')
1046}
1047
1048// The theme the code colors come from, read once per session when the first code is about to draw; the mod's own palette draws until it resolves, and when none does.
1049let themeLoad: Promise<void> | null = null
1050
1051const ensureTheme = ($: EngineInterface) => {
1052 void loadCodeTheme($).catch(() => {
1053 // A failed search keeps the palette's colors.
1054 })
1055}
1056
1057// The file as the card's link shows it: under the session folder it is relative to it; the line follows.
1058function fileLabel(path: string, line: number) {
1059 const slash = path.replace(/\\/g, '/')
1060 const base = paths.cwd.replace(/\/$/, '')
1061 const isUnder = base && slash.toLowerCase().startsWith(`${base.toLowerCase()}/`)
1062 return `${isUnder ? slash.slice(base.length + 1) : slash}:${line}`
1063}
1064
1065const homeOf = async ($: EngineInterface) => ((await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '').replace(/\\/g, '/')
1066
1067// The session's folder and home, read once per session, so a relative or ~ path in a reply resolves against them.
1068let rootsLoad: Promise<void> | null = null
1069const loadRoots = ($: EngineInterface) =>
1070 (rootsLoad ??= (async () => {
1071 paths.cwd = (await $.session.cwd().catch(() => '')).replace(/\\/g, '/')
1072 paths.home = await homeOf($).catch(() => '')
1073 })())
1074
1075// The machine as the theme finder reads it; null when no home folder is known.
1076async function themeHost($: EngineInterface): Promise<ThemeHost | null> {
1077 const home = await homeOf($)
1078 if (!home) return null
1079 return {
1080 home,
1081 appData: ((await $.env.get('APPDATA')) ?? '').replace(/\\/g, '/'),
1082 localAppData: ((await $.env.get('LOCALAPPDATA')) ?? '').replace(/\\/g, '/'),
1083 programFiles: ((await $.env.get('PROGRAMFILES')) ?? '').replace(/\\/g, '/'),
1084 read: (path: string) => $.fs.read(path).then(String),
1085 list: (path: string) => $.fs.list(path).then(entries => entries.filter(entry => entry.kind !== 'file').map(entry => entry.name)),
1086 }
1087}
1088
1089function loadCodeTheme($: EngineInterface) {
1090 themeLoad ??= (async () => {
1091 const host = await themeHost($)
1092 if (!host) return
1093 const saved = await Promise.resolve($.store.get('code-theme')).catch(() => undefined)
1094 const known = saved && typeof saved === 'object' && typeof (saved as any).label === 'string' && typeof (saved as any).path === 'string' ? (saved as { label: string; path: string }) : undefined
1095 const found = await loadTheme(host, codeTheme, known)
1096 if (!found) return
1097 useTheme(found)
1098 $.ui.invalidate('ui.render')
1099 await Promise.resolve($.store.set('code-theme', { label: found.label, path: found.path })).catch(() => {
1100 // Without a store the search just runs again next time.
1101 })
1102 })()
1103 return themeLoad
1104}
1105
1106// Hooks of one event run in turn, so the slow font check runs beside them instead of holding up every mod after this one.
1107let lookLoad: Promise<void> | null = null
1108
1109function loadLook($: EngineInterface) {
1110 lookLoad ??= (async () => {
1111 void loadWedge($).catch(() => {
1112 // A failed check keeps the wedge drawn now.
1113 })
1114 look = await savedLook($)
1115 void detectLook($).then(async found => {
1116 const isChanged = found.nerd !== look.nerd
1117 look = found
1118 await saveLook($)
1119 if (isChanged) $.ui.invalidate('ui.render')
1120 }).catch(() => {
1121 // A failed check keeps the icons drawn now.
1122 })
1123 })()
1124 return lookLoad
1125}
1126
1127// A time as a rounded pill; plain muted text where the font has no caps.
1128function timePill({ Box, Text }: { Box: (props: any) => RenderElement; Text: (props: any) => RenderElement }, key: string, time: string) {
1129 if (!look.nerd) return <Box key={key} flexShrink={0} height={1} alignSelf="flex-start"><Text color={THEME.muted}>{`${time} `}</Text></Box>
1130 // One row tall whatever the line's height: a filled body stretched by a taller row painted the row below.
1131 return (
1132 <Box key={key} flexDirection="row" flexShrink={0} height={1} alignSelf="flex-start">
1133 <Text color={THEME.surface3}>{CAPS.left}</Text>
1134 <Box key={`${key}-body`} height={1} backgroundColor={THEME.surface3}><Text color={THEME.muted}>{time}</Text></Box>
1135 <Text color={THEME.surface3}>{`${CAPS.right} `}</Text>
1136 </Box>
1137 )
1138}
1139
1140// A settings card's click: its row, saved through /config; a saved change reloads this mod.
1141async function saveSetting($: EngineInterface, key: string, value: ConfigValue) {
1142 const row = (await $.config.list()).find(each => each.key === key)
1143 if (!row) return
1144 const result = await $.config.set({ key: row.key, value })
1145 note = 'deny' in result && result.deny ? `${MARK.warn} ${result.deny}` : `${MARK.ok} ${fill(text.noteSettingSaved, { label: row.label })}`
1146 $.ui.invalidate('ui.render')
1147}
1148
1149const CODE_THEME = 'mod-transcript.code_theme'
1150const THEMES_MORE = 'themes-more'
1151// Past this many rows (Follow VS Code included) the list folds behind its expand control: a long extension folder would push every other card off screen.
1152const THEME_ROWS = 8
1153
1154// The Code theme card: Follow VS Code, then every installed theme; a click on a row saves it as the setting.
1155async function themeCard({ Box, Client, Text }: Pick<Rows, 'Box' | 'Client' | 'Text'>, $: EngineInterface, chosen: string) {
1156 themeList ??= (async () => {
1157 const host = await themeHost($)
1158 return host ? { labels: await listThemes(host), active: await activeLabel(host) } : { labels: [], active: undefined }
1159 })()
1160 const found = await themeList.catch(() => ({ labels: [] as string[], active: undefined }))
1161 const all = themeRows(found.labels, found.active, chosen)
1162 const shown = isThemesOpen ? all : all.slice(0, THEME_ROWS)
1163 const hidden = all.length - shown.length
1164 const arrow = isThemesOpen ? look.glyph.less : look.glyph.more
1165 const label = isThemesOpen ? text.themeLess : fill(text.themeMore, { n: hidden })
1166 const list = <Client key="themes-list" module="./themes.tsx" width="100%" height={shown.length} props={{ rows: shown, glyph: look.glyph.ok, tip: { glyph: look.glyph.ok, text: text.tipTheme }, still } satisfies ThemesProps} />
1167 const more =
1168 hidden > 0 || isThemesOpen ? (
1169 <Client
1170 key={THEMES_MORE}
1171 module="./entry.tsx"
1172 width={cells(label) + 4}
1173 height={1}
1174 props={{ id: THEMES_MORE, label, lead: arrow, leadColor: THEME.accent, isOpen: false, bg: cardFill(), idle: THEME.muted, nerd: look.nerd, still, tip: { glyph: arrow, label, text: isThemesOpen ? text.tipThemeLess : text.tipThemeMore } } satisfies EntryProps}
1175 />
1176 ) : null
1177 return section({ Box, Text }, look, 'themes', 'icons', text.themeSection, [list, more])
1178}
1179
1180export const register: Register = (on, options) => {
1181 iconMode = (options.icons as IconMode | undefined) ?? 'auto'
1182 codeTheme = typeof options.code_theme === 'string' ? options.code_theme : ''
1183 themeLoad = null
1184 useTheme(null)
1185 look = lookFor(iconMode === 'nerd')
1186 foldTurns = options.fold_turns !== false
1187 foldTools = options.fold_tools !== false
1188 showTimes = options.timestamps !== false
1189 expandLive = options.expand_live !== false
1190 showCopy = options.copy_pill !== false
1191 showNumbers = options.prompt_numbers !== false
1192 still = options.animations === false
1193 toasts.isStill = still
1194
1195 // This mod's toasts draw as cards in the band; Claude Code's own box is narrow and cuts them.
1196 on('ui.toast', async ($, e) => {
1197 const id = toasts.add(e.text)
1198 $.ui.invalidate('ui.render')
1199 $.clock.after(e.timeoutMs ?? TOAST_MS, () => {
1200 toasts.drop(id)hooks/cells.ts 68 lines1// hooks/cells.ts, by v-amorim
2// Terminal cells: East Asian wide and emoji code points take two, combining marks and joiners none, anything else one.
3const WIDE: [number, number][] = [
4 [0x1100, 0x115f],
5 [0x2e80, 0x303e],
6 [0x3041, 0x33ff],
7 [0x3400, 0x4dbf],
8 [0x4e00, 0x9fff],
9 [0xa000, 0xa4cf],
10 [0xac00, 0xd7a3],
11 [0xf900, 0xfaff],
12 [0xfe30, 0xfe4f],
13 [0xff00, 0xff60],
14 [0xffe0, 0xffe6],
15 [0x1f300, 0x1faff],
16 [0x20000, 0x3fffd],
17]
18
19const ZERO: [number, number][] = [
20 [0x0300, 0x036f],
21 [0x200b, 0x200d],
22 [0xfe00, 0xfe0f],
23]
24
25const within = (code: number, ranges: [number, number][]) => ranges.some(([from, to]) => code >= from && code <= to)
26
27export const cellOf = (char: string) => {
28 const code = char.codePointAt(0)!
29 if (within(code, ZERO)) return 0
30 return within(code, WIDE) ? 2 : 1
31}
32
33export const cells = (text: string) => {
34 let sum = 0
35 for (const char of text) sum += cellOf(char)
36 return sum
37}
38
39// The text cut to `width` cells, its last cell an ellipsis when it had to cut; spaces stay as written.
40export function cut(text: string, width: number) {
41 if (cells(text) <= width) return text
42 let out = ''
43 let used = 0
44 for (const char of text) {
45 const next = used + cellOf(char)
46 if (next > width - 1) break
47 out += char
48 used = next
49 }
50 return `${out}…`
51}
52
53// One line, runs of whitespace joined, cut to `width` cells.
54export const fit = (text: string, width: number) => cut(text.replace(/\s+/g, ' '), width)
55
56// A Text inside a card overflows instead of wrapping, so long lines break at word boundaries here.
57export function wrapWords(line: string, width: number) {
58 const rows: string[] = []
59 let row = ''
60 for (const word of line.split(' ')) {
61 if (row && cells(row) + 1 + cells(word) > width) {
62 rows.push(row)
63 row = word
64 } else row = row ? `${row} ${word}` : word
65 }
66 return [...rows, row]
67}
68hooks/bar.tsx 33 lines1// mod-transcript / hooks/bar.tsx, by v-amorim
2// Draws a card's left strip that folds its turn or tools on click.
3import type { ClientModule } from 'claude-code'
4
5import { postTip } from './entry'
6import text from './text'
7
8export type BarProps = { kind: 'turns' | 'tools'; id: string }
9
10export type BarMessage = { type: 'fold' } & BarProps
11
12// Per instance, so a tip posts only when hover changes.
13const isTipped = new WeakMap<object, boolean>()
14
15const Bar: ClientModule<BarProps> = (props, surface) => {
16 const { Box } = surface.elements
17
18 surface.onPointer(event => {
19 if (event.type === 'down' && event.button === 'left') {
20 surface.post({ type: 'fold', ...props } satisfies BarMessage)
21 return
22 }
23 const over = event.type !== 'leave'
24 if (over === (isTipped.get(surface) ?? false)) return
25 isTipped.set(surface, over)
26 postTip(surface, `bar-${props.id}`, over ? { glyph: '', label: text.tipLabel[props.kind], text: text.tipBar[props.kind] } : null)
27 })
28
29 return <Box width={1} height="100%" />
30}
31
32export default Bar
33hooks/chips.tsx 129 lines1// mod-transcript / hooks/chips.tsx, by v-amorim
2// Draws clickable tool chips with marks, counts and added or removed line totals.
3import type { ClientModule } from 'claude-code'
4
5import { cells } from './cells'
6import { HOVER_STEPS, HOVER_TICK_MS, hoverMix, postTip, stepToward } from './entry'
7import style from './style'
8import text from './text'
9import { CAPS, mix, THEME, WASH } from './theme'
10
11export type Chip = { kind: keyof typeof style.colors.kinds; mark: string; label: string; count: number; added: number; removed: number; isOpen: boolean }
12
13export type ChipsProps = { id: string; chips: Chip[]; nerd: boolean; still: boolean }
14
15export type ChipsMessage = { type: 'chip'; id: string; kind: Chip['kind'] }
16
17// over: the chip under the pointer; levels: each chip's hover fade, 0 at rest.
18type State = { over: Chip['kind'] | null; at: number; levels: Partial<Record<Chip['kind'], number>> }
19
20// A pointer that left without a leave event stops lighting the chip after this long.
21const QUIET_MS = 5000
22
23const stat = (chip: Chip) => [chip.added ? `+${chip.added}` : '', chip.removed ? `−${chip.removed}` : ''].filter(Boolean).join(' ')
24
25// Cells a chip takes: its two ends, mark, label, the count badge with its two ends, and the counts after it.
26const widthOf = (chip: Chip) => 1 + cells(chip.mark) + 1 + cells(chip.label) + 1 + (2 + String(chip.count).length) + (stat(chip) ? 1 + cells(stat(chip)) : 0) + 1
27
28// The chip under the pointer, per instance, so a tip posts only when it changes.
29const under = new WeakMap<object, string | null>()
30
31const latest = new WeakMap<object, ChipsProps>()
32
33// Each line's timer runs from the pointer's first event until every chip is at rest again.
34const timers = new WeakMap<object, () => void>()
35
36type Surface = Parameters<ClientModule<ChipsProps, State>>[1]
37
38const wake = (surface: Surface) => {
39 if (timers.has(surface)) return
40 const stop = surface.every(HOVER_TICK_MS, () => {
41 const { state } = surface
42 if (!state) return
43 const levels = { ...state.levels }
44 let isMoving = false
45 for (const kind of Object.keys(levels) as Chip['kind'][]) {
46 const next = stepToward(levels[kind] ?? 0, state.over === kind)
47 if (next !== levels[kind]) isMoving = true
48 levels[kind] = next
49 }
50 if (isMoving) surface.setState({ ...state, levels })
51 else if (state.over && Date.now() - state.at >= QUIET_MS) surface.setState({ ...state, over: null })
52 else if (!state.over) {
53 stop()
54 timers.delete(surface)
55 }
56 })
57 timers.set(surface, stop)
58}
59
60const Chips: ClientModule<ChipsProps, State> = (props, surface) => {
61 const { Box, Text } = surface.elements
62 const { chips, nerd } = props
63 latest.set(surface, props)
64 const state = surface.state ?? { over: null, at: 0, levels: {} }
65 if (!surface.state) surface.setState(state)
66 // Without a Nerd Font the pill ends are plain spaces in the pill's color.
67 const left = nerd ? CAPS.left : ' '
68 const right = nerd ? CAPS.right : ' '
69
70 surface.onPointer(event => {
71 wake(surface)
72 let hit: Chip | undefined
73 let x = 0
74 for (const chip of chips) {
75 const width = widthOf(chip)
76 if (event.x >= x && event.x < x + width) hit = chip
77 x += width + 1
78 }
79 const base = surface.state ?? state
80 const over = event.type === 'leave' ? null : (hit?.kind ?? null)
81 if (over !== base.over) {
82 const levels = { ...base.levels }
83 if (over) levels[over] = HOVER_STEPS
84 if (base.over && latest.get(surface)?.still) levels[base.over] = 0
85 surface.setState({ over, at: Date.now(), levels })
86 }
87 if (event.type === 'down' && event.button === 'left') {
88 if (hit) surface.post({ type: 'chip', id: props.id, kind: hit.kind } satisfies ChipsMessage)
89 return
90 }
91 if (over === (under.get(surface) ?? null)) return
92 under.set(surface, over)
93 postTip(surface, `chip-${props.id}`, hit && over ? { glyph: '', label: hit.label, text: text.tipChip[hit.kind] } : null)
94 })
95
96 return (
97 <Box flexDirection="row" columnGap={1}>
98 {chips.map(chip => {
99 const fill = chip.isOpen ? THEME.surface3 : THEME.surface2
100 const { hue } = style.colors.kinds[chip.kind]
101 const wash = mix(hue, fill, WASH)
102 const label = hoverMix(chip.isOpen ? THEME.onSurface : THEME.muted, THEME.accent, state.levels[chip.kind] ?? 0)
103 const cap = (side: string, key: string, color: string) => nerd ? <Text key={key} color={color}>{side}</Text> : <Box key={key} backgroundColor={color}><Text> </Text></Box>
104 return (
105 <Box key={chip.kind} flexDirection="row" flexShrink={0}>
106 {cap(left, `${chip.kind}-left`, fill)}
107 <Box key={`${chip.kind}-body`} flexDirection="row" backgroundColor={fill}>
108 <Text color={hue}>{`${chip.mark} `}</Text>
109 <Text color={label} bold={chip.isOpen}>{`${chip.label} `}</Text>
110 {cap(left, `${chip.kind}-badge-left`, wash)}
111 <Box key={`${chip.kind}-badge`} backgroundColor={wash}><Text color={hue} bold>{String(chip.count)}</Text></Box>
112 {cap(right, `${chip.kind}-badge-right`, wash)}
113 {stat(chip) ? (
114 <Text key={`${chip.kind}-stat`}>
115 <Text color={THEME.good}>{chip.added ? ` +${chip.added}` : ''}</Text>
116 <Text color={THEME.alert}>{chip.removed ? ` −${chip.removed}` : ''}</Text>
117 </Text>
118 ) : null}
119 </Box>
120 {cap(right, `${chip.kind}-right`, fill)}
121 </Box>
122 )
123 })}
124 </Box>
125 )
126}
127
128export default Chips
129hooks/copy.tsx 155 lines1// mod-transcript / hooks/copy.tsx, by v-amorim
2// Draws the copy pill whose choices copy parts of a reply on click.
3import type { ClientModule } from 'claude-code'
4
5import type { Tip, TipMessage } from './entry'
6import { subpill } from './look'
7import text from './text'
8import { CAPS, fill, THEME } from './theme'
9
10export type CopyMessage = { type: 'copy'; id: string; what: string }
11
12// The choice whose characters the reply row marks while it is hovered or just copied; `what` is null when none is.
13export type CopyHoverMessage = { type: 'copy-hover'; id: string; what: string | null }
14
15// One post lands per frame, so a hover change that comes with a tip change rides on the tip message.
16export type CopyTipMessage = TipMessage & { mark?: CopyHoverMessage }
17
18// copied: the note a click leaves, {what} the choice's label; lead: what fills the cells left of the pill, the edge's line.
19export type CopyProps = { id: string; choices: { what: string; label: string }[]; icon: string; copied: string; bg: string; nerd: boolean; lead: { char: string; color: string } }
20
21// over: -2 off the pill, -1 on it but on no choice, else the choice under the pointer.
22type State = { over: number; at: number; note: string; noteAt: number; noteWhat?: string }
23
24const TICK_MS = 250
25// A dialog opening under the pointer can swallow the leave event, so a hover with no pointer event for this long ends on its own.
26const QUIET_MS = 5000
27const NOTE_MS = 1500
28
29// The part under the pointer (a choice's `what`, 'icon', or none), per instance, so a tip posts only when it changes.
30const under = new WeakMap<object, string | null>()
31
32// The choice last announced to the reply row, so a hover message posts only when it changes.
33const marked = new WeakMap<object, string>()
34
35type Surface = Parameters<ClientModule<CopyProps, State>>[1]
36
37// The choice the reply row should mark for a state: the one under the pointer, or the one a click just copied while its note shows.
38const markOf = (props: Pick<CopyProps, 'choices'>, state: State) =>
39 state.note && Date.now() - state.noteAt < NOTE_MS ? (state.noteWhat ?? '') : state.over >= 0 ? (props.choices[state.over]?.what ?? '') : ''
40
41const markChange = (surface: Surface, props: Pick<CopyProps, 'id' | 'choices'>, state: State): CopyHoverMessage | null => {
42 const what = markOf(props, state)
43 if (what === (marked.get(surface) ?? '')) return null
44 marked.set(surface, what)
45 return { type: 'copy-hover', id: props.id, what: what || null }
46}
47
48const tipMessage = (id: string, tip: Tip | null, mark: CopyHoverMessage | null): CopyTipMessage => ({
49 ...(tip ? { type: 'tip', id, glyph: tip.glyph, label: tip.label, text: tip.text } : { type: 'tip', id: null }),
50 ...(mark ? { mark } : {}),
51})
52
53// The cells the open pill takes; the row gives the Client this width, since the pointer reaches only the cells laid out for it.
54export const copyWidth = (props: Pick<CopyProps, 'choices' | 'copied' | 'nerd'>) => {
55 const open = props.choices.reduce((sum, choice) => sum + choice.label.length + 2, 0)
56 const note = Math.max(0, ...props.choices.map(choice => fill(props.copied, { what: choice.label }).length + 2))
57 return (props.nerd ? 2 : 0) + Math.max(open, note) + 3
58}
59
60// Each pill's timer runs from the pointer's first event until the pill is at rest again: every reply of a long session ticking kept the process busy.
61const timers = new WeakMap<object, () => void>()
62
63const wake = (surface: Surface, props: CopyProps) => {
64 if (timers.has(surface)) return
65 const stop = surface.every(TICK_MS, () => {
66 const now = surface.state
67 if (!now) return
68 if (now.note && Date.now() - now.noteAt >= NOTE_MS) {
69 const ended = { ...now, note: '', noteWhat: '' }
70 surface.setState(ended)
71 const change = markChange(surface, props, ended)
72 if (change) surface.post(change)
73 } else if (now.over !== -2 && Date.now() - now.at >= QUIET_MS) {
74 const quiet = { ...now, over: -2 }
75 surface.setState(quiet)
76 const change = markChange(surface, props, quiet)
77 if (change) surface.post(change)
78 } else if (!now.note && now.over === -2) {
79 stop()
80 timers.delete(surface)
81 }
82 })
83 timers.set(surface, stop)
84}
85
86// At rest only the icon at the right end; the pointer opens the choices to its left, so the icon stays put, and lights the one under it.
87const Copy: ClientModule<CopyProps, State> = (props, surface) => {
88 const { Box, Text } = surface.elements
89 const state = surface.state ?? { over: -2, at: 0, note: '', noteAt: 0 }
90 const hasNote = Boolean(state.note) && Date.now() - state.noteAt < NOTE_MS
91 const isExpanded = state.over !== -2 && !hasNote
92 const caps = props.nerd ? 2 : 0
93 const bodyWidth = hasNote ? state.note.length + 2 : isExpanded ? props.choices.reduce((sum, choice) => sum + choice.label.length + 2, 0) : 0
94 const lead = Math.max(0, (surface.columns || copyWidth(props)) - caps - bodyWidth - 3)
95
96 if (!surface.state) surface.setState(state)
97
98 const choiceAt = (x: number) => {
99 let start = lead + (props.nerd ? 1 : 0)
100 for (const [index, choice] of props.choices.entries()) {
101 const end = start + choice.label.length + 2
102 if (x >= start && x < end) return index
103 start = end
104 }
105 return -1
106 }
107
108 surface.onPointer(event => {
109 wake(surface, props)
110 // While folded the choices are not where they will be, so the first event on the pill only opens it; the lead never does.
111 const next = event.type === 'leave' || (!isExpanded && event.x < lead) ? -2 : isExpanded ? choiceAt(event.x) : -1
112 if (event.type === 'down' && event.button === 'left' && next >= 0) {
113 const choice = props.choices[next]!
114 surface.post({ type: 'copy', id: props.id, what: choice.what } satisfies CopyMessage)
115 const copied = { over: -2, at: Date.now(), note: fill(props.copied, { what: choice.label }), noteAt: Date.now(), noteWhat: choice.what }
116 surface.setState(copied)
117 markChange(surface, props, copied)
118 return
119 }
120 const moved = { ...state, over: next, at: Date.now() }
121 surface.setState(moved)
122 const change = markChange(surface, props, moved)
123 const now = next >= 0 ? props.choices[next]!.what : next === -1 ? 'icon' : null
124 if (now === (under.get(surface) ?? null)) {
125 if (change) surface.post(change)
126 return
127 }
128 under.set(surface, now)
129 const choice = props.choices.find(each => each.what === now)
130 const tip = choice
131 ? { glyph: '', label: fill(text.tipCopyChoiceLabel, { what: choice.label }), text: text.tipCopyChoice[choice.what as keyof typeof text.tipCopyChoice] }
132 : now === 'icon' ? { glyph: '', label: text.tipCopyLabel, text: text.tipCopyIcon } : null
133 surface.post(tipMessage(`copy-${props.id}`, tip, change))
134 })
135
136 const body = hasNote
137 ? [<Text key="note" color={THEME.muted} backgroundColor={props.bg}>{` ${state.note} `}</Text>]
138 : isExpanded
139 ? props.choices.map((choice, index) => (
140 <Text key={`choice-${choice.what}`} color={index === state.over ? THEME.accent : THEME.pill} backgroundColor={props.bg}>{` ${choice.label} `}</Text>
141 ))
142 : []
143 return (
144 <Box flexDirection="row">
145 {lead ? <Text key="lead" color={props.lead.color}>{props.lead.char.repeat(lead)}</Text> : null}
146 {props.nerd ? <Text key="cap-left" color={props.bg}>{CAPS.left}</Text> : null}
147 {body}
148 {subpill({ Box, Text }, props.nerd, props.bg, props.icon, 'copy-icon', isExpanded || hasNote ? THEME.muted : THEME.accent)}
149 {props.nerd ? <Text key="cap-right" color={props.bg}>{CAPS.right}</Text> : null}
150 </Box>
151 )
152}
153
154export default Copy
155hooks/edge.tsx 23 lines1// mod-transcript / hooks/edge.tsx, by v-amorim
2// Draws one row's share of a reply bubble's top, bottom or side border.
3import type { ClientModule } from 'claude-code'
4
5import { MARK } from './theme'
6
7// A Box border is all four sides or none, and a reply's bubble spans many rows, so each row draws its own share.
8export type EdgeProps = { kind: 'top' | 'bottom' | 'side'; color: string; wedge?: string; open?: 'start' | 'end' } // open: a bottom edge cut there, for the copy pill
9
10const Edge: ClientModule<EdgeProps, number> = (props, surface) => {
11 const { Text } = surface.elements
12 const size = props.kind === 'top' || props.kind === 'bottom' ? surface.columns : surface.rows
13 // The first call comes before layout; one more call once the size is known, and again only when it changes.
14 if (size && surface.state !== size) surface.setState(size)
15 const run = Math.max(0, size - 2)
16 const line = MARK.rule.repeat(run)
17 if (props.kind === 'top') return <Text color={props.color}>{`${props.wedge ?? '╭'}${line}╮`}</Text>
18 if (props.kind === 'bottom') return <Text color={props.color}>{`${props.open === 'start' ? MARK.rule : '╰'}${line}${props.open === 'end' ? MARK.rule : '╯'}`}</Text>
19 return <Text color={props.color}>{Array.from({ length: Math.max(1, size) }, () => MARK.rail).join('\n')}</Text>
20}
21
22export default Edge
23hooks/dot.tsx 20 lines1// mod-transcript / hooks/dot.tsx, by v-amorim
2// Draws a running command's braille spinner until it finishes.
3import type { ClientModule } from 'claude-code'
4
5import { SPIN_FRAMES, SPIN_MS } from './entry'
6
7export type DotProps = { color: string; still: boolean }
8
9// The running command's spinner, the same braille dots as every running label; a still one holds its first frame.
10const Dot: ClientModule<DotProps, number> = (props, surface) => {
11 const { Text } = surface.elements
12 if (surface.state === undefined) {
13 surface.setState(0)
14 if (!props.still) surface.every(SPIN_MS, () => surface.setState(((surface.state ?? 0) + 1) % SPIN_FRAMES.length))
15 }
16 return <Text color={props.color}>{SPIN_FRAMES[surface.state ?? 0]}</Text>
17}
18
19export default Dot
20hooks/fold.tsx 67 lines1// mod-transcript / hooks/fold.tsx, by v-amorim
2// Draws the fold pill whose mark lights under the pointer and folds on click.
3import type { ClientModule } from 'claude-code'
4
5import type { BarMessage, BarProps } from './bar'
6import { HOVER_STEPS, HOVER_TICK_MS, hoverMix, postTip, stepToward } from './entry'
7import text from './text'
8import { CAPS, THEME } from './theme'
9
10// color: the mark's color at rest; glyphs: the marks of an open and a closed line, from the look.
11export type FoldProps = BarProps & { color: string; isOpen: boolean; nerd: boolean; still: boolean; glyphs: { open: string; closed: string } }
12
13type State = { isOver: boolean; at: number; level: number }
14
15// A pointer that left without a leave event stops lighting the pill after this long.
16const QUIET_MS = 5000
17
18const latest = new WeakMap<object, FoldProps>()
19
20// Each pill's timer runs from the pointer's first event until the pill is at rest again: every folded turn of a long session ticking kept the process busy.
21const timers = new WeakMap<object, () => void>()
22
23const wake = (surface: Parameters<ClientModule<FoldProps, State>>[1]) => {
24 if (timers.has(surface)) return
25 const stop = surface.every(HOVER_TICK_MS, () => {
26 const { state } = surface
27 if (!state) return
28 if (state.level !== (state.isOver ? HOVER_STEPS : 0)) surface.setState({ ...state, level: stepToward(state.level, state.isOver) })
29 else if (state.isOver && Date.now() - state.at >= QUIET_MS) surface.setState({ ...state, isOver: false })
30 else if (!state.isOver) {
31 stop()
32 timers.delete(surface)
33 }
34 })
35 timers.set(surface, stop)
36}
37
38// The fold chip on a surface3 pill: a Button would invert only its own cell under the pointer, never the caps.
39const Fold: ClientModule<FoldProps, State> = (props, surface) => {
40 const { Box, Text } = surface.elements
41 latest.set(surface, props)
42
43 if (!surface.state) surface.setState({ isOver: false, at: 0, level: 0 })
44
45 surface.onPointer(event => {
46 wake(surface)
47 const over = event.type !== 'leave'
48 const base = surface.state ?? { isOver: false, at: 0, level: 0 }
49 const level = over === base.isOver ? base.level : latest.get(surface)?.still ? (over ? HOVER_STEPS : 0) : over ? HOVER_STEPS : base.level
50 surface.setState({ isOver: over, at: Date.now(), level })
51 if (event.type === 'down' && event.button === 'left') surface.post({ type: 'fold', kind: props.kind, id: props.id } satisfies BarMessage)
52 else if (over !== base.isOver) postTip(surface, `fold-${props.id}`, over ? { glyph: '', label: text.tipLabel[props.kind], text: text.tipFold[props.kind] } : null)
53 })
54
55 const mark = hoverMix(props.color, THEME.accent, surface.state?.level ?? 0)
56 const glyph = props.isOpen ? props.glyphs.open : props.glyphs.closed
57 return (
58 <Box flexDirection="row">
59 {props.nerd ? <Text color={THEME.surface3}>{CAPS.left}</Text> : null}
60 <Text color={mark} backgroundColor={THEME.surface3} bold>{props.nerd ? glyph : ` ${glyph} `}</Text>
61 {props.nerd ? <Text color={THEME.surface3}>{CAPS.right}</Text> : null}
62 </Box>
63 )
64}
65
66export default Fold
67hooks/label.tsx 37 lines1// mod-transcript / hooks/label.tsx, by v-amorim
2// Draws a fold line's colored label that folds its section on click.
3import type { ClientModule } from 'claude-code'
4
5import type { BarMessage, BarProps } from './bar'
6import { postTip } from './entry'
7import text from './text'
8
9// One run of a label: uncolored parts take the line's own color, dim at rest and `hover` under the pointer.
10export type LabelPart = { text: string; color?: string; bold?: boolean }
11
12export type LabelProps = BarProps & { parts: LabelPart[]; isDim: boolean; hover: string; tip?: { label: string; text: string } }
13
14// A Button label is one plain string, so a colored label is a Client that folds on click.
15const Label: ClientModule<LabelProps, boolean> = (props, surface) => {
16 const { Text } = surface.elements
17 surface.onPointer(event => {
18 const isOver = event.type !== 'leave'
19 const changed = isOver !== (surface.state === true)
20 if (changed) surface.setState(isOver)
21 if (event.type === 'down' && event.button === 'left') surface.post({ type: 'fold', kind: props.kind, id: props.id } satisfies BarMessage)
22 else if (changed) postTip(surface, `label-${props.id}`, isOver ? { glyph: '', label: props.tip?.label ?? text.tipLabel[props.kind], text: props.tip?.text ?? text.tipFold[props.kind] } : null)
23 })
24 const isOver = surface.state === true
25 return (
26 <Text wrap="truncate-end">
27 {props.parts.map((part, n) => (
28 <Text key={`part-${n}`} color={part.color ?? (isOver ? props.hover : undefined)} bold={part.bold} dimColor={!part.color && props.isDim && !isOver}>
29 {part.text}
30 </Text>
31 ))}
32 </Text>
33 )
34}
35
36export default Label
37hooks/number.tsx 38 lines1// mod-transcript / hooks/number.tsx, by v-amorim
2// Draws the prompt number pill and measures its width in cells.
3import type { ClientModule } from 'claude-code'
4
5import { postTip } from './entry'
6import text from './text'
7import { CAPS, fill, mix, THEME, WASH } from './theme'
8
9export type NumberProps = { id: string; n: number; nerd: boolean }
10
11// The pill's cells: both ends (a space each without caps) around `#N`.
12export const pillWidth = (n: number) => fill(text.promptNumber, { n }).length + 2
13
14const isTipped = new WeakMap<object, boolean>()
15
16const PromptNumber: ClientModule<NumberProps> = (props, surface) => {
17 const { Box, Text } = surface.elements
18
19 surface.onPointer(event => {
20 const over = event.type !== 'leave'
21 if (over === (isTipped.get(surface) ?? false)) return
22 isTipped.set(surface, over)
23 postTip(surface, `prompt-${props.id}`, over ? { glyph: '', label: fill(text.tipPromptLabel, { n: props.n }), text: fill(text.tipPrompt, { n: props.n }) } : null)
24 })
25
26 const wash = mix(THEME.accent, THEME.bg, WASH)
27 const label = fill(text.promptNumber, { n: props.n })
28 return (
29 <Box flexDirection="row" paddingTop={1}>
30 {props.nerd ? <Text color={wash} backgroundColor={THEME.bg}>{CAPS.left}</Text> : null}
31 <Text color={THEME.accent} backgroundColor={wash} bold>{props.nerd ? label : ` ${label} `}</Text>
32 {props.nerd ? <Text color={wash} backgroundColor={THEME.bg}>{CAPS.right}</Text> : null}
33 </Box>
34 )
35}
36
37export default PromptNumber
38hooks/blocks.tsx 201 lines1// mod-transcript / hooks/blocks.tsx, by v-amorim
2// The reply blocks the mod draws itself: code and diff cards, headings on a rule, callout cards, status tags.
3import type { RenderElement } from 'claude-code'
4
5import { cellOf } from './cells'
6import { chunkPieces, diffLanguage, highlight, type Piece } from './hilite'
7import type { Paint, Part } from './mark'
8import { inline, tinted, type Inline } from './prose'
9import text from './text'
10import { MARK, mix, THEME } from './theme'
11import style from './style'
12
13type Elements = { Box: (props: any) => RenderElement; Text: (props: any) => RenderElement }
14
15// Rounded card corners; style.ts is a shared copy and has none, so they live here.
16export const CORNER = { topLeft: '╭', topRight: '╮', bottomLeft: '╰', bottomRight: '╯' }
17
18// A code block taller than this shows its last rows and an expand control (my call: about a screen's third).
19export const CODE_CAP = 12
20
21const DIFF_LANG = /^(diff|patch)$/i
22
23export type Fence = { lang: string; code: string }
24
25// What the card needs from the reply row: the copy pill and the expand control are Clients, which only it can draw.
26export type CardParts = {
27 copy: (code: string) => unknown
28 more: (isOpen: boolean, hidden: number, bg: string) => unknown
29 isOpen: boolean
30 paint?: Paint
31}
32
33// A line cut into rows of at most `room` cells; an empty line is one empty row.
34export function chunks(line: string, room: number) {
35 const rows: string[] = []
36 let row = ''
37 let used = 0
38 for (const char of line.replace(/\t/g, ' ')) {
39 if (used + cellOf(char) > room && row) {
40 rows.push(row)
41 row = ''
42 used = 0
43 }
44 row += char
45 used += cellOf(char)
46 }
47 return [...rows, row]
48}
49
50// A diff line's kind: the added and removed lines carry their mark in the gutter, headers and hunks sit dim.
51function diffKind(line: string) {
52 if (/^(\+\+\+|---|@@|diff |index )/.test(line)) return 'head'
53 if (line.startsWith('+')) return 'add'
54 if (line.startsWith('-')) return 'del'
55 return 'same'
56}
57
58export const WASH_ADD = mix(THEME.surface1, THEME.good, 0.16)
59export const WASH_DEL = mix(THEME.surface1, THEME.alert, 0.22)
60
61export function codeCard({ Box, Text }: Elements, fence: Fence, parts: CardParts, width: number, key: string) {
62 const isDiff = DIFF_LANG.test(fence.lang)
63 const lines = fence.code.replace(/\n+$/, '').split('\n')
64 const isCapped = lines.length > CODE_CAP
65 const shown = isCapped && !parts.isOpen ? lines.slice(-CODE_CAP) : lines
66 const first = lines.length - shown.length
67 const digits = String(lines.length).length
68 const gutter = isDiff ? 2 : digits + 3
69 const room = Math.max(1, width - 4 - gutter)
70 const tag = isDiff ? text.diffTag : fence.lang.toUpperCase()
71 const { paint } = parts
72 // A hovered copy choice dims the card or washes its fill; the diff rows keep their own tints while marked.
73 const c = (color: string) => tinted(paint, color, 'code')!
74 const fillOf = (base: string, isPlain: boolean) => (!paint ? base : paint.on('code') ? (isPlain ? paint.fill(base, 'code') : base) : paint.fill(base, 'code'))
75 const edge = (side: string, body: unknown, id: string) => (
76 <Box key={`${key}-${id}`} flexDirection="row">
77 <Text color={c(THEME.border)}>{MARK.rail}</Text>
78 <Box key={`${key}-${id}-in`} width={width - 2} backgroundColor={side} paddingX={1} flexDirection="row">{body}</Box>
79 <Text color={c(THEME.border)}>{MARK.rail}</Text>
80 </Box>
81 )
82 const bodyOf = (line: string) => {
83 const kind = isDiff ? diffKind(line) : 'same'
84 return { kind, body: isDiff && (kind === 'add' || kind === 'del') ? line.slice(1) : line }
85 }
86 // The whole block is tokenized, so a comment opened above the rows shown still colors them; a diff header line is left out of the code.
87 const colors = highlight(isDiff ? diffLanguage(fence.code) : fence.lang, lines.map(line => (bodyOf(line).kind === 'head' ? '' : bodyOf(line).body)).join('\n'))
88 const rows = shown.flatMap((line, at) => {
89 const { kind, body } = bodyOf(line)
90 const bg = fillOf(kind === 'add' ? WASH_ADD : kind === 'del' ? WASH_DEL : THEME.surface1, kind !== 'add' && kind !== 'del')
91 const mark = kind === 'add' ? { char: '+', color: c(THEME.good) } : kind === 'del' ? { char: '−', color: c(THEME.alert) } : { char: ' ', color: c(style.colors.pills.readout) }
92 const pieces = kind === 'head' ? undefined : colors?.[first + at]
93 const wrapped: (string | Piece[])[] = pieces ? chunkPieces(pieces, room) : chunks(body, room)
94 return wrapped.map((chunk, part) => (
95 edge(
96 bg,
97 <Text key={`${key}-text-${at}-${part}`} wrap="truncate-end">
98 {isDiff ? <Text color={mark.color}>{`${part ? ' ' : mark.char} `}</Text> : <Text color={c(style.colors.pills.readout)}>{part ? ' '.repeat(digits) : String(first + at + 1).padStart(digits)}</Text>}
99 {isDiff ? null : <Text color={c(THEME.border)}>{` ${MARK.rail} `}</Text>}
100 {typeof chunk === 'string' ? (
101 <Text color={c(kind === 'head' ? style.colors.pills.readout : THEME.onSurface)}>{chunk || ' '}</Text>
102 ) : chunk.length ? (
103 chunk.map((piece, index) => <Text key={`${key}-piece-${at}-${part}-${index}`} color={tinted(paint, piece.color, 'code')} bold={piece.bold} italic={piece.italic} underline={piece.underline}>{piece.text}</Text>)
104 ) : (
105 <Text color={c(THEME.onSurface)}> </Text>
106 )}
107 </Text>,
108 `row-${at}-${part}`,
109 )
110 ))
111 })
112 const run = Math.max(1, width)
113 return (
114 <Box key={key} flexDirection="column">
115 <Box key={`${key}-top`} flexDirection="row" height={1}>
116 {/* The run after it is drawn longer than the card, so the tag must not shrink or it loses its last letter. */}
117 <Box key={`${key}-tag`} flexShrink={0}>
118 <Text>
119 <Text color={c(THEME.border)}>{`${CORNER.topLeft}${MARK.rule}`}</Text>
120 <Text color={c(style.colors.pills.readout)}>{tag ? ` ${tag} ` : ''}</Text>
121 </Text>
122 </Box>
123 {/* Clipped by the box, not truncated, so the run never ends in an ellipsis. */}
124 <Box key={`${key}-run`} flexGrow={1} flexShrink={1} height={1} overflow="hidden">
125 <Text color={c(THEME.border)}>{MARK.rule.repeat(run)}</Text>
126 </Box>
127 <Text color={c(THEME.border)}>{' '}</Text>
128 <Box key={`${key}-copy`} flexShrink={0}>{parts.copy(fence.code) as never}</Box>
129 <Text color={c(THEME.border)}>{` ${MARK.rule}${CORNER.topRight}`}</Text>
130 </Box>
131 {rows}
132 {isCapped ? edge(fillOf(THEME.surface1, true),[<Box key={`${key}-gap`} flexGrow={1} />, <Box key={`${key}-more`} flexShrink={0}>{parts.more(parts.isOpen, lines.length - CODE_CAP, fillOf(THEME.surface1, true)) as never}</Box>], 'more') : null}
133 <Text key={`${key}-bottom`} color={c(THEME.border)}>{`${CORNER.bottomLeft}${MARK.rule.repeat(Math.max(0, width - 2))}${CORNER.bottomRight}`}</Text>
134 </Box>
135 )
136}
137
138// Heading text in capitals, but a code span keeps its case.
139const upper = (heading: string) => heading.replace(/(`[^`]*`)|[^`]+/g, (part, code) => (code ? part : part.toUpperCase()))
140
141// `#`, `##` and `###` alike: dim caps, then a hairline to the reply's width.
142export function headingRow({ Box, Text }: Elements, heading: string, look: Inline, width: number, key: string) {
143 const title = upper(heading)
144 return (
145 <Box key={key} flexDirection="row" height={1}>
146 <Box key={`${key}-title`} flexShrink={0}>
147 <Text color={tinted(look.paint, style.colors.pills.readout, 'text')} backgroundColor={look.paint?.wash('text')}>{inline({ Text }, title, look, key)}</Text>
148 </Box>
149 <Box key={`${key}-rule`} flexGrow={1} flexShrink={1} height={1} overflow="hidden">
150 <Text color={tinted(look.paint, THEME.border, 'text')}>{` ${MARK.rule.repeat(Math.max(1, width))}`}</Text>
151 </Box>
152 </Box>
153 )
154}
155
156export type Callout = { tone: 'note' | 'warning'; label: string; detail: string }
157
158const CALLOUT_KINDS: Record<string, Callout['tone']> = { note: 'note', tip: 'note', important: 'note', warning: 'warning', caution: 'warning' }
159
160// `> **Note:** text` or `> [!NOTE]` with the text after it or on the lines below; null for a plain quote.
161export function calloutOf(lines: string[]): Callout | null {
162 const first = /^\s*>\s*(?:\*\*(note|tip|important|warning|caution):?\*\*:?|\[!(note|tip|important|warning|caution)\])\s*(.*)$/i.exec(lines[0] ?? '')
163 if (!first) return null
164 const kind = (first[1] ?? first[2])!.toLowerCase()
165 const detail = [first[3]!, ...lines.slice(1).map(line => line.replace(/^\s*>\s?/, ''))].map(line => line.trim()).filter(Boolean).join(' ')
166 return { tone: CALLOUT_KINDS[kind]!, label: kind, detail }
167}
168
169// A square tag tinted at 15% of its hue over the fill it sits on.
170export const squareTag = ({ Text }: Pick<Elements, 'Text'>, label: string, hue: string, under: string, key: string, paint?: Paint, part: Part = 'text') => (
171 <Text key={key} color={tinted(paint, hue, part)} backgroundColor={mix(tinted(paint, under, part)!, tinted(paint, hue, part)!, 0.15)}>{` ${label} `}</Text>
172)
173
174export function calloutCard({ Box, Text }: Elements, callout: Callout, look: Inline, width: number, key: string) {
175 const hue = callout.tone === 'warning' ? THEME.alert : THEME.accent
176 const { paint } = look
177 const fill = paint ? paint.fill(THEME.surface1, 'text') : THEME.surface1
178 return (
179 <Box key={key} borderStyle="round" borderColor={tinted(paint, callout.tone === 'warning' ? mix(THEME.bg, THEME.alert, 0.33) : THEME.border, 'text')} backgroundColor={fill} paddingX={1} width={width} flexDirection="row" columnGap={1}>
180 <Box key={`${key}-tag`} flexShrink={0}>{squareTag({ Text }, text.calloutMark[callout.tone], hue, THEME.surface1, `${key}-tag-text`, paint)}</Box>
181 <Box key={`${key}-body`} flexShrink={1} flexGrow={1}>
182 <Text wrap="wrap">
183 <Text bold color={tinted(paint, THEME.onSurface, 'text')}>{text.calloutTitle[callout.label as keyof typeof text.calloutTitle]}</Text>
184 {callout.detail ? <Text color={tinted(paint, THEME.muted, 'text')}>{[' ', ...inline({ Text }, callout.detail, look, `${key}-detail`)]}</Text> : null}
185 </Text>
186 </Box>
187 </Box>
188 )
189}
190
191const STATUS: [RegExp, string][] = [
192 [/^(pass|ok|done|success)$/i, THEME.good],
193 [/^(fail|failed|error)$/i, THEME.alert],
194 [/^(warn|warning|skipped)$/i, THEME.warn],
195]
196
197// The hue of a table cell whose whole text is a status word; undefined for any other cell.
198export const statusHue = (word: string) => STATUS.find(([pattern]) => pattern.test(word.trim()))?.[1]
199
200export const STATUS_EXTRA = 2
201hooks/hilite.ts 143 lines1// mod-transcript / hooks/hilite.ts, by v-amorim: code as colored pieces, each token styled by the active VS Code theme, or by the mod's own palette when none resolved.
2import { cellOf, cells } from './cells'
3import { languageOf, languageOfPath, tokenizeCode } from './grammar'
4import { SYNTAX, THEME } from './theme'
5import { buildTheme, styleFor, type Theme } from './vstheme'
6
7export type Piece = { text: string; color: string; bold?: boolean; italic?: boolean; underline?: boolean }
8
9const own = (color: string, extra: object = {}) => ({ settings: { foreground: color, ...extra } })
10
11// The mod's palette as a theme: shell roles for code, the surface colors for the rest.
12const PALETTE = buildTheme(
13 {
14 colors: { 'editor.foreground': THEME.onSurface },
15 tokenColors: [
16 { scope: ['punctuation', 'meta.brace'], ...own(SYNTAX.operator) },
17 { scope: ['keyword', 'storage', 'entity.name.tag', 'entity.name.command', 'support.function.builtin'], ...own(SYNTAX.command) },
18 { scope: ['keyword.operator', 'keyword.other.unit'], ...own(SYNTAX.operator) },
19 { scope: ['string', 'markup.inline.raw'], ...own(SYNTAX.text) },
20 { scope: ['constant.numeric'], ...own(SYNTAX.number) },
21 { scope: ['constant', 'variable.language', 'entity.other.attribute-name', 'constant.other.option'], ...own(SYNTAX.option) },
22 { scope: ['entity.name.function', 'support.function', 'support.type.property-name', 'variable.other.property', 'variable.other.normal', 'variable.other.member'], ...own(SYNTAX.variable) },
23 { scope: ['entity.name.type', 'entity.name.class', 'support.type', 'support.class', 'entity.name.section', 'entity.other.inherited-class'], ...own(THEME.secondary) },
24 { scope: ['comment'], ...own(THEME.muted, { fontStyle: 'italic' }) },
25 { scope: ['markup.inserted', 'meta.diff.header.to-file'], ...own(THEME.good) },
26 { scope: ['markup.deleted', 'meta.diff.header.from-file'], ...own(THEME.alert) },
27 { scope: ['markup.bold'], settings: { fontStyle: 'bold' } },
28 { scope: ['markup.italic'], settings: { fontStyle: 'italic' } },
29 ],
30 },
31 'palette',
32 '',
33)
34
35let theme: Theme = PALETTE
36const cache = new Map<string, Piece[][]>()
37
38// Whatever the person's setting resolved to; null puts the mod's palette back.
39export function useTheme(next: Theme | null) {
40 theme = next ?? PALETTE
41 cache.clear()
42}
43
44export const themeInUse = () => theme
45
46// Past this a block draws plain: tokenizing costs time on every new reply (my call: far above any reply's code).
47const MAX_CHARS = 60_000
48const CACHE_SIZE = 200
49
50// `code` as lines of colored pieces, tokenized once per language and text; undefined when the language is unknown, so the caller keeps its own color.
51export function highlight(language: string | undefined, code: string): Piece[][] | undefined {
52 const name = language ? languageOf(language) : undefined
53 if (!name || code.length > MAX_CHARS) return undefined
54 const key = `${name}\0${code}`
55 const known = cache.get(key)
56 if (known) return known
57 const lines = (tokenizeCode(name, code) ?? []).map(tokens =>
58 tokens.reduce<Piece[]>((pieces, token) => {
59 const style = styleFor(theme, [...token.scopes])
60 const piece: Piece = { text: token.text, color: style.color ?? THEME.onSurface, ...(style.bold ? { bold: true } : {}), ...(style.italic ? { italic: true } : {}), ...(style.underline ? { underline: true } : {}) }
61 const last = pieces[pieces.length - 1]
62 if (last && last.color === piece.color && last.bold === piece.bold && last.italic === piece.italic && last.underline === piece.underline) last.text += piece.text
63 else pieces.push(piece)
64 return pieces
65 }, []),
66 )
67 if (cache.size >= CACHE_SIZE) cache.delete(cache.keys().next().value!)
68 cache.set(key, lines)
69 return lines
70}
71
72// The grammar a diff names in its file header (`+++ b/src/a.ts`, `diff --git a/x.py b/x.py`), by the file's extension.
73export function diffLanguage(diff: string) {
74 const path = /^\+\+\+ (?:b\/)?(\S+)/m.exec(diff)?.[1] ?? /^--- (?:a\/)?(\S+)/m.exec(diff)?.[1] ?? /^diff --git a\/(\S+)/m.exec(diff)?.[1]
75 return path ? languageOfPath(path) : undefined
76}
77
78export const languageForPath = languageOfPath
79
80export type PreviewLine = { kind: string; text: string }
81
82// A Read result's line: its number and arrow or tab, then the file's own line.
83const NUMBERED = /^(\s*\d+[→\t])(.*)$/
84
85const MARKS: Record<string, string> = { add: '+ ', del: '- ', same: ' ' }
86
87// Each preview line as its lead (diff mark or Read line number) and colored code; a diff's new and old sides are two files, so a removed quote colors nothing around it.
88export function previewPieces(language: string | undefined, lines: PreviewLine[], isRead: boolean): ({ lead: string; code: Piece[] } | undefined)[] | undefined {
89 if (!language || !lines.length) return undefined
90 const split = lines.map(line => {
91 const numbered = isRead && line.kind === 'out' ? NUMBERED.exec(line.text) : null
92 return { lead: numbered ? numbered[1]! : (MARKS[line.kind] ?? ''), code: numbered ? numbered[2]! : line.text, isCode: !isRead || line.kind !== 'out' || Boolean(numbered), kind: line.kind }
93 })
94 const color = (picked: number[]) => {
95 const colored = highlight(language, picked.map(at => split[at]!.code).join('\n'))
96 return new Map(picked.map((at, row) => [at, colored?.[row]] as const))
97 }
98 const every = split.map((_, at) => at)
99 const isDiff = split.some(line => line.kind === 'add' || line.kind === 'del')
100 const removed = isDiff ? [...color(every.filter(at => split[at]!.kind === 'del' || split[at]!.kind === 'same'))].filter(([at]) => split[at]!.kind === 'del') : []
101 const colors = isDiff ? new Map([...color(every.filter(at => split[at]!.kind !== 'del')), ...removed]) : color(every.filter(at => split[at]!.isCode))
102 if (![...colors.values()].some(Boolean)) return undefined
103 return split.map((line, at) => (line.isCode && colors.get(at) ? { lead: line.lead, code: colors.get(at)! } : undefined))
104}
105
106// A line cut into rows of at most `room` cells, colors kept across the cut; a tab is two spaces, an empty line one empty row.
107export function chunkPieces(line: Piece[], room: number): Piece[][] {
108 const rows: Piece[][] = []
109 let row: Piece[] = []
110 let used = 0
111 for (const piece of line) {
112 for (const char of piece.text.replace(/\t/g, ' ')) {
113 if (used + cellOf(char) > room && row.length) {
114 rows.push(row)
115 row = []
116 used = 0
117 }
118 const last = row[row.length - 1]
119 if (last && last.color === piece.color && last.bold === piece.bold && last.italic === piece.italic && last.underline === piece.underline) last.text += char
120 else row.push({ ...piece, text: char })
121 used += cellOf(char)
122 }
123 }
124 return [...rows, row]
125}
126
127// The pieces cut to `width` cells, the last cell an ellipsis in the color it cuts, as `cut` does for text.
128export function cutPieces(line: Piece[], width: number): Piece[] {
129 if (line.reduce((sum, piece) => sum + cells(piece.text), 0) <= width) return line
130 const out: Piece[] = []
131 let used = 0
132 for (const piece of line) {
133 let text = ''
134 for (const char of piece.text) {
135 if (used + cellOf(char) > width - 1) return [...out, ...(text ? [{ ...piece, text }] : []), { ...piece, text: '…' }]
136 text += char
137 used += cellOf(char)
138 }
139 out.push(piece)
140 }
141 return out
142}
143