SLOPSHOPPER

mod-transcript

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

newpanebandspinnerrowsguard
★ 2v1.3.0MITupdated 2026-10-26v-amorim/moonlight-claude-mods/mod-transcript
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mod-transcript
▣ client module ./number.tsx╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │17:13 ⟨Claude Code's own drawing⟩│ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ◥ ▣ client module ./edge.tsx ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /mod-transcript ⎿ mod-transcript: Unfolded every finished turn ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Your message
▣ client module ./number.tsx╭─────────────────────────────────────────────────────────────────────── │17:13 ╰─────────────────────────────────────────────────────────────────────── ◥ ▣ client module ./edge.tsx
README

<!-- mod-transcript / README.md, by v-amorim --> <!-- Generated from the root README, plugin.json and showcase.json; edit those. -->

mod-transcript

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.

Install

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].

How to open

Automatic.

See it

FoldedOpenCopy 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

Features

  • Turn line: Under each prompt, a line names what the turn did: tool count, file changes with lines added and removed, skills, agents and time.
  • Tool lines: Inside an open turn, each finished tool call is one line; a click shows its full row and diff.
  • Call previews: A tool call's preview shows its first lines. Show all opens the rest, and a line that was cut wraps in full. Show less folds it back.
  • Code colors: Code fences in replies and the lines of a Read, Write or Edit preview are colored from your VS Code color theme: the one VS Code is set to, or the one the Code theme setting names. Without a match, the mod's own colors draw.
  • Replies: Claude's replies draw in the mod's style: tables with header and row lines, inline code and hex colors as pills, code blocks as cards with line numbers, headings, callouts, task boxes and status tags. A code block past 12 lines shows its last 12 behind a fold. A table too wide for the window draws as Claude Code draws it.
  • Links: A file path that exists becomes a pill that opens the file in VS Code at its line. A web link becomes a pill with a globe that opens in the browser.
  • File results: An Edit, Write or Read result draws as a card: the file as a link pill and a copy pill on its top edge, then numbered lines with + and − marks on green and red. Past 12 lines it shows the first 12 and folds the rest behind N more lines. A result that failed, or has no diff to read, draws as Claude Code draws it.
  • Card: An open turn draws as one tinted card with a bar down its left edge; the final reply stays outside it.
  • Copy button: A button under each finished reply copies it as markdown, its code, plain text, its tables rebuilt as clean markdown, or its links. Pointing at a choice marks the characters it takes.
  • Command hooks: On SessionStart, PermissionDenied and StopFailure, a small shell hook appends the event's JSON as one line to ~/.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.

Settings

Change them with /mod-transcript settings or the gear pill.

SettingDefaultWhat it does
Fold finished turnsonTurns start folded to one line. The line sits under its prompt. Off, they start open and the line folds them
Fold tool rowsonEach tool row folds to one line. Finished rows have their own toggle. Off, tool rows draw as Claude Code draws them
Show timesonPrompt 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 fullonStays open until your next prompt, with every tool row and result as Claude Code draws them. Off, finished tool calls fold right away
Copy buttononOn 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 numberson#N before each of your prompts. It matches the number the Prompts and Heads up panels show
AnimationsonFold 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 themeemptyThe 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
Iconsautoauto 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

License

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

Source 37 files
hooks/register.tsx 1742 lines
1// 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 lines
1// 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}
68
hooks/bar.tsx 33 lines
1// 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
33
hooks/chips.tsx 129 lines
1// 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
129
hooks/copy.tsx 155 lines
1// 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
155
hooks/edge.tsx 23 lines
1// 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
23
hooks/dot.tsx 20 lines
1// 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
20
hooks/fold.tsx 67 lines
1// 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
67
hooks/label.tsx 37 lines
1// 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
37
hooks/number.tsx 38 lines
1// 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
38
hooks/blocks.tsx 201 lines
1// 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
201
hooks/hilite.ts 143 lines
1// 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