@workerdeck/ui 0.23.0 → 1.1.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 (145) hide show
  1. package/build/{SessionPanel-DUt2VzXG.mjs → SessionPanel-I5QSLLb0.mjs} +2266 -3459
  2. package/build/SessionPanel-I5QSLLb0.mjs.map +1 -0
  3. package/build/SessionPanel-yNO60Tzw.d.mts +151 -0
  4. package/build/format-C_lpFq0l.d.mts +16 -0
  5. package/build/format.d.mts +8 -76
  6. package/build/format.mjs +2 -2
  7. package/build/index.d.mts +291 -1695
  8. package/build/index.mjs +6 -301
  9. package/build/index.mjs.map +1 -1
  10. package/build/scoped.css +32 -23
  11. package/build/{status-BE-zg88x.mjs → status-Dm9INVTt.mjs} +17 -88
  12. package/build/status-Dm9INVTt.mjs.map +1 -0
  13. package/build/workspace.d.mts +8 -187
  14. package/build/workspace.mjs +13 -144
  15. package/build/workspace.mjs.map +1 -1
  16. package/package.json +24 -24
  17. package/src/components/agent/CodeEditor.tsx +31 -103
  18. package/src/components/agent/Composer.tsx +114 -333
  19. package/src/components/agent/ContextDialog.tsx +20 -41
  20. package/src/components/agent/ContextRing.tsx +6 -28
  21. package/src/components/agent/Conversation.tsx +21 -33
  22. package/src/components/agent/EditorTabs.tsx +39 -67
  23. package/src/components/agent/EngineIcon.tsx +29 -72
  24. package/src/components/agent/EntryIcon.tsx +16 -0
  25. package/src/components/agent/FileCard.tsx +10 -16
  26. package/src/components/agent/FileTree.tsx +52 -125
  27. package/src/components/agent/FileViewer.tsx +30 -60
  28. package/src/components/agent/HostFilesDialog.tsx +40 -69
  29. package/src/components/agent/Loader.tsx +3 -24
  30. package/src/components/agent/McpDialog.tsx +64 -137
  31. package/src/components/agent/Message.tsx +2 -21
  32. package/src/components/agent/ModelSelect.tsx +33 -62
  33. package/src/components/agent/PermissionModeSelect.tsx +21 -79
  34. package/src/components/agent/PermissionPrompt.tsx +59 -63
  35. package/src/components/agent/ProjectIcon.tsx +228 -93
  36. package/src/components/agent/PromptTokenText.tsx +10 -16
  37. package/src/components/agent/QuestionPrompt.tsx +43 -64
  38. package/src/components/agent/Reasoning.tsx +13 -16
  39. package/src/components/agent/Response.tsx +3 -9
  40. package/src/components/agent/Scrubber.tsx +31 -80
  41. package/src/components/agent/SessionBrowser.tsx +64 -243
  42. package/src/components/agent/SessionEmptyState.tsx +10 -40
  43. package/src/components/agent/SessionInfoDialog.tsx +46 -78
  44. package/src/components/agent/SessionItem.tsx +53 -320
  45. package/src/components/agent/SessionList.tsx +23 -39
  46. package/src/components/agent/SessionPanel.tsx +315 -996
  47. package/src/components/agent/SessionStatusIcon.tsx +14 -28
  48. package/src/components/agent/SessionSteps.tsx +34 -187
  49. package/src/components/agent/SessionWorkspace.tsx +104 -219
  50. package/src/components/agent/SkillsDialog.tsx +35 -80
  51. package/src/components/agent/StatusBar.tsx +51 -136
  52. package/src/components/agent/SubagentStrip.tsx +12 -77
  53. package/src/components/agent/ToolCallCard.tsx +94 -135
  54. package/src/components/agent/Transcript.tsx +103 -1288
  55. package/src/components/agent/TranscriptItemView.tsx +155 -0
  56. package/src/components/agent/TranscriptRows.tsx +418 -0
  57. package/src/components/agent/UsageDialog.tsx +7 -29
  58. package/src/components/agent/UsageMeters.tsx +18 -73
  59. package/src/components/agent/pulse.tsx +9 -32
  60. package/src/components/agent/scrubber-marks.ts +33 -103
  61. package/src/components/agent/status.ts +1 -5
  62. package/src/components/agent/tool-result-fetch.tsx +3 -25
  63. package/src/components/agent/tool-result-image.tsx +32 -111
  64. package/src/components/agent/transcript-rows.ts +41 -118
  65. package/src/components/agent/transcript-variant.tsx +3 -80
  66. package/src/components/agent/use-height-epoch.ts +10 -24
  67. package/src/components/agent/use-path-links.ts +45 -63
  68. package/src/components/agent/use-subagent-frame.ts +106 -0
  69. package/src/components/agent/use-transcript-jumps.ts +49 -107
  70. package/src/components/prompt-area/animated-placeholder.tsx +6 -5
  71. package/src/components/prompt-area/clipboard-helpers.ts +26 -21
  72. package/src/components/prompt-area/cursor-helpers.ts +79 -37
  73. package/src/components/prompt-area/dom-helpers.ts +97 -69
  74. package/src/components/prompt-area/file-strip.tsx +90 -80
  75. package/src/components/prompt-area/html-to-markdown.ts +67 -46
  76. package/src/components/prompt-area/image-strip.tsx +8 -18
  77. package/src/components/prompt-area/index.ts +2 -15
  78. package/src/components/prompt-area/prompt-area-engine.ts +100 -115
  79. package/src/components/prompt-area/prompt-area-list-ops.ts +66 -59
  80. package/src/components/prompt-area/prompt-area.tsx +31 -38
  81. package/src/components/prompt-area/remove-button.tsx +3 -9
  82. package/src/components/prompt-area/segment-helpers.ts +4 -12
  83. package/src/components/prompt-area/trigger-popover.tsx +15 -27
  84. package/src/components/prompt-area/trigger-presets.ts +0 -10
  85. package/src/components/prompt-area/types.ts +3 -8
  86. package/src/components/prompt-area/use-chip-editing.ts +305 -0
  87. package/src/components/prompt-area/use-markdown-mode.ts +7 -17
  88. package/src/components/prompt-area/use-prompt-area-events.ts +80 -86
  89. package/src/components/prompt-area/use-prompt-area-keydown.ts +598 -0
  90. package/src/components/prompt-area/use-prompt-area-state.ts +5 -23
  91. package/src/components/prompt-area/use-prompt-area.ts +170 -896
  92. package/src/components/prompt-area/use-trigger-search.ts +21 -19
  93. package/src/components/terminal/PermissionPrompt.tsx +56 -59
  94. package/src/components/terminal/QuestionPrompt.tsx +28 -79
  95. package/src/components/terminal/StatusLine.tsx +13 -63
  96. package/src/components/terminal/TerminalTranscript.tsx +44 -160
  97. package/src/components/terminal/affordances.tsx +68 -98
  98. package/src/components/terminal/blocks.ts +20 -156
  99. package/src/components/terminal/diff.tsx +19 -67
  100. package/src/components/terminal/height.ts +163 -313
  101. package/src/components/terminal/image-box.ts +0 -44
  102. package/src/components/terminal/items.tsx +150 -305
  103. package/src/components/terminal/markdown.tsx +36 -95
  104. package/src/components/terminal/press.tsx +26 -53
  105. package/src/components/terminal/prompt.tsx +40 -170
  106. package/src/components/terminal/result-preview.ts +8 -56
  107. package/src/components/terminal/row.tsx +11 -98
  108. package/src/components/terminal/scrubber.tsx +108 -401
  109. package/src/components/terminal/surface.tsx +6 -54
  110. package/src/components/terminal/todos.ts +66 -0
  111. package/src/components/terminal/tool-run.ts +21 -192
  112. package/src/components/ui/AlertDialog.tsx +8 -23
  113. package/src/components/ui/Badge.tsx +18 -29
  114. package/src/components/ui/Button.tsx +7 -15
  115. package/src/components/ui/Card.tsx +2 -13
  116. package/src/components/ui/CodeBlock.tsx +10 -37
  117. package/src/components/ui/CopyButton.tsx +10 -23
  118. package/src/components/ui/Dialog.tsx +23 -36
  119. package/src/components/ui/Empty.tsx +6 -29
  120. package/src/components/ui/Input.tsx +14 -16
  121. package/src/components/ui/Menu.tsx +6 -18
  122. package/src/components/ui/PortalScope.tsx +1 -21
  123. package/src/components/ui/ProgressRing.tsx +8 -19
  124. package/src/components/ui/Select.tsx +18 -31
  125. package/src/components/ui/Sonner.tsx +1 -2
  126. package/src/components/ui/Spinner.tsx +1 -1
  127. package/src/components/ui/Splitter.tsx +30 -64
  128. package/src/components/ui/Textarea.tsx +2 -5
  129. package/src/components/ui/Tooltip.tsx +3 -13
  130. package/src/format.ts +0 -9
  131. package/src/index.ts +16 -113
  132. package/src/lib/clipboard.ts +8 -28
  133. package/src/lib/css.ts +8 -0
  134. package/src/lib/format.ts +101 -86
  135. package/src/lib/plan-request.ts +16 -0
  136. package/src/lib/status.ts +45 -95
  137. package/src/lib/tool-icon.ts +39 -47
  138. package/src/lib/utils.ts +1 -3
  139. package/src/styles/terminal.css +81 -49
  140. package/src/styles/theme.css +96 -32
  141. package/src/workspace.ts +1 -24
  142. package/build/SessionPanel-BobynUo4.d.mts +0 -669
  143. package/build/SessionPanel-DUt2VzXG.mjs.map +0 -1
  144. package/build/format-ljc3lKpA.d.mts +0 -59
  145. package/build/status-BE-zg88x.mjs.map +0 -1
@@ -1,111 +1,23 @@
1
1
  import type { FilePatch, PatchHunk } from '@workerdeck/protocol'
2
2
  import type { TranscriptItem } from '@workerdeck/react'
3
- import {
4
- formatBytes,
5
- formatCost,
6
- formatDuration,
7
- toolInputPreview,
8
- } from '../../lib/format.ts'
3
+ import { formatBytes, formatCost, formatDuration, toolInputPreview } from '../../lib/format.ts'
9
4
  import { taskChildItems, type TerminalBlock, type ToolCallItem } from './blocks.ts'
10
5
  import { IMAGE_BOX_LINES } from './image-box.ts'
11
6
  import { collapsedResult } from './result-preview.ts'
7
+ import { todoLine, todoPreview } from './todos.ts'
12
8
  import { runSummary, taskSummary } from './tool-run.ts'
13
9
 
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
10
  export type CellMetrics = {
79
- /** Content-box width of the virtual row wrapper, px. */
80
11
  width: number
81
- /** Advance of `0`, px. */
82
12
  ch: number
83
- /** `--term-line`, px. */
84
13
  line: number
85
14
  }
86
15
 
87
16
  export type ComputedHeight = {
88
17
  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
18
  exact: boolean
93
19
  }
94
20
 
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
21
  export type HeightEpoch = CellMetrics & {
110
22
  cache: WeakMap<TranscriptItem, ComputedHeight>
111
23
  }
@@ -114,52 +26,27 @@ export function createHeightEpoch(width: number, ch: number, line: number): Heig
114
26
  return { width, ch, line, cache: new WeakMap() }
115
27
  }
116
28
 
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
29
  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
30
+ if (!('item' in block)) {
31
+ return blockHeight(block, epoch).px
32
+ }
128
33
  const hit = epoch.cache.get(block.item)
129
- if (hit) return hit.px
34
+ if (hit) {
35
+ return hit.px
36
+ }
130
37
  const computed = itemHeight(block.item, epoch)
131
38
  epoch.cache.set(block.item, computed)
132
39
  return computed.px
133
40
  }
134
41
 
135
- /**
136
- * How many lines of a sub-agent's brief the collapsed row shows.
137
- *
138
- * Four, and the number is a judgement rather than a measurement: a brief runs
139
- * to thousands of characters, and an uncapped one buries the work it was asking
140
- * for under its own instructions. Four is enough to recognise the task and
141
- * short enough that the agent's first line stays on screen beside it.
142
- */
143
42
  export const BRIEF_LINES = 4
144
43
 
145
- /**
146
- * The collapsed brief row's height — the frame's first row, which is synthetic
147
- * and so cannot go through {@link estimateBlockPx}.
148
- *
149
- * Clipped, so it is `min(wrapped, BRIEF_LINES)` lines plus the row the header
150
- * occupies. Expanding is local state on a *mounted* row, which the virtualizer
151
- * re-measures; what has to be right before it mounts is the collapsed height,
152
- * exactly as a task row is always collapsed when unmounted for the same reason.
153
- */
154
44
  export function briefPx(text: string, m: CellMetrics): number {
155
45
  const cols = Math.max(1, Math.floor(m.width / m.ch + EPS))
156
46
  return (Math.min(textLines(text, cols).lines, BRIEF_LINES) + 1) * m.line
157
47
  }
158
48
 
159
- /** Measure the advance of `0` on the live surface. A DOM read call it from
160
- * the epoch's measurement pass (an effect / ResizeObserver callback), never
161
- * from render. The probe is absolutely positioned, so it contributes no layout
162
- * and cannot re-trigger the observer that called it. */
49
+ // Absolutely positioned so the probe adds no layout and cannot re-trigger the ResizeObserver that called it.
163
50
  export function measureCh(surface: HTMLElement): number {
164
51
  const probe = document.createElement('span')
165
52
  probe.textContent = '0'.repeat(200)
@@ -172,69 +59,61 @@ export function measureCh(surface: HTMLElement): number {
172
59
  return width
173
60
  }
174
61
 
175
- /* ── Cell counting ─────────────────────────────────────────────────────────── */
176
-
177
62
  const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' })
178
63
 
179
64
  const WIDE_RANGES: [number, number][] = [
180
- [0x1100, 0x115f], // Hangul Jamo
181
- [0x2e80, 0x303e], // CJK radicals, Kangxi, CJK symbols/punctuation
182
- [0x3041, 0x33ff], // Hiragana … CJK compatibility
65
+ [0x1100, 0x115f],
66
+ [0x2e80, 0x303e],
67
+ [0x3041, 0x33ff],
183
68
  [0x3400, 0x4dbf],
184
69
  [0x4e00, 0x9fff],
185
70
  [0xa000, 0xa4cf],
186
- [0xac00, 0xd7a3], // Hangul syllables
71
+ [0xac00, 0xd7a3],
187
72
  [0xf900, 0xfaff],
188
73
  [0xfe30, 0xfe4f],
189
- [0xff00, 0xff60], // fullwidth forms
74
+ [0xff00, 0xff60],
190
75
  [0xffe0, 0xffe6],
191
76
  [0x20000, 0x3fffd],
192
77
  ]
193
78
 
194
- const isWide = (cp: number): boolean => WIDE_RANGES.some(([lo, hi]) => cp >= lo && cp <= hi)
79
+ function isWide(cp: number): boolean {
80
+ return WIDE_RANGES.some(([lo, hi]) => cp >= lo && cp <= hi)
81
+ }
195
82
 
196
83
  const PICTOGRAPHIC = /\p{Extended_Pictographic}/u
197
84
 
198
- /** One grapheme cluster's advance, in cells. Wide and pictographic clusters are
199
- * counted as 2 but flagged: they render from a fallback face whose advance is
200
- * not a whole number of cells, and the flag is what keeps the model honest. */
201
85
  function clusterCells(cluster: string): { w: number; exact: boolean } {
202
- if (PICTOGRAPHIC.test(cluster) || cluster.includes('‍') || cluster.includes('️'))
86
+ if (PICTOGRAPHIC.test(cluster) || cluster.includes('‍') || cluster.includes('️')) {
203
87
  return { w: 2, exact: false }
88
+ }
204
89
  const cp = cluster.codePointAt(0) ?? 0
205
- if (isWide(cp)) return { w: 2, exact: false }
206
- if (cp < 0x20) return { w: 0, exact: true }
90
+ if (isWide(cp)) {
91
+ return { w: 2, exact: false }
92
+ }
93
+ if (cp < 0x20) {
94
+ return { w: 0, exact: true }
95
+ }
207
96
  return { w: 1, exact: true }
208
97
  }
209
98
 
210
- /* ── The wrap model ────────────────────────────────────────────────────────── */
211
-
212
99
  type Token = { kind: 'word' | 'space'; w: number; exact: boolean }
213
100
 
214
- /** `tab-size: 2` on the surface. */
101
+ // Must match `tab-size: 2` on the surface.
215
102
  const TAB_SIZE = 2
216
103
 
217
- /** Break *after* these when the next character is not a digit. The digit guard
218
- * keeps `protocol-0.16.0` together; `?` is in the set because Chrome breaks
219
- * long URLs after it (verified against real break rects). */
104
+ // Break after these unless a digit follows; `?` is here because Chrome breaks long URLs after it (verified against real break rects).
220
105
  const BREAK_AFTER = new Set(['-', '–', '—', '?'])
221
106
 
222
- /** Printable ASCII, tab excluded: every character is one cell and one grapheme,
223
- * so the segmenter has nothing to tell us. This is nearly every line a
224
- * transcript holds, and the segmenter was the calculator's whole CPU bill —
225
- * ~1s of a 30s scroll sweep on the 4k-item perf fixture, all of it spent
226
- * segmenting text that `charCodeAt` classifies for free. */
227
107
  const PLAIN_ASCII = /^[\x20-\x7e]*$/
228
108
 
229
- /** The ASCII half of {@link tokenize}: same tokens, same break rules ('-' and
230
- * '?' break unless a digit follows; '–'/'—' are not ASCII), no segmenter and
231
- * no per-grapheme allocation. Must stay semantically identical to the general
232
- * path — the height audit runs both, via content that takes each. */
109
+ // Must stay semantically identical to `tokenize`; the height audit runs content that takes each path.
233
110
  function tokenizeAscii(line: string): Token[] {
234
111
  const tokens: Token[] = []
235
112
  let wordW = 0
236
113
  const flushWord = () => {
237
- if (wordW > 0) tokens.push({ kind: 'word', w: wordW, exact: true })
114
+ if (wordW > 0) {
115
+ tokens.push({ kind: 'word', w: wordW, exact: true })
116
+ }
238
117
  wordW = 0
239
118
  }
240
119
  for (let i = 0; i < line.length; i++) {
@@ -242,33 +121,36 @@ function tokenizeAscii(line: string): Token[] {
242
121
  if (code === 0x20) {
243
122
  flushWord()
244
123
  const last = tokens[tokens.length - 1]
245
- if (last?.kind === 'space') last.w += 1
246
- else tokens.push({ kind: 'space', w: 1, exact: true })
124
+ if (last?.kind === 'space') {
125
+ last.w += 1
126
+ } else {
127
+ tokens.push({ kind: 'space', w: 1, exact: true })
128
+ }
247
129
  continue
248
130
  }
249
131
  wordW += 1
250
- if (code === 0x2d /* - */ || code === 0x3f /* ? */) {
132
+ if (code === 0x2d || code === 0x3f) {
251
133
  const next = line.charCodeAt(i + 1)
252
- if (!(next >= 0x30 && next <= 0x39)) flushWord()
134
+ if (!(next >= 0x30 && next <= 0x39)) {
135
+ flushWord()
136
+ }
253
137
  }
254
138
  }
255
139
  flushWord()
256
140
  return tokens
257
141
  }
258
142
 
259
- /**
260
- * Tokenize one hard line into wrap units. Tabs advance to the next 2-cell stop
261
- * measured from the hard line's start — position-dependent after a soft wrap in
262
- * principle, but content with tabs beyond the first wrap point is vanishingly
263
- * rare and the error is bounded by one tab stop.
264
- */
265
143
  function tokenize(line: string): Token[] {
266
- if (PLAIN_ASCII.test(line)) return tokenizeAscii(line)
144
+ if (PLAIN_ASCII.test(line)) {
145
+ return tokenizeAscii(line)
146
+ }
267
147
  const tokens: Token[] = []
268
148
  let word = { w: 0, exact: true }
269
149
  let col = 0
270
150
  const flushWord = () => {
271
- if (word.w > 0) tokens.push({ kind: 'word', w: word.w, exact: word.exact })
151
+ if (word.w > 0) {
152
+ tokens.push({ kind: 'word', w: word.w, exact: word.exact })
153
+ }
272
154
  word = { w: 0, exact: true }
273
155
  }
274
156
  const segments = [...segmenter.segment(line)].map((s) => s.segment)
@@ -276,8 +158,11 @@ function tokenize(line: string): Token[] {
276
158
  if (segment === ' ') {
277
159
  flushWord()
278
160
  const last = tokens.at(-1)
279
- if (last?.kind === 'space') last.w += 1
280
- else tokens.push({ kind: 'space', w: 1, exact: true })
161
+ if (last?.kind === 'space') {
162
+ last.w += 1
163
+ } else {
164
+ tokens.push({ kind: 'space', w: 1, exact: true })
165
+ }
281
166
  col += 1
282
167
  continue
283
168
  }
@@ -285,8 +170,11 @@ function tokenize(line: string): Token[] {
285
170
  flushWord()
286
171
  const advance = TAB_SIZE - (col % TAB_SIZE) || TAB_SIZE
287
172
  const last = tokens.at(-1)
288
- if (last?.kind === 'space') last.w += advance
289
- else tokens.push({ kind: 'space', w: advance, exact: true })
173
+ if (last?.kind === 'space') {
174
+ last.w += advance
175
+ } else {
176
+ tokens.push({ kind: 'space', w: advance, exact: true })
177
+ }
290
178
  col += advance
291
179
  continue
292
180
  }
@@ -310,9 +198,10 @@ function tokenize(line: string): Token[] {
310
198
  return tokens
311
199
  }
312
200
 
313
- /** Visual lines one hard line occupies at `cols` columns. */
314
201
  function wrapOne(line: string, cols: number): { lines: number; exact: boolean } {
315
- if (cols <= 0) return { lines: 1, exact: false }
202
+ if (cols <= 0) {
203
+ return { lines: 1, exact: false }
204
+ }
316
205
  const tokens = tokenize(line)
317
206
  let lines = 1
318
207
  let pos = 0
@@ -320,8 +209,7 @@ function wrapOne(line: string, cols: number): { lines: number; exact: boolean }
320
209
  for (const token of tokens) {
321
210
  exact = exact && token.exact
322
211
  if (token.kind === 'space') {
323
- // Preserved spaces hang at the line end (CSS Text 3): they may overflow
324
- // without forcing a wrap, and the next word starts the next line.
212
+ // Preserved spaces hang at the line end (CSS Text 3): they overflow without forcing a wrap.
325
213
  pos += token.w
326
214
  continue
327
215
  }
@@ -335,7 +223,9 @@ function wrapOne(line: string, cols: number): { lines: number; exact: boolean }
335
223
  continue
336
224
  }
337
225
  // break-word: the token first moves to its own line, then fills whole lines.
338
- if (pos > 0) lines += 1
226
+ if (pos > 0) {
227
+ lines += 1
228
+ }
339
229
  const full = Math.ceil(token.w / cols)
340
230
  lines += full - 1
341
231
  pos = token.w - (full - 1) * cols
@@ -343,9 +233,6 @@ function wrapOne(line: string, cols: number): { lines: number; exact: boolean }
343
233
  return { lines, exact }
344
234
  }
345
235
 
346
- /** Visual lines of a (possibly multi-hard-line) text at `cols` columns.
347
- * Exported for the dev audit; also the one genuinely pure piece a unit test
348
- * could pin, should `ui` ever grow a runner. */
349
236
  export function textLines(text: string, cols: number): { lines: number; exact: boolean } {
350
237
  let lines = 0
351
238
  let exact = true
@@ -357,40 +244,23 @@ export function textLines(text: string, cols: number): { lines: number; exact: b
357
244
  return { lines: Math.max(1, lines), exact }
358
245
  }
359
246
 
360
- /* ── Rows ──────────────────────────────────────────────────────────────────── */
361
-
362
- /** Layout rounds to 1/64px; the epsilon keeps a body width that is exactly
363
- * N × ch from flooring to N−1. */
247
+ // Layout rounds to 1/64px; the epsilon keeps a body width of exactly N × ch from flooring to N−1.
364
248
  const EPS = 1e-4
365
249
 
366
250
  type Acc = { px: number; exact: boolean }
367
251
 
368
- const add = (a: Acc, b: Acc): Acc => ({ px: a.px + b.px, exact: a.exact && b.exact })
369
-
370
- /**
371
- * One `Row`: `indentCells` of padding (already resolved against the row's own
372
- * `--term-cell` a `columns={3} indent={1}` row loses 3ch to the indent *and*
373
- * 3ch to the gutter, because the indent padding reads the same variable the
374
- * override sets), `gutterCells` of gutter, the body wrapping in what is left.
375
- * `extraPx` is non-cell chrome around the row (the nested-run border+padding).
376
- */
377
- function rowH(
378
- text: string,
379
- m: CellMetrics,
380
- { indentCells = 0, gutterCells = 2, extraPx = 0 } = {},
381
- ): Acc {
252
+ function add(a: Acc, b: Acc): Acc {
253
+ return { px: a.px + b.px, exact: a.exact && b.exact }
254
+ }
255
+
256
+ // `indent` resolves against the row's own `--term-cell`, so `columns={3} indent={1}` loses 3ch to the indent and 3ch to the gutter.
257
+ function rowH(text: string, m: CellMetrics, { indentCells = 0, gutterCells = 2, extraPx = 0 } = {}): Acc {
382
258
  const bodyPx = m.width - extraPx - (indentCells + gutterCells) * m.ch
383
259
  const cols = Math.floor(bodyPx / m.ch + EPS)
384
260
  const { lines, exact } = textLines(text, cols)
385
261
  return { px: lines * m.line, exact }
386
262
  }
387
263
 
388
- /* ── Markdown ──────────────────────────────────────────────────────────────── */
389
-
390
- /** Rendered text of an inline run — markers stripped the way the component map
391
- * renders them (`**bold**` is 4 chars narrower on screen than in source).
392
- * Approximate: reference links, raw HTML and nested emphasis edge cases are
393
- * not modelled. */
394
264
  function stripInline(s: string): string {
395
265
  return s
396
266
  .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
@@ -404,32 +274,16 @@ function stripInline(s: string): string {
404
274
  .replace(/\\([\\`*_{}[\]()#+.!-])/g, '$1')
405
275
  }
406
276
 
407
- /**
408
- * Text blocks carry **rendered lines**, resolved by CommonMark's own break
409
- * rule: a source line ending in two spaces (or a backslash) is a hard break
410
- * and renders as `<br>`; any other newline is soft and collapses to a space
411
- * under `white-space: normal`. Both halves are load-bearing and each was the
412
- * whole model once. Join-always missed the hard breaks — models write poems
413
- * with trailing double-spaces, and a 350-line one estimated ~115 lines short
414
- * (a phantom scrollbar tail, scrubber marks resizing as rows mounted).
415
- * Break-always missed the soft ones and overestimated the same stanza written
416
- * without them by four lines. The `markdown` fixture carries one poem of each
417
- * as the audit's guard.
418
- */
419
277
  type MdBlock =
420
278
  | { t: 'p'; lines: string[] }
421
279
  | { t: 'h'; text: string }
422
280
  | { t: 'fence'; code: string }
423
281
  | { t: 'list'; items: { lines: string[]; gutter: number; indent: number }[] }
424
282
  | { t: 'quote'; paras: string[][] }
425
- /** One line per row, unconditionally — see the `table` case in
426
- * {@link markdownHeight}. */
427
283
  | { t: 'table'; rows: number }
428
284
  | { t: 'hr' }
429
285
 
430
- /** Source lines of one paragraph-ish run the lines the renderer draws: hard
431
- * breaks (trailing double space or backslash) split, soft newlines join with a
432
- * space. The break marker itself never renders and is stripped. */
286
+ // CommonMark: a trailing double space or backslash is a hard break; any other newline is soft and joins with a space.
433
287
  function hardLines(source: string[]): string[] {
434
288
  const out: string[] = []
435
289
  let current: string[] = []
@@ -441,12 +295,12 @@ function hardLines(source: string[]): string[] {
441
295
  current = []
442
296
  }
443
297
  }
444
- if (current.length) out.push(current.join(' '))
298
+ if (current.length) {
299
+ out.push(current.join(' '))
300
+ }
445
301
  return out
446
302
  }
447
303
 
448
- /** A line-based CommonMark-ish block parser — just enough structure to mirror
449
- * what Streamdown + the terminal component map emit for transcript content. */
450
304
  function parseBlocks(md: string): MdBlock[] {
451
305
  const lines = md.split('\n')
452
306
  const blocks: MdBlock[] = []
@@ -465,7 +319,7 @@ function parseBlocks(md: string): MdBlock[] {
465
319
  code.push(lines[i]!)
466
320
  i += 1
467
321
  }
468
- i += 1 // closing fence (or EOF, mid-stream)
322
+ i += 1
469
323
  blocks.push({ t: 'fence', code: code.join('\n') })
470
324
  continue
471
325
  }
@@ -490,26 +344,31 @@ function parseBlocks(md: string): MdBlock[] {
490
344
  let current: string[] = []
491
345
  for (const l of raw) {
492
346
  if (l.trim() === '') {
493
- if (current.length) paras.push(hardLines(current))
347
+ if (current.length) {
348
+ paras.push(hardLines(current))
349
+ }
494
350
  current = []
495
- } else current.push(l)
351
+ } else {
352
+ current.push(l)
353
+ }
354
+ }
355
+ if (current.length) {
356
+ paras.push(hardLines(current))
496
357
  }
497
- if (current.length) paras.push(hardLines(current))
498
358
  blocks.push({ t: 'quote', paras })
499
359
  continue
500
360
  }
501
361
  const listMarker = (s: string) => {
502
362
  const m = /^(\s*)([-*+]|\d+\.)\s+(.*)$/.exec(s)
503
- if (!m) return undefined
363
+ if (!m) {
364
+ return undefined
365
+ }
504
366
  const depth = Math.floor(m[1]!.length / 2)
505
367
  const ordered = /\d/.test(m[2]!)
506
368
  return { depth, ordered, text: m[3]! }
507
369
  }
508
370
  if (listMarker(line)) {
509
371
  const items: { lines: string[]; gutter: number; indent: number }[] = []
510
- // Marker cells per level, outermost first — a nested item's indent is the
511
- // sum of its ancestors' gutters (each nested list sits in its parent li's
512
- // body column).
513
372
  const gutters: number[] = []
514
373
  let listOrdered: boolean | undefined
515
374
  while (i < lines.length) {
@@ -521,21 +380,18 @@ function parseBlocks(md: string): MdBlock[] {
521
380
  }
522
381
  break
523
382
  }
524
- // A different marker type at the top level starts a *new* list — one
525
- // more block, one more inter-block margin.
526
383
  if (m.depth === 0) {
527
- if (listOrdered !== undefined && m.ordered !== listOrdered) break
384
+ if (listOrdered !== undefined && m.ordered !== listOrdered) {
385
+ break
386
+ }
528
387
  listOrdered = m.ordered
529
388
  }
530
389
  gutters[m.depth] = m.ordered ? 3 : 2
531
390
  const indent = gutters.slice(0, m.depth).reduce((a, b) => a + (b ?? 2), 0)
532
391
  const source = [m.text]
533
392
  i += 1
534
- // Lazy continuation lines belong to the item; whether each newline
535
- // renders is `hardLines`' call, same as a paragraph's.
536
393
  while (i < lines.length && lines[i]!.trim() !== '' && !listMarker(lines[i]!)) {
537
- // trimStart only: a trailing double space is the hard-break marker,
538
- // and `hardLines` needs to see it.
394
+ // trimStart only: a trailing double space is the hard-break marker `hardLines` needs to see.
539
395
  source.push(lines[i]!.trimStart())
540
396
  i += 1
541
397
  }
@@ -545,25 +401,20 @@ function parseBlocks(md: string): MdBlock[] {
545
401
  continue
546
402
  }
547
403
  if (line.trimStart().startsWith('|')) {
548
- // Only the row count. A table lays out at `max-content` and scrolls rather
549
- // than compressing, so a row is a line whatever the cells hold — the
550
- // per-column width measuring this used to do fed a wrap model that no
551
- // longer exists, and it was real work on every table.
552
404
  let rows = 0
553
405
  while (i < lines.length && lines[i]!.trimStart().startsWith('|')) {
554
- const cells = lines[i]!
555
- .trim()
406
+ const cells = lines[i]!.trim()
556
407
  .replace(/^\||\|$/g, '')
557
408
  .split('|')
558
409
  .map((cell) => cell.trim())
559
- // The `|---|:--:|` delimiter row is structure, not a rendered line.
560
- if (!cells.every((cell) => /^:?-+:?$/.test(cell))) rows += 1
410
+ if (!cells.every((cell) => /^:?-+:?$/.test(cell))) {
411
+ rows += 1
412
+ }
561
413
  i += 1
562
414
  }
563
415
  blocks.push({ t: 'table', rows: Math.max(1, rows) })
564
416
  continue
565
417
  }
566
- // Paragraph: gather until a blank line or another block opener.
567
418
  const para: string[] = [line]
568
419
  i += 1
569
420
  while (
@@ -584,15 +435,15 @@ function parseBlocks(md: string): MdBlock[] {
584
435
  return blocks
585
436
  }
586
437
 
587
- /** The markdown body's height: blocks, one line between consecutive ones
588
- * (`.term-md .term-block + .term-block`). Exported for the dev audit only. */
589
438
  export function markdownHeight(md: string, m: CellMetrics, extraPx = 0): Acc {
590
- const bodyPx = m.width - extraPx - 2 * m.ch // the outer Row's gutter
439
+ const bodyPx = m.width - extraPx - 2 * m.ch
591
440
  const cols = Math.floor(bodyPx / m.ch + EPS)
592
441
  const blocks = parseBlocks(md)
593
442
  let acc: Acc = { px: 0, exact: true }
594
443
  for (const [index, block] of blocks.entries()) {
595
- if (index > 0) acc = add(acc, { px: m.line, exact: true })
444
+ if (index > 0) {
445
+ acc = add(acc, { px: m.line, exact: true })
446
+ }
596
447
  switch (block.t) {
597
448
  case 'p': {
598
449
  for (const line of block.lines) {
@@ -634,7 +485,9 @@ export function markdownHeight(md: string, m: CellMetrics, extraPx = 0): Acc {
634
485
  let px = 0
635
486
  let exact = true
636
487
  for (const [pi, para] of block.paras.entries()) {
637
- if (pi > 0) px += m.line
488
+ if (pi > 0) {
489
+ px += m.line
490
+ }
638
491
  for (const line of para) {
639
492
  const r = textLines(line, cols - 2)
640
493
  px += r.lines * m.line
@@ -644,26 +497,19 @@ export function markdownHeight(md: string, m: CellMetrics, extraPx = 0): Acc {
644
497
  acc = add(acc, { px, exact })
645
498
  break
646
499
  }
647
- case 'table':
648
- // One line per row, always. A table is laid out at `max-content` and
649
- // carries no `max-width`, so a wide one scrolls inside
650
- // `.term-table-wrap` rather than compressing — which means the auto
651
- // table layout never wraps a cell and there is nothing here to model.
652
- // This used to branch on whether the table fit, and flag the wide case:
653
- // that branch was the theme's own `max-width: 100%` leaking into the
654
- // calculator, and both went at once. See `terminal.css` §Markdown.
500
+ case 'table': {
655
501
  acc = add(acc, { px: block.rows * m.line, exact: true })
656
502
  break
657
- case 'hr':
503
+ }
504
+ case 'hr': {
658
505
  acc = add(acc, { px: m.line, exact: true })
659
506
  break
507
+ }
660
508
  }
661
509
  }
662
510
  return { px: Math.max(acc.px, m.line), exact: acc.exact }
663
511
  }
664
512
 
665
- /* ── Items ─────────────────────────────────────────────────────────────────── */
666
-
667
513
  function diffHeight(patch: FilePatch, m: CellMetrics, extraPx = 0): Acc {
668
514
  const walk = (hunk: PatchHunk) => {
669
515
  let newLine = hunk.newStart
@@ -681,107 +527,111 @@ function diffHeight(patch: FilePatch, m: CellMetrics, extraPx = 0): Acc {
681
527
  }
682
528
  const hunks = patch.hunks.map(walk)
683
529
  const numbered = patch.hunks.some((hunk) => hunk.newStart > 0)
684
- const width = numbered
685
- ? String(Math.max(...hunks.flat().map((row) => row.number), 1)).length
686
- : 0
530
+ const width = numbered ? String(Math.max(...hunks.flat().map((row) => row.number), 1)).length : 0
687
531
  const columns = numbered ? width + 3 : 2
688
532
  let acc: Acc = { px: 0, exact: true }
689
533
  for (const [index, hunk] of hunks.entries()) {
690
- if (index > 0) acc = add(acc, { px: m.line, exact: true }) // the ⋮ separator row
534
+ if (index > 0) {
535
+ acc = add(acc, { px: m.line, exact: true })
536
+ } // the ⋮ separator row
691
537
  for (const row of hunk) {
692
538
  acc = add(acc, rowH(row.text || ' ', m, { indentCells: 2, gutterCells: columns, extraPx }))
693
539
  }
694
540
  }
695
- if (patch.truncated) acc = add(acc, { px: m.line, exact: true })
541
+ if (patch.truncated) {
542
+ acc = add(acc, { px: m.line, exact: true })
543
+ }
696
544
  return acc
697
545
  }
698
546
 
699
- /** A collapsed tool row: the header, then the diff (when the call carries a
700
- * patch) or the shared collapsed preview plus its trailing "there is more" row.
701
- * No expanded branch — see the module comment's first invariant. */
702
547
  function toolRowHeight(item: ToolCallItem, m: CellMetrics, extraPx: number): Acc {
703
- const preview = toolInputPreview(item.input)
548
+ const todos = todoPreview(item.name, item.input)
549
+ const preview = todos ? todos.summary : toolInputPreview(item.input)
704
550
  const backend = item.backend && item.backend !== 'server' ? ` · ${item.backend}` : ''
705
551
  let acc = rowH(`${item.name}(${preview})${backend}`, m, { gutterCells: 2, extraPx })
706
552
 
707
- // Each replayed image part draws a box of whole lines, and it draws it in
708
- // every state — placeholder, picture, failure — so the height is settled
709
- // before the first byte is asked for and the load can never reflow the list.
710
- // No wrap and no `exact: false`: the box is the constant, not the image (see
711
- // `image-box.ts`).
712
553
  const images = item.result?.images
713
- if (images?.length)
554
+ if (images?.length) {
714
555
  acc = add(acc, { px: images.length * IMAGE_BOX_LINES * m.line, exact: true })
556
+ }
715
557
 
716
- if (item.patch) return add(acc, diffHeight(item.patch, m, extraPx))
558
+ if (item.patch) {
559
+ return add(acc, diffHeight(item.patch, m, extraPx))
560
+ }
561
+ if (todos) {
562
+ for (const todo of todos.shown) {
563
+ acc = add(acc, rowH(todoLine(todo), m, { indentCells: 3, gutterCells: 3, extraPx }))
564
+ }
565
+ if (todos.more) {
566
+ acc = add(acc, rowH(todos.more, m, { indentCells: 3, gutterCells: 3, extraPx }))
567
+ }
568
+ return acc
569
+ }
717
570
  const text = item.result?.text ?? ''
718
- if (!text) return acc
719
- // The renderer's own budget, not a copy of it: `collapsedResult` returns both
720
- // the lines and the exact trailing label, so this cannot drift from what
721
- // `items.tsx` draws — which it previously could, and which its own comment
722
- // said only the dev audit was catching.
571
+ if (!text) {
572
+ return acc
573
+ }
723
574
  const { shown, more } = collapsedResult(text.trimEnd().split('\n'), item.result?.totalChars)
724
575
  for (const line of shown) {
725
- // indent=1 with columns=3 resolves the indent against the row's own
726
- // --term-cell: 3ch of padding + 3ch of gutter.
727
576
  acc = add(acc, rowH(line || ' ', m, { indentCells: 3, gutterCells: 3, extraPx }))
728
577
  }
729
- if (more) acc = add(acc, rowH(more, m, { indentCells: 3, gutterCells: 3, extraPx }))
578
+ if (more) {
579
+ acc = add(acc, rowH(more, m, { indentCells: 3, gutterCells: 3, extraPx }))
580
+ }
730
581
  return acc
731
582
  }
732
583
 
733
- /** Rows produced inside a subagent are stepped in behind a rule
734
- * `border-l-2` (2px) + `pl-3` (12px) on the wrapper in `agent/Transcript.tsx`. */
735
- const nestedExtraPx = (item: TranscriptItem): number =>
736
- 'parentToolUseId' in item && item.parentToolUseId != null ? 14 : 0
584
+ // Nested rows are stepped in behind a rule: `border-l-2` (2px) + `pl-3` (12px) on the wrapper in `agent/Transcript.tsx`.
585
+ function nestedExtraPx(item: TranscriptItem): number {
586
+ return 'parentToolUseId' in item && item.parentToolUseId != null ? 14 : 0
587
+ }
737
588
 
738
- /** One transcript item's height, in its default (collapsed, settled-or-not)
739
- * presentation. */
740
589
  export function itemHeight(item: TranscriptItem, m: CellMetrics): ComputedHeight {
741
590
  const extraPx = nestedExtraPx(item)
742
591
  switch (item.kind) {
743
592
  case 'user': {
744
593
  let acc: Acc = { px: 0, exact: true }
745
- if (item.attachments?.length)
594
+ if (item.attachments?.length) {
746
595
  acc = add(acc, rowH(item.attachments.map((a) => a.name).join(', '), m, { extraPx }))
747
- if (item.text)
748
- for (const line of item.text.split('\n')) acc = add(acc, rowH(line || ' ', m, { extraPx }))
596
+ }
597
+ if (item.text) {
598
+ for (const line of item.text.split('\n')) {
599
+ acc = add(acc, rowH(line || ' ', m, { extraPx }))
600
+ }
601
+ }
749
602
  return acc
750
603
  }
751
- case 'assistant_text':
604
+ case 'assistant_text': {
752
605
  return markdownHeight(item.text, m, extraPx)
753
- case 'thinking':
606
+ }
607
+ case 'thinking': {
754
608
  return rowH(item.text, m, { extraPx })
755
- case 'tool_call':
609
+ }
610
+ case 'tool_call': {
756
611
  return toolRowHeight(item, m, extraPx)
612
+ }
757
613
  case 'turn_result': {
758
- let acc = rowH(
759
- `${item.isError ? item.subtype : 'done'} · ${formatDuration(item.durationMs)} · ${formatCost(item.totalCostUsd)}`,
760
- m,
761
- { extraPx },
762
- )
763
- for (const message of item.errors ?? []) acc = add(acc, rowH(message, m, { extraPx }))
614
+ let acc = rowH(`${item.isError ? item.subtype : 'done'} · ${formatDuration(item.durationMs)} · ${formatCost(item.totalCostUsd)}`, m, {
615
+ extraPx,
616
+ })
617
+ for (const message of item.errors ?? []) {
618
+ acc = add(acc, rowH(message, m, { extraPx }))
619
+ }
764
620
  return acc
765
621
  }
766
- case 'notice':
622
+ case 'notice': {
767
623
  return rowH(item.text, m, { extraPx })
624
+ }
768
625
  case 'file_delivered': {
769
- const text =
770
- `${item.path} · ${formatBytes(item.bytes)}` +
771
- (item.description ? ` · ${item.description}` : '')
626
+ const text = `${item.path} · ${formatBytes(item.bytes)}` + (item.description ? ` · ${item.description}` : '')
772
627
  return rowH(text, m, { extraPx })
773
628
  }
774
- default:
629
+ default: {
775
630
  return { px: 0, exact: false }
631
+ }
776
632
  }
777
633
  }
778
634
 
779
- /** A virtual row's height: an item, a folded tool run, or a task block —
780
- * either fold collapsed is its one summary line, built by the same function
781
- * the row draws (`runSummary` / `taskSummary`), rendered as one standard
782
- * 2-cell-gutter `Row`. No expanded branch for the task block either: it is
783
- * always collapsed by default, which is the invariant that keeps every
784
- * unmounted row's estimate exact. */
785
635
  export function blockHeight(block: TerminalBlock, m: CellMetrics): ComputedHeight {
786
636
  if ('task' in block) {
787
637
  return rowH(taskSummary(block.task, taskChildItems(block)), m)