@workerdeck/ui 0.15.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +60 -0
  2. package/build/{SessionPanel-J2U8v88q.d.mts → SessionPanel-CnNYEX80.d.mts} +170 -21
  3. package/build/{SessionPanel-DI1NO4l8.mjs → SessionPanel-DMPhsNlW.mjs} +4759 -1483
  4. package/build/SessionPanel-DMPhsNlW.mjs.map +1 -0
  5. package/build/{format-ljc3lKpA.d.mts → format-DfI_je9S.d.mts} +1 -1
  6. package/build/format.d.mts +39 -4
  7. package/build/format.mjs +2 -118
  8. package/build/index.d.mts +493 -45
  9. package/build/index.mjs +343 -17
  10. package/build/index.mjs.map +1 -1
  11. package/build/status-Ydzi7n6j.mjs +143 -0
  12. package/build/status-Ydzi7n6j.mjs.map +1 -0
  13. package/build/workspace.d.mts +9 -1
  14. package/build/workspace.mjs +108 -5
  15. package/build/workspace.mjs.map +1 -1
  16. package/package.json +16 -7
  17. package/src/components/agent/Composer.tsx +189 -89
  18. package/src/components/agent/Conversation.tsx +12 -12
  19. package/src/components/agent/FileCard.tsx +0 -26
  20. package/src/components/agent/FileTree.tsx +9 -8
  21. package/src/components/agent/Loader.tsx +22 -72
  22. package/src/components/agent/Message.tsx +11 -46
  23. package/src/components/agent/PermissionPrompt.tsx +0 -92
  24. package/src/components/agent/ProjectIcon.tsx +119 -0
  25. package/src/components/agent/QuestionPrompt.tsx +0 -122
  26. package/src/components/agent/Reasoning.tsx +5 -19
  27. package/src/components/agent/Response.tsx +1 -132
  28. package/src/components/agent/SessionBrowser.tsx +84 -3
  29. package/src/components/agent/SessionPanel.tsx +249 -28
  30. package/src/components/agent/SessionWorkspace.tsx +29 -0
  31. package/src/components/agent/StatusBar.tsx +20 -4
  32. package/src/components/agent/ToolCallCard.tsx +85 -112
  33. package/src/components/agent/Transcript.tsx +780 -203
  34. package/src/components/agent/UsageDialog.tsx +20 -106
  35. package/src/components/agent/UsageMeters.tsx +133 -0
  36. package/src/components/agent/pulse.tsx +3 -2
  37. package/src/components/agent/tool-result-fetch.tsx +36 -0
  38. package/src/components/agent/tool-result-image.tsx +209 -0
  39. package/src/components/agent/transcript-rows.ts +173 -0
  40. package/src/components/agent/transcript-variant.tsx +29 -51
  41. package/src/components/agent/use-height-epoch.ts +60 -0
  42. package/src/components/agent/use-path-links.ts +147 -0
  43. package/src/components/agent/use-transcript-jumps.ts +190 -0
  44. package/src/components/prompt-area/cursor-helpers.ts +65 -0
  45. package/src/components/prompt-area/use-prompt-area.ts +16 -10
  46. package/src/components/terminal/PermissionPrompt.tsx +119 -0
  47. package/src/components/terminal/QuestionPrompt.tsx +322 -0
  48. package/src/components/terminal/StatusLine.tsx +159 -0
  49. package/src/components/terminal/TerminalTranscript.tsx +225 -0
  50. package/src/components/terminal/affordances.tsx +118 -0
  51. package/src/components/terminal/blocks.ts +232 -0
  52. package/src/components/terminal/diff.tsx +130 -0
  53. package/src/components/terminal/height.ts +770 -0
  54. package/src/components/terminal/image-box.ts +53 -0
  55. package/src/components/terminal/items.tsx +486 -0
  56. package/src/components/terminal/markdown.tsx +191 -0
  57. package/src/components/terminal/press.tsx +120 -0
  58. package/src/components/terminal/prompt.tsx +343 -0
  59. package/src/components/terminal/result-preview.ts +86 -0
  60. package/src/components/terminal/row.tsx +132 -0
  61. package/src/components/terminal/scrubber.tsx +784 -0
  62. package/src/components/terminal/surface.tsx +80 -0
  63. package/src/components/terminal/tool-run.ts +224 -0
  64. package/src/index.ts +36 -1
  65. package/src/lib/status.ts +59 -3
  66. package/src/lib/tool-icon.ts +14 -0
  67. package/src/styles/terminal.css +1081 -0
  68. package/src/styles/theme.css +41 -0
  69. package/build/SessionPanel-DI1NO4l8.mjs.map +0 -1
  70. package/build/format.mjs.map +0 -1
  71. package/src/components/agent/line-prompt.tsx +0 -249
@@ -0,0 +1,770 @@
1
+ import type { FilePatch, PatchHunk } from '@workerdeck/protocol'
2
+ import type { TranscriptItem } from '@workerdeck/react'
3
+ import {
4
+ formatBytes,
5
+ formatCost,
6
+ formatDuration,
7
+ toolInputPreview,
8
+ } from '../../lib/format.ts'
9
+ import { taskChildItems, type TerminalBlock, type ToolCallItem } from './blocks.ts'
10
+ import { IMAGE_BOX_LINES } from './image-box.ts'
11
+ import { collapsedResult } from './result-preview.ts'
12
+ import { runSummary, taskSummary } from './tool-run.ts'
13
+
14
+ /**
15
+ * The terminal theme's row-height calculator.
16
+ *
17
+ * Computes, from a {@link TranscriptItem} and the cell metrics alone — no DOM —
18
+ * the pixel height the terminal renderer will draw it at. This exists because
19
+ * the theme makes it possible: one line height, one cell, monospace, ligatures
20
+ * off. It is a *terminal* capability, deliberately homed here rather than in a
21
+ * generic location — the cards variant has padding scales, borders and a
22
+ * proportional face, and nothing in this file transfers to it.
23
+ *
24
+ * The consumer is the virtualizer's `estimateSize` (see `agent/Transcript.tsx`):
25
+ * a computed height governs a row only until the row mounts and is measured, so
26
+ * the contract is "almost always exact, and honest when it cannot be". Honesty
27
+ * is the `exact` flag on {@link ComputedHeight}: `false` marks content whose
28
+ * advance this model cannot know — emoji and CJK render from fallback faces
29
+ * whose advances are not whole cells.
30
+ * Measured against the real renderer in the dev playground
31
+ * (`dev/height-audit.ts`): every unflagged row was pixel-exact across fixtures,
32
+ * widths and cell sizes — the audit is this file's regression gate.
33
+ *
34
+ * Two invariants keep the model small, and both are load-bearing:
35
+ *
36
+ * - **Only the default state is ever computed.** A tool row's expansion is
37
+ * component-local state that resets when the row unmounts, so an off-screen
38
+ * row is always collapsed — and an expanded row is by definition mounted,
39
+ * which means the virtualizer has its real measurement. There is no
40
+ * open/expanded branch here on purpose.
41
+ *
42
+ * **This is a decided, permanent divergence from iOS**, not a gap anyone
43
+ * should close. There (`apps/ios/WorkerDeckKit/.../TerminalExpansion.swift`)
44
+ * nothing self-measures — a `UICollectionViewLayout` takes every frame from
45
+ * the height book — so expansion has to be an *input to the planner*, and a
46
+ * height the book does not know about is a frame the layout gets wrong. That
47
+ * inversion is what lets the iOS scrubber be expansion-aware (a band over the
48
+ * region you opened; a failed member of an *open* run marking on its own
49
+ * line) and it is the one thing this client's rail cannot do. Lifting
50
+ * expansion to shared state here to match would buy one rail feature and cost
51
+ * this invariant — the central simplification of the whole calculator — so
52
+ * the two clients share the *rule* and differ in how much of it each can see.
53
+ *
54
+ * And the gap is entailed by **where the state can live**, not by how it is
55
+ * plumbed: a paint-only registry would preserve this invariant, but web
56
+ * expansion exists only while a row is *mounted*, so the rail it fed could
57
+ * only ever band the rows currently on screen — the one place an overview
58
+ * rail is useless, because the yellow wash is already visible there.
59
+ * Said here as well as in the Swift, because this is the file where someone
60
+ * would go looking to "fix" it.
61
+ * - **Mutation is object replacement.** The transcript reducer never mutates an
62
+ * item in place — streaming text, a result arriving, a patch attaching each
63
+ * produce a new object — which is what lets {@link HeightEpoch}'s cache key on
64
+ * identity and never go stale.
65
+ *
66
+ * The wrap model mirrors `.term-body` (`white-space: pre-wrap` +
67
+ * `overflow-wrap: break-word` in a `minmax(0,1fr)` grid column): greedy fill,
68
+ * breaks at preserved spaces (which hang at the line end rather than wrapping),
69
+ * after hyphens/dashes/`?` not followed by a digit (verified against Chrome's
70
+ * actual break points in long URLs), and between CJK characters; a token wider
71
+ * than the whole column moves to its own line first and then breaks per cell —
72
+ * which is exactly where `break-word` differs from `anywhere`.
73
+ */
74
+
75
+ /** The cell everything is measured in. `ch` is the advance of `0` in px —
76
+ * *measured* off the live surface via {@link measureCh}, never derived from the
77
+ * font size (7.83px at 13px JetBrains Mono, not 13 × 0.6). */
78
+ export type CellMetrics = {
79
+ /** Content-box width of the virtual row wrapper, px. */
80
+ width: number
81
+ /** Advance of `0`, px. */
82
+ ch: number
83
+ /** `--term-line`, px. */
84
+ line: number
85
+ }
86
+
87
+ export type ComputedHeight = {
88
+ px: number
89
+ /** False when the content contains glyphs or structures whose rendered size
90
+ * this model cannot know — treat the row as an estimate, corrected the
91
+ * moment it mounts and measures. */
92
+ exact: boolean
93
+ }
94
+
95
+ /* ── The epoch ─────────────────────────────────────────────────────────────
96
+ *
97
+ * One cache generation. **Owned by the transcript shell** (`TranscriptRows` in
98
+ * `agent/Transcript.tsx` holds exactly one, in React state): it is created from
99
+ * the first real measurement of the mounted surface and *replaced wholesale*
100
+ * whenever width, `ch` or line change — a computed height is only meaningful
101
+ * against the metrics it was computed for, so partial invalidation is a bug
102
+ * factory and a new WeakMap is free. Within an epoch the cache keys on item
103
+ * object identity, which the reducer's replace-on-mutation discipline turns
104
+ * into automatic invalidation: a streamed delta is a new object and simply
105
+ * misses. Run blocks are not cached — the folded array is rebuilt every
106
+ * render, so its identity is worthless as a key, and a collapsed run is one
107
+ * `wrapOne` over a short string (~2µs).
108
+ */
109
+ export type HeightEpoch = CellMetrics & {
110
+ cache: WeakMap<TranscriptItem, ComputedHeight>
111
+ }
112
+
113
+ export function createHeightEpoch(width: number, ch: number, line: number): HeightEpoch {
114
+ return { width, ch, line, cache: new WeakMap() }
115
+ }
116
+
117
+ /** A virtual row's computed height under `epoch` — the `estimateSize` feed.
118
+ * The inter-row gap is the *pair's* business (`gapBefore`), not the row's, so
119
+ * it is added by the caller. */
120
+ export function estimateBlockPx(block: TerminalBlock, epoch: HeightEpoch): number {
121
+ // Only item blocks cache. A run's array is rebuilt every render, so its
122
+ // identity is worthless as a key — and a task block must not key on its
123
+ // `task` item either: children arrive without the call object changing (the
124
+ // reducer replaces the *child*), so a height cached against the task would
125
+ // survive exactly the mutation that changes the summary line. Both are one
126
+ // `wrapOne` over a short string (~2µs), so neither needs the cache.
127
+ if (!('item' in block)) return blockHeight(block, epoch).px
128
+ const hit = epoch.cache.get(block.item)
129
+ if (hit) return hit.px
130
+ const computed = itemHeight(block.item, epoch)
131
+ epoch.cache.set(block.item, computed)
132
+ return computed.px
133
+ }
134
+
135
+ /** Measure the advance of `0` on the live surface. A DOM read — call it from
136
+ * the epoch's measurement pass (an effect / ResizeObserver callback), never
137
+ * from render. The probe is absolutely positioned, so it contributes no layout
138
+ * and cannot re-trigger the observer that called it. */
139
+ export function measureCh(surface: HTMLElement): number {
140
+ const probe = document.createElement('span')
141
+ probe.textContent = '0'.repeat(200)
142
+ probe.style.position = 'absolute'
143
+ probe.style.visibility = 'hidden'
144
+ probe.style.whiteSpace = 'pre'
145
+ surface.appendChild(probe)
146
+ const width = probe.getBoundingClientRect().width / 200
147
+ probe.remove()
148
+ return width
149
+ }
150
+
151
+ /* ── Cell counting ─────────────────────────────────────────────────────────── */
152
+
153
+ const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' })
154
+
155
+ const WIDE_RANGES: [number, number][] = [
156
+ [0x1100, 0x115f], // Hangul Jamo
157
+ [0x2e80, 0x303e], // CJK radicals, Kangxi, CJK symbols/punctuation
158
+ [0x3041, 0x33ff], // Hiragana … CJK compatibility
159
+ [0x3400, 0x4dbf],
160
+ [0x4e00, 0x9fff],
161
+ [0xa000, 0xa4cf],
162
+ [0xac00, 0xd7a3], // Hangul syllables
163
+ [0xf900, 0xfaff],
164
+ [0xfe30, 0xfe4f],
165
+ [0xff00, 0xff60], // fullwidth forms
166
+ [0xffe0, 0xffe6],
167
+ [0x20000, 0x3fffd],
168
+ ]
169
+
170
+ const isWide = (cp: number): boolean => WIDE_RANGES.some(([lo, hi]) => cp >= lo && cp <= hi)
171
+
172
+ const PICTOGRAPHIC = /\p{Extended_Pictographic}/u
173
+
174
+ /** One grapheme cluster's advance, in cells. Wide and pictographic clusters are
175
+ * counted as 2 but flagged: they render from a fallback face whose advance is
176
+ * not a whole number of cells, and the flag is what keeps the model honest. */
177
+ function clusterCells(cluster: string): { w: number; exact: boolean } {
178
+ if (PICTOGRAPHIC.test(cluster) || cluster.includes('‍') || cluster.includes('️'))
179
+ return { w: 2, exact: false }
180
+ const cp = cluster.codePointAt(0) ?? 0
181
+ if (isWide(cp)) return { w: 2, exact: false }
182
+ if (cp < 0x20) return { w: 0, exact: true }
183
+ return { w: 1, exact: true }
184
+ }
185
+
186
+ /* ── The wrap model ────────────────────────────────────────────────────────── */
187
+
188
+ type Token = { kind: 'word' | 'space'; w: number; exact: boolean }
189
+
190
+ /** `tab-size: 2` on the surface. */
191
+ const TAB_SIZE = 2
192
+
193
+ /** Break *after* these when the next character is not a digit. The digit guard
194
+ * keeps `protocol-0.16.0` together; `?` is in the set because Chrome breaks
195
+ * long URLs after it (verified against real break rects). */
196
+ const BREAK_AFTER = new Set(['-', '–', '—', '?'])
197
+
198
+ /** Printable ASCII, tab excluded: every character is one cell and one grapheme,
199
+ * so the segmenter has nothing to tell us. This is nearly every line a
200
+ * transcript holds, and the segmenter was the calculator's whole CPU bill —
201
+ * ~1s of a 30s scroll sweep on the 4k-item perf fixture, all of it spent
202
+ * segmenting text that `charCodeAt` classifies for free. */
203
+ const PLAIN_ASCII = /^[\x20-\x7e]*$/
204
+
205
+ /** The ASCII half of {@link tokenize}: same tokens, same break rules ('-' and
206
+ * '?' break unless a digit follows; '–'/'—' are not ASCII), no segmenter and
207
+ * no per-grapheme allocation. Must stay semantically identical to the general
208
+ * path — the height audit runs both, via content that takes each. */
209
+ function tokenizeAscii(line: string): Token[] {
210
+ const tokens: Token[] = []
211
+ let wordW = 0
212
+ const flushWord = () => {
213
+ if (wordW > 0) tokens.push({ kind: 'word', w: wordW, exact: true })
214
+ wordW = 0
215
+ }
216
+ for (let i = 0; i < line.length; i++) {
217
+ const code = line.charCodeAt(i)
218
+ if (code === 0x20) {
219
+ flushWord()
220
+ const last = tokens[tokens.length - 1]
221
+ if (last?.kind === 'space') last.w += 1
222
+ else tokens.push({ kind: 'space', w: 1, exact: true })
223
+ continue
224
+ }
225
+ wordW += 1
226
+ if (code === 0x2d /* - */ || code === 0x3f /* ? */) {
227
+ const next = line.charCodeAt(i + 1)
228
+ if (!(next >= 0x30 && next <= 0x39)) flushWord()
229
+ }
230
+ }
231
+ flushWord()
232
+ return tokens
233
+ }
234
+
235
+ /**
236
+ * Tokenize one hard line into wrap units. Tabs advance to the next 2-cell stop
237
+ * measured from the hard line's start — position-dependent after a soft wrap in
238
+ * principle, but content with tabs beyond the first wrap point is vanishingly
239
+ * rare and the error is bounded by one tab stop.
240
+ */
241
+ function tokenize(line: string): Token[] {
242
+ if (PLAIN_ASCII.test(line)) return tokenizeAscii(line)
243
+ const tokens: Token[] = []
244
+ let word = { w: 0, exact: true }
245
+ let col = 0
246
+ const flushWord = () => {
247
+ if (word.w > 0) tokens.push({ kind: 'word', w: word.w, exact: word.exact })
248
+ word = { w: 0, exact: true }
249
+ }
250
+ const segments = [...segmenter.segment(line)].map((s) => s.segment)
251
+ for (const [index, segment] of segments.entries()) {
252
+ if (segment === ' ') {
253
+ flushWord()
254
+ const last = tokens.at(-1)
255
+ if (last?.kind === 'space') last.w += 1
256
+ else tokens.push({ kind: 'space', w: 1, exact: true })
257
+ col += 1
258
+ continue
259
+ }
260
+ if (segment === '\t') {
261
+ flushWord()
262
+ const advance = TAB_SIZE - (col % TAB_SIZE) || TAB_SIZE
263
+ const last = tokens.at(-1)
264
+ if (last?.kind === 'space') last.w += advance
265
+ else tokens.push({ kind: 'space', w: advance, exact: true })
266
+ col += advance
267
+ continue
268
+ }
269
+ const { w, exact } = clusterCells(segment)
270
+ const cp = segment.codePointAt(0) ?? 0
271
+ if (isWide(cp) || PICTOGRAPHIC.test(segment)) {
272
+ // Each wide cluster is its own token — a break may fall between any two.
273
+ flushWord()
274
+ tokens.push({ kind: 'word', w, exact })
275
+ col += w
276
+ continue
277
+ }
278
+ word.w += w
279
+ word.exact = word.exact && exact
280
+ col += w
281
+ if (BREAK_AFTER.has(segment) && !/^\d/.test(segments[index + 1] ?? '')) {
282
+ flushWord()
283
+ }
284
+ }
285
+ flushWord()
286
+ return tokens
287
+ }
288
+
289
+ /** Visual lines one hard line occupies at `cols` columns. */
290
+ function wrapOne(line: string, cols: number): { lines: number; exact: boolean } {
291
+ if (cols <= 0) return { lines: 1, exact: false }
292
+ const tokens = tokenize(line)
293
+ let lines = 1
294
+ let pos = 0
295
+ let exact = true
296
+ for (const token of tokens) {
297
+ exact = exact && token.exact
298
+ if (token.kind === 'space') {
299
+ // Preserved spaces hang at the line end (CSS Text 3): they may overflow
300
+ // without forcing a wrap, and the next word starts the next line.
301
+ pos += token.w
302
+ continue
303
+ }
304
+ if (pos + token.w <= cols) {
305
+ pos += token.w
306
+ continue
307
+ }
308
+ if (token.w <= cols) {
309
+ lines += 1
310
+ pos = token.w
311
+ continue
312
+ }
313
+ // break-word: the token first moves to its own line, then fills whole lines.
314
+ if (pos > 0) lines += 1
315
+ const full = Math.ceil(token.w / cols)
316
+ lines += full - 1
317
+ pos = token.w - (full - 1) * cols
318
+ }
319
+ return { lines, exact }
320
+ }
321
+
322
+ /** Visual lines of a (possibly multi-hard-line) text at `cols` columns.
323
+ * Exported for the dev audit; also the one genuinely pure piece a unit test
324
+ * could pin, should `ui` ever grow a runner. */
325
+ export function textLines(text: string, cols: number): { lines: number; exact: boolean } {
326
+ let lines = 0
327
+ let exact = true
328
+ for (const hard of text.split('\n')) {
329
+ const r = wrapOne(hard, cols)
330
+ lines += Math.max(1, r.lines)
331
+ exact = exact && r.exact
332
+ }
333
+ return { lines: Math.max(1, lines), exact }
334
+ }
335
+
336
+ /* ── Rows ──────────────────────────────────────────────────────────────────── */
337
+
338
+ /** Layout rounds to 1/64px; the epsilon keeps a body width that is exactly
339
+ * N × ch from flooring to N−1. */
340
+ const EPS = 1e-4
341
+
342
+ type Acc = { px: number; exact: boolean }
343
+
344
+ const add = (a: Acc, b: Acc): Acc => ({ px: a.px + b.px, exact: a.exact && b.exact })
345
+
346
+ /**
347
+ * One `Row`: `indentCells` of padding (already resolved against the row's own
348
+ * `--term-cell` — a `columns={3} indent={1}` row loses 3ch to the indent *and*
349
+ * 3ch to the gutter, because the indent padding reads the same variable the
350
+ * override sets), `gutterCells` of gutter, the body wrapping in what is left.
351
+ * `extraPx` is non-cell chrome around the row (the nested-run border+padding).
352
+ */
353
+ function rowH(
354
+ text: string,
355
+ m: CellMetrics,
356
+ { indentCells = 0, gutterCells = 2, extraPx = 0 } = {},
357
+ ): Acc {
358
+ const bodyPx = m.width - extraPx - (indentCells + gutterCells) * m.ch
359
+ const cols = Math.floor(bodyPx / m.ch + EPS)
360
+ const { lines, exact } = textLines(text, cols)
361
+ return { px: lines * m.line, exact }
362
+ }
363
+
364
+ /* ── Markdown ──────────────────────────────────────────────────────────────── */
365
+
366
+ /** Rendered text of an inline run — markers stripped the way the component map
367
+ * renders them (`**bold**` is 4 chars narrower on screen than in source).
368
+ * Approximate: reference links, raw HTML and nested emphasis edge cases are
369
+ * not modelled. */
370
+ function stripInline(s: string): string {
371
+ return s
372
+ .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
373
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
374
+ .replace(/``([^`]+)``/g, '$1')
375
+ .replace(/`([^`]+)`/g, '$1')
376
+ .replace(/\*\*([^*]+)\*\*/g, '$1')
377
+ .replace(/__([^_]+)__/g, '$1')
378
+ .replace(/\*([^*\s][^*]*)\*/g, '$1')
379
+ .replace(/(^|\W)_([^_]+)_(?=\W|$)/g, '$1$2')
380
+ .replace(/\\([\\`*_{}[\]()#+.!-])/g, '$1')
381
+ }
382
+
383
+ /**
384
+ * Text blocks carry **rendered lines**, resolved by CommonMark's own break
385
+ * rule: a source line ending in two spaces (or a backslash) is a hard break
386
+ * and renders as `<br>`; any other newline is soft and collapses to a space
387
+ * under `white-space: normal`. Both halves are load-bearing and each was the
388
+ * whole model once. Join-always missed the hard breaks — models write poems
389
+ * with trailing double-spaces, and a 350-line one estimated ~115 lines short
390
+ * (a phantom scrollbar tail, scrubber marks resizing as rows mounted).
391
+ * Break-always missed the soft ones and overestimated the same stanza written
392
+ * without them by four lines. The `markdown` fixture carries one poem of each
393
+ * as the audit's guard.
394
+ */
395
+ type MdBlock =
396
+ | { t: 'p'; lines: string[] }
397
+ | { t: 'h'; text: string }
398
+ | { t: 'fence'; code: string }
399
+ | { t: 'list'; items: { lines: string[]; gutter: number; indent: number }[] }
400
+ | { t: 'quote'; paras: string[][] }
401
+ /** One line per row, unconditionally — see the `table` case in
402
+ * {@link markdownHeight}. */
403
+ | { t: 'table'; rows: number }
404
+ | { t: 'hr' }
405
+
406
+ /** Source lines of one paragraph-ish run → the lines the renderer draws: hard
407
+ * breaks (trailing double space or backslash) split, soft newlines join with a
408
+ * space. The break marker itself never renders and is stripped. */
409
+ function hardLines(source: string[]): string[] {
410
+ const out: string[] = []
411
+ let current: string[] = []
412
+ for (const raw of source) {
413
+ const hard = /(?:\s{2}|\\)$/.test(raw)
414
+ current.push(stripInline(raw.replace(/(?:\s+|\\)$/, '')))
415
+ if (hard) {
416
+ out.push(current.join(' '))
417
+ current = []
418
+ }
419
+ }
420
+ if (current.length) out.push(current.join(' '))
421
+ return out
422
+ }
423
+
424
+ /** A line-based CommonMark-ish block parser — just enough structure to mirror
425
+ * what Streamdown + the terminal component map emit for transcript content. */
426
+ function parseBlocks(md: string): MdBlock[] {
427
+ const lines = md.split('\n')
428
+ const blocks: MdBlock[] = []
429
+ let i = 0
430
+ const isBlank = (s: string | undefined) => s !== undefined && s.trim() === ''
431
+ while (i < lines.length) {
432
+ const line = lines[i]!
433
+ if (line.trim() === '') {
434
+ i += 1
435
+ continue
436
+ }
437
+ if (line.startsWith('```')) {
438
+ const code: string[] = []
439
+ i += 1
440
+ while (i < lines.length && !lines[i]!.startsWith('```')) {
441
+ code.push(lines[i]!)
442
+ i += 1
443
+ }
444
+ i += 1 // closing fence (or EOF, mid-stream)
445
+ blocks.push({ t: 'fence', code: code.join('\n') })
446
+ continue
447
+ }
448
+ const h = /^(#{1,6})\s+(.*)$/.exec(line)
449
+ if (h) {
450
+ blocks.push({ t: 'h', text: stripInline(h[2]!) })
451
+ i += 1
452
+ continue
453
+ }
454
+ if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) {
455
+ blocks.push({ t: 'hr' })
456
+ i += 1
457
+ continue
458
+ }
459
+ if (/^>\s?/.test(line)) {
460
+ const raw: string[] = []
461
+ while (i < lines.length && /^>\s?/.test(lines[i]!)) {
462
+ raw.push(lines[i]!.replace(/^>\s?/, ''))
463
+ i += 1
464
+ }
465
+ const paras: string[][] = []
466
+ let current: string[] = []
467
+ for (const l of raw) {
468
+ if (l.trim() === '') {
469
+ if (current.length) paras.push(hardLines(current))
470
+ current = []
471
+ } else current.push(l)
472
+ }
473
+ if (current.length) paras.push(hardLines(current))
474
+ blocks.push({ t: 'quote', paras })
475
+ continue
476
+ }
477
+ const listMarker = (s: string) => {
478
+ const m = /^(\s*)([-*+]|\d+\.)\s+(.*)$/.exec(s)
479
+ if (!m) return undefined
480
+ const depth = Math.floor(m[1]!.length / 2)
481
+ const ordered = /\d/.test(m[2]!)
482
+ return { depth, ordered, text: m[3]! }
483
+ }
484
+ if (listMarker(line)) {
485
+ const items: { lines: string[]; gutter: number; indent: number }[] = []
486
+ // Marker cells per level, outermost first — a nested item's indent is the
487
+ // sum of its ancestors' gutters (each nested list sits in its parent li's
488
+ // body column).
489
+ const gutters: number[] = []
490
+ let listOrdered: boolean | undefined
491
+ while (i < lines.length) {
492
+ const m = listMarker(lines[i]!)
493
+ if (!m) {
494
+ if (isBlank(lines[i]) && listMarker(lines[i + 1] ?? '')) {
495
+ i += 1
496
+ continue
497
+ }
498
+ break
499
+ }
500
+ // A different marker type at the top level starts a *new* list — one
501
+ // more block, one more inter-block margin.
502
+ if (m.depth === 0) {
503
+ if (listOrdered !== undefined && m.ordered !== listOrdered) break
504
+ listOrdered = m.ordered
505
+ }
506
+ gutters[m.depth] = m.ordered ? 3 : 2
507
+ const indent = gutters.slice(0, m.depth).reduce((a, b) => a + (b ?? 2), 0)
508
+ const source = [m.text]
509
+ i += 1
510
+ // Lazy continuation lines belong to the item; whether each newline
511
+ // renders is `hardLines`' call, same as a paragraph's.
512
+ while (i < lines.length && lines[i]!.trim() !== '' && !listMarker(lines[i]!)) {
513
+ // trimStart only: a trailing double space is the hard-break marker,
514
+ // and `hardLines` needs to see it.
515
+ source.push(lines[i]!.trimStart())
516
+ i += 1
517
+ }
518
+ items.push({ lines: hardLines(source), gutter: gutters[m.depth]!, indent })
519
+ }
520
+ blocks.push({ t: 'list', items })
521
+ continue
522
+ }
523
+ if (line.trimStart().startsWith('|')) {
524
+ // Only the row count. A table lays out at `max-content` and scrolls rather
525
+ // than compressing, so a row is a line whatever the cells hold — the
526
+ // per-column width measuring this used to do fed a wrap model that no
527
+ // longer exists, and it was real work on every table.
528
+ let rows = 0
529
+ while (i < lines.length && lines[i]!.trimStart().startsWith('|')) {
530
+ const cells = lines[i]!
531
+ .trim()
532
+ .replace(/^\||\|$/g, '')
533
+ .split('|')
534
+ .map((cell) => cell.trim())
535
+ // The `|---|:--:|` delimiter row is structure, not a rendered line.
536
+ if (!cells.every((cell) => /^:?-+:?$/.test(cell))) rows += 1
537
+ i += 1
538
+ }
539
+ blocks.push({ t: 'table', rows: Math.max(1, rows) })
540
+ continue
541
+ }
542
+ // Paragraph: gather until a blank line or another block opener.
543
+ const para: string[] = [line]
544
+ i += 1
545
+ while (
546
+ i < lines.length &&
547
+ lines[i]!.trim() !== '' &&
548
+ !lines[i]!.startsWith('```') &&
549
+ !/^(#{1,6})\s+/.test(lines[i]!) &&
550
+ !/^>\s?/.test(lines[i]!) &&
551
+ !listMarker(lines[i]!) &&
552
+ !lines[i]!.trimStart().startsWith('|') &&
553
+ !/^(-{3,}|\*{3,}|_{3,})\s*$/.test(lines[i]!)
554
+ ) {
555
+ para.push(lines[i]!)
556
+ i += 1
557
+ }
558
+ blocks.push({ t: 'p', lines: hardLines(para) })
559
+ }
560
+ return blocks
561
+ }
562
+
563
+ /** The markdown body's height: blocks, one line between consecutive ones
564
+ * (`.term-md .term-block + .term-block`). Exported for the dev audit only. */
565
+ export function markdownHeight(md: string, m: CellMetrics, extraPx = 0): Acc {
566
+ const bodyPx = m.width - extraPx - 2 * m.ch // the outer Row's gutter
567
+ const cols = Math.floor(bodyPx / m.ch + EPS)
568
+ const blocks = parseBlocks(md)
569
+ let acc: Acc = { px: 0, exact: true }
570
+ for (const [index, block] of blocks.entries()) {
571
+ if (index > 0) acc = add(acc, { px: m.line, exact: true })
572
+ switch (block.t) {
573
+ case 'p': {
574
+ for (const line of block.lines) {
575
+ const r = textLines(line, cols)
576
+ acc = add(acc, { px: r.lines * m.line, exact: r.exact })
577
+ }
578
+ break
579
+ }
580
+ case 'h': {
581
+ const r = textLines(block.text, cols)
582
+ acc = add(acc, { px: r.lines * m.line, exact: r.exact })
583
+ break
584
+ }
585
+ case 'fence': {
586
+ let px = 0
587
+ let exact = true
588
+ for (const codeLine of block.code.split('\n')) {
589
+ const r = wrapOne(codeLine, cols)
590
+ px += Math.max(1, r.lines) * m.line
591
+ exact = exact && r.exact
592
+ }
593
+ acc = add(acc, { px: Math.max(px, m.line), exact })
594
+ break
595
+ }
596
+ case 'list': {
597
+ let px = 0
598
+ let exact = true
599
+ for (const item of block.items) {
600
+ for (const line of item.lines) {
601
+ const r = textLines(line, cols - item.indent - item.gutter)
602
+ px += r.lines * m.line
603
+ exact = exact && r.exact
604
+ }
605
+ }
606
+ acc = add(acc, { px, exact })
607
+ break
608
+ }
609
+ case 'quote': {
610
+ let px = 0
611
+ let exact = true
612
+ for (const [pi, para] of block.paras.entries()) {
613
+ if (pi > 0) px += m.line
614
+ for (const line of para) {
615
+ const r = textLines(line, cols - 2)
616
+ px += r.lines * m.line
617
+ exact = exact && r.exact
618
+ }
619
+ }
620
+ acc = add(acc, { px, exact })
621
+ break
622
+ }
623
+ case 'table':
624
+ // One line per row, always. A table is laid out at `max-content` and
625
+ // carries no `max-width`, so a wide one scrolls inside
626
+ // `.term-table-wrap` rather than compressing — which means the auto
627
+ // table layout never wraps a cell and there is nothing here to model.
628
+ // This used to branch on whether the table fit, and flag the wide case:
629
+ // that branch was the theme's own `max-width: 100%` leaking into the
630
+ // calculator, and both went at once. See `terminal.css` §Markdown.
631
+ acc = add(acc, { px: block.rows * m.line, exact: true })
632
+ break
633
+ case 'hr':
634
+ acc = add(acc, { px: m.line, exact: true })
635
+ break
636
+ }
637
+ }
638
+ return { px: Math.max(acc.px, m.line), exact: acc.exact }
639
+ }
640
+
641
+ /* ── Items ─────────────────────────────────────────────────────────────────── */
642
+
643
+ function diffHeight(patch: FilePatch, m: CellMetrics, extraPx = 0): Acc {
644
+ const walk = (hunk: PatchHunk) => {
645
+ let newLine = hunk.newStart
646
+ let oldLine = hunk.oldStart
647
+ return hunk.lines.map((line) => {
648
+ const kind = line.startsWith('+') ? 'add' : line.startsWith('-') ? 'remove' : 'context'
649
+ const text = line.slice(1)
650
+ const number = kind === 'add' ? newLine++ : kind === 'remove' ? oldLine++ : newLine
651
+ if (kind === 'context') {
652
+ oldLine++
653
+ newLine++
654
+ }
655
+ return { number, text }
656
+ })
657
+ }
658
+ const hunks = patch.hunks.map(walk)
659
+ const numbered = patch.hunks.some((hunk) => hunk.newStart > 0)
660
+ const width = numbered
661
+ ? String(Math.max(...hunks.flat().map((row) => row.number), 1)).length
662
+ : 0
663
+ const columns = numbered ? width + 3 : 2
664
+ let acc: Acc = { px: 0, exact: true }
665
+ for (const [index, hunk] of hunks.entries()) {
666
+ if (index > 0) acc = add(acc, { px: m.line, exact: true }) // the ⋮ separator row
667
+ for (const row of hunk) {
668
+ acc = add(acc, rowH(row.text || ' ', m, { indentCells: 2, gutterCells: columns, extraPx }))
669
+ }
670
+ }
671
+ if (patch.truncated) acc = add(acc, { px: m.line, exact: true })
672
+ return acc
673
+ }
674
+
675
+ /** A collapsed tool row: the header, then the diff (when the call carries a
676
+ * patch) or the shared collapsed preview plus its trailing "there is more" row.
677
+ * No expanded branch — see the module comment's first invariant. */
678
+ function toolRowHeight(item: ToolCallItem, m: CellMetrics, extraPx: number): Acc {
679
+ const preview = toolInputPreview(item.input)
680
+ const backend = item.backend && item.backend !== 'server' ? ` · ${item.backend}` : ''
681
+ let acc = rowH(`${item.name}(${preview})${backend}`, m, { gutterCells: 2, extraPx })
682
+
683
+ // Each replayed image part draws a box of whole lines, and it draws it in
684
+ // every state — placeholder, picture, failure — so the height is settled
685
+ // before the first byte is asked for and the load can never reflow the list.
686
+ // No wrap and no `exact: false`: the box is the constant, not the image (see
687
+ // `image-box.ts`).
688
+ const images = item.result?.images
689
+ if (images?.length)
690
+ acc = add(acc, { px: images.length * IMAGE_BOX_LINES * m.line, exact: true })
691
+
692
+ if (item.patch) return add(acc, diffHeight(item.patch, m, extraPx))
693
+ const text = item.result?.text ?? ''
694
+ if (!text) return acc
695
+ // The renderer's own budget, not a copy of it: `collapsedResult` returns both
696
+ // the lines and the exact trailing label, so this cannot drift from what
697
+ // `items.tsx` draws — which it previously could, and which its own comment
698
+ // said only the dev audit was catching.
699
+ const { shown, more } = collapsedResult(text.trimEnd().split('\n'), item.result?.totalChars)
700
+ for (const line of shown) {
701
+ // indent=1 with columns=3 resolves the indent against the row's own
702
+ // --term-cell: 3ch of padding + 3ch of gutter.
703
+ acc = add(acc, rowH(line || ' ', m, { indentCells: 3, gutterCells: 3, extraPx }))
704
+ }
705
+ if (more) acc = add(acc, rowH(more, m, { indentCells: 3, gutterCells: 3, extraPx }))
706
+ return acc
707
+ }
708
+
709
+ /** Rows produced inside a subagent are stepped in behind a rule —
710
+ * `border-l-2` (2px) + `pl-3` (12px) on the wrapper in `agent/Transcript.tsx`. */
711
+ const nestedExtraPx = (item: TranscriptItem): number =>
712
+ 'parentToolUseId' in item && item.parentToolUseId != null ? 14 : 0
713
+
714
+ /** One transcript item's height, in its default (collapsed, settled-or-not)
715
+ * presentation. */
716
+ export function itemHeight(item: TranscriptItem, m: CellMetrics): ComputedHeight {
717
+ const extraPx = nestedExtraPx(item)
718
+ switch (item.kind) {
719
+ case 'user': {
720
+ let acc: Acc = { px: 0, exact: true }
721
+ if (item.attachments?.length)
722
+ acc = add(acc, rowH(item.attachments.map((a) => a.name).join(', '), m, { extraPx }))
723
+ if (item.text)
724
+ for (const line of item.text.split('\n')) acc = add(acc, rowH(line || ' ', m, { extraPx }))
725
+ return acc
726
+ }
727
+ case 'assistant_text':
728
+ return markdownHeight(item.text, m, extraPx)
729
+ case 'thinking':
730
+ return rowH(item.text, m, { extraPx })
731
+ case 'tool_call':
732
+ return toolRowHeight(item, m, extraPx)
733
+ case 'turn_result': {
734
+ let acc = rowH(
735
+ `${item.isError ? item.subtype : 'done'} · ${formatDuration(item.durationMs)} · ${formatCost(item.totalCostUsd)}`,
736
+ m,
737
+ { extraPx },
738
+ )
739
+ for (const message of item.errors ?? []) acc = add(acc, rowH(message, m, { extraPx }))
740
+ return acc
741
+ }
742
+ case 'notice':
743
+ return rowH(item.text, m, { extraPx })
744
+ case 'file_delivered': {
745
+ const text =
746
+ `${item.path} · ${formatBytes(item.bytes)}` +
747
+ (item.description ? ` · ${item.description}` : '')
748
+ return rowH(text, m, { extraPx })
749
+ }
750
+ default:
751
+ return { px: 0, exact: false }
752
+ }
753
+ }
754
+
755
+ /** A virtual row's height: an item, a folded tool run, or a task block —
756
+ * either fold collapsed is its one summary line, built by the same function
757
+ * the row draws (`runSummary` / `taskSummary`), rendered as one standard
758
+ * 2-cell-gutter `Row`. No expanded branch for the task block either: it is
759
+ * always collapsed by default, which is the invariant that keeps every
760
+ * unmounted row's estimate exact. */
761
+ export function blockHeight(block: TerminalBlock, m: CellMetrics): ComputedHeight {
762
+ if ('task' in block) {
763
+ return rowH(taskSummary(block.task, taskChildItems(block)), m)
764
+ }
765
+ if ('run' in block) {
766
+ const busy = block.run.some((item) => item.status === 'running' || item.status === 'pending')
767
+ return rowH(runSummary(block.run, busy), m)
768
+ }
769
+ return itemHeight(block.item, m)
770
+ }