@workerdeck/ui 0.16.0 → 0.18.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 (47) hide show
  1. package/README.md +7 -0
  2. package/build/{SessionPanel-B9CHoq8x.d.mts → SessionPanel-CnU_IJ3-.d.mts} +36 -9
  3. package/build/{SessionPanel-DII9MmQ8.mjs → SessionPanel-DPx8Iz8a.mjs} +1432 -384
  4. package/build/SessionPanel-DPx8Iz8a.mjs.map +1 -0
  5. package/build/{format-DfI_je9S.d.mts → format-ljc3lKpA.d.mts} +1 -1
  6. package/build/format.d.mts +16 -5
  7. package/build/format.mjs +2 -3
  8. package/build/index.d.mts +288 -8
  9. package/build/index.mjs +620 -165
  10. package/build/index.mjs.map +1 -1
  11. package/build/{format-DqR56Y8l.mjs → status-BE-zg88x.mjs} +154 -2
  12. package/build/status-BE-zg88x.mjs.map +1 -0
  13. package/build/workspace.d.mts +4 -1
  14. package/build/workspace.mjs +4 -3
  15. package/build/workspace.mjs.map +1 -1
  16. package/package.json +8 -6
  17. package/src/components/agent/ContextRing.tsx +41 -0
  18. package/src/components/agent/EngineIcon.tsx +40 -0
  19. package/src/components/agent/ProjectIcon.tsx +119 -0
  20. package/src/components/agent/SessionBrowser.tsx +191 -17
  21. package/src/components/agent/SessionPanel.tsx +177 -2
  22. package/src/components/agent/SessionSteps.tsx +233 -0
  23. package/src/components/agent/SessionWorkspace.tsx +4 -0
  24. package/src/components/agent/StatusBar.tsx +4 -2
  25. package/src/components/agent/SubagentStrip.tsx +134 -0
  26. package/src/components/agent/ToolCallCard.tsx +72 -5
  27. package/src/components/agent/Transcript.tsx +212 -23
  28. package/src/components/agent/tool-result-fetch.tsx +36 -0
  29. package/src/components/agent/tool-result-image.tsx +209 -0
  30. package/src/components/agent/transcript-rows.ts +122 -23
  31. package/src/components/terminal/TerminalTranscript.tsx +154 -2
  32. package/src/components/terminal/affordances.tsx +34 -0
  33. package/src/components/terminal/blocks.ts +260 -0
  34. package/src/components/terminal/height.ts +73 -6
  35. package/src/components/terminal/image-box.ts +53 -0
  36. package/src/components/terminal/items.tsx +116 -79
  37. package/src/components/terminal/result-preview.ts +20 -6
  38. package/src/components/terminal/scrubber.tsx +172 -26
  39. package/src/components/terminal/tool-run.ts +177 -0
  40. package/src/index.ts +20 -1
  41. package/src/lib/status.ts +16 -3
  42. package/src/styles/terminal.css +85 -5
  43. package/src/styles/theme.css +42 -0
  44. package/build/SessionPanel-DII9MmQ8.mjs.map +0 -1
  45. package/build/format-DqR56Y8l.mjs.map +0 -1
  46. package/build/status-Ydzi7n6j.mjs +0 -143
  47. package/build/status-Ydzi7n6j.mjs.map +0 -1
@@ -0,0 +1,260 @@
1
+ /**
2
+ * The terminal theme's block model — **which rows exist**.
3
+ *
4
+ * Pure and separate from `items.tsx` because which rows exist is part of what
5
+ * the theme *is*: the virtualizer counts these, `height.ts` sizes them, the
6
+ * scrubber addresses them, and both renderers — the virtualized shell in
7
+ * `agent/Transcript.tsx` and the plain `TerminalTranscript` — must fold
8
+ * identically or two clients would be showing different transcripts of the
9
+ * same session. `items.tsx` re-exports everything here, so its old imports
10
+ * keep working; the components stay there, the model lives here.
11
+ *
12
+ * Two folds happen in one pass:
13
+ *
14
+ * - **Runs.** Consecutive tool calls fold into one row (`tool-run.ts` owns the
15
+ * membership rule and the summary line).
16
+ * - **Tasks.** A `Task` tool call *absorbs* every item whose
17
+ * `parentToolUseId` names it — its subagent's brief, thinking, text and
18
+ * tool calls — into ONE row, **wherever those items fall in the stream**.
19
+ * Subagents run in parallel, so their items interleave with each other and
20
+ * with top-level work; a consecutive-run rule cannot group them, which is
21
+ * why absorption is by parent id and not by adjacency. The absorbed items
22
+ * are folded again *within* the block (a subagent's consecutive calls
23
+ * become runs — `foldsTogether` already keys on `parentToolUseId`), and the
24
+ * block is always collapsed by default: that preserves the height
25
+ * calculator's invariant that an unmounted row is collapsed by definition.
26
+ *
27
+ * The absorption rule, precisely: a task block forms for a **top-level** tool
28
+ * call (`parentToolUseId` empty) that has at least one child in the slice,
29
+ * and an item is absorbed iff its parent is such a call. Everything else
30
+ * renders as its own row, which settles the edges deliberately:
31
+ *
32
+ * - A **childless** `Task` call is a plain tool call and folds into runs —
33
+ * right for a task still spawning, and for a resumed session whose
34
+ * children were compacted away entirely.
35
+ * - An **orphan** child (its parent call absent from the slice) keeps today's
36
+ * behaviour: its own row, stepped in behind a rule. The recap boundary is
37
+ * the load-bearing case — the shell folds each side separately, so a task
38
+ * split by the boundary shows its post-boundary children *below* the seam
39
+ * rather than hiding new work inside a collapsed row above it, the same
40
+ * claim the run fold makes about never counting across "what you already
41
+ * read".
42
+ * - A **grandchild** (parent is itself a subagent's call — unreachable from
43
+ * today's engines, which do not nest sidechains) is not absorbed and not
44
+ * dropped: it renders top-level, stepped in. An unmapped item must be
45
+ * visible, never gone.
46
+ * - Two top-level calls separated only by absorbed items **fold together**:
47
+ * the interleaved step was another frame's work, and once it is absorbed
48
+ * the two calls are adjacent on screen — the count matches what the reader
49
+ * sees.
50
+ */
51
+ import type { TranscriptItem } from '@workerdeck/react'
52
+ import { foldsTogether } from './tool-run.ts'
53
+
54
+ export type ToolCallItem = Extract<TranscriptItem, { kind: 'tool_call' }>
55
+
56
+ /**
57
+ * The id of the tool call this item was produced inside, or `undefined` at the
58
+ * top level. One spelling for both shapes the reducer emits: `assistant_text`
59
+ * / `thinking` / `tool_call` carry `parentToolUseId: string | null` on every
60
+ * instance, while `user` carries it **optionally** (a human prompt has no
61
+ * parent at all — the key exists only on a subagent's brief). Callers must go
62
+ * through this rather than reading the field, or the absent-key case silently
63
+ * types as a compile error on one kind and a miss on another.
64
+ */
65
+ export function parentOf(item: TranscriptItem): string | undefined {
66
+ const parent = 'parentToolUseId' in item ? item.parentToolUseId : undefined
67
+ return parent ?? undefined
68
+ }
69
+
70
+ /**
71
+ * **The frame membership rule**: the items a sub-agent produced, and nothing
72
+ * else — what the takeover renders instead of the whole conversation.
73
+ *
74
+ * One exported function rather than a `filter` at each call site, because this
75
+ * is a rule two renderers have to agree on: iOS mirrors the terminal model out
76
+ * of this module, and a phone that framed a sub-agent slightly differently would
77
+ * be a second answer to "what is this agent doing".
78
+ *
79
+ * It picks up the brief (a `user` item *with* a parent), the thinking, the
80
+ * streamed text (deltas are namespaced per sidechain, so `streaming:<parentId>`
81
+ * carries the parent like any other item), every tool call with its result, and
82
+ * the final report. It excludes the spawning `Task` call itself — that is the
83
+ * frame, not a row in it — and every other agent's work.
84
+ *
85
+ * The slice is safe to hand straight to {@link terminalBlocks} at offset 0:
86
+ * nothing in it is top-level, so nothing absorbs, and consecutive calls still
87
+ * fold into runs because {@link foldsTogether} keys on an *equal* parent rather
88
+ * than on absence of one. Row indices are internally consistent because the rows
89
+ * and the `items` they came from are the same array.
90
+ */
91
+ export function subagentItems(
92
+ items: readonly TranscriptItem[],
93
+ parentToolUseId: string,
94
+ ): TranscriptItem[] {
95
+ return items.filter((item) => parentOf(item) === parentToolUseId)
96
+ }
97
+
98
+ /** Is this a row the transcript folds into a run? Any tool call is — see
99
+ * `tool-run.ts` for why this is no longer shell-only. */
100
+ export function isRunCall(item: TranscriptItem): item is ToolCallItem {
101
+ return item.kind === 'tool_call'
102
+ }
103
+
104
+ /** One transcript item as its own row. */
105
+ export type ItemBlock = { key: string; item: TranscriptItem; index: number }
106
+ /** A folded run of consecutive tool calls — one row for `run.length` items.
107
+ * At the top level its coverage is contiguous (`[index, index + run.length)`);
108
+ * inside a task block the members' global indices may be scattered (the run is
109
+ * consecutive in the *subagent's* stream, not the transcript's). */
110
+ export type RunBlock = {
111
+ key: string
112
+ run: ToolCallItem[]
113
+ /** Every member's global transcript index, in stream order — `childIndices`'
114
+ * sibling, and needed for the same reason: a run folded across an absorbed
115
+ * gap has no `[index, index + len)` coverage, so a member's ordinal within
116
+ * the run (what the scrubber anchors a failure by) is unrecoverable from
117
+ * `index` arithmetic. */
118
+ indices: number[]
119
+ index: number
120
+ }
121
+ /** What a task block's children fold into. Never a task block itself — the
122
+ * engines do not nest sidechains, and a hypothetical grandchild renders
123
+ * top-level rather than vanishing (see the module comment). */
124
+ export type LeafBlock = ItemBlock | RunBlock
125
+
126
+ /**
127
+ * A `Task` call and everything produced inside it, as ONE row — collapsed by
128
+ * default, pressable to expand, the same shape as the folded tool run.
129
+ *
130
+ * - `task` is the call itself; `index` its own transcript index, which is the
131
+ * row's address (rows stay ordered by `index`).
132
+ * - `children` are the absorbed items in stream order, folded exactly as
133
+ * top-level rows are; each leaf's `index` is its first member's *global*
134
+ * transcript index.
135
+ * - `childIndices` is the flat list of every absorbed item's global index, in
136
+ * stream order. It exists because absorption is the one exception to row
137
+ * contiguity: a child run's members can straddle other rows' starts, so no
138
+ * `[start, start + len)` arithmetic can say what this row covers —
139
+ * `rowIndexForItem` answers from this list instead.
140
+ */
141
+ export type TaskBlock = {
142
+ key: string
143
+ task: ToolCallItem
144
+ children: LeafBlock[]
145
+ childIndices: number[]
146
+ index: number
147
+ }
148
+
149
+ /**
150
+ * Fold consecutive tool calls into runs and absorb subagent items into task
151
+ * blocks, leaving everything else alone.
152
+ */
153
+ export type TerminalBlock = ItemBlock | RunBlock | TaskBlock
154
+
155
+ /** The absorbed items, flat and in stream order — what `taskSummary` counts
156
+ * and the collapsed row's one line is built from. */
157
+ export function taskChildItems(block: TaskBlock): TranscriptItem[] {
158
+ return block.children.flatMap((child) => ('run' in child ? child.run : [child.item]))
159
+ }
160
+
161
+ /** Append one item to a leaf-block list, folding it into the previous run when
162
+ * the membership rule allows — the one fold implementation, used for the
163
+ * top-level stream and for each task's children alike. */
164
+ function pushLeaf(out: LeafBlock[], item: TranscriptItem, index: number): void {
165
+ const previous = out.at(-1)
166
+ if (isRunCall(item)) {
167
+ if (previous && 'run' in previous && foldsTogether(previous.run[0]!, item)) {
168
+ previous.run.push(item)
169
+ previous.indices.push(index)
170
+ } else {
171
+ // Keyed by the run's *first* call, so the key is stable as the run grows.
172
+ out.push({ key: `run:${item.id}`, run: [item], indices: [index], index })
173
+ }
174
+ return
175
+ }
176
+ out.push({ key: `${item.kind}:${item.id}`, item, index })
177
+ }
178
+
179
+ /**
180
+ * @param offset What `items[0]`'s index is in the whole transcript — the
181
+ * virtualized shell folds each side of the recap boundary separately, and the
182
+ * rows still have to say where they sit for the catch-up dimming.
183
+ * @param fold Whether to group at all. `false` gives one block per item —
184
+ * no runs *and no task absorption* — which is what the cards variant
185
+ * renders: this is the terminal theme's rule and must not silently reshape
186
+ * another renderer's row list.
187
+ */
188
+ export function terminalBlocks(
189
+ items: readonly TranscriptItem[],
190
+ offset = 0,
191
+ fold = true,
192
+ ): TerminalBlock[] {
193
+ if (!fold) {
194
+ return items.map((item, position) => ({
195
+ key: `${item.kind}:${item.id}`,
196
+ item,
197
+ index: offset + position,
198
+ }))
199
+ }
200
+
201
+ // Which top-level tool calls have children in this slice, and what those
202
+ // children are. Collected over the whole slice before any block is built:
203
+ // membership is by parent id, not adjacency, so a call cannot know it is a
204
+ // task until every item has been seen.
205
+ const topLevelCalls = new Set<string>()
206
+ for (const item of items) {
207
+ if (item.kind === 'tool_call' && parentOf(item) === undefined) topLevelCalls.add(item.id)
208
+ }
209
+ const childrenOf = new Map<string, { item: TranscriptItem; index: number }[]>()
210
+ items.forEach((item, position) => {
211
+ const parent = parentOf(item)
212
+ if (parent !== undefined && topLevelCalls.has(parent)) {
213
+ const list = childrenOf.get(parent)
214
+ if (list) list.push({ item, index: offset + position })
215
+ else childrenOf.set(parent, [{ item, index: offset + position }])
216
+ }
217
+ })
218
+
219
+ const out: TerminalBlock[] = []
220
+ for (const [position, item] of items.entries()) {
221
+ const index = offset + position
222
+ const parent = parentOf(item)
223
+ // Absorbed into its task's row — it must not also appear as its own.
224
+ if (parent !== undefined && childrenOf.has(parent)) continue
225
+ if (item.kind === 'tool_call') {
226
+ const children = childrenOf.get(item.id)
227
+ if (children) {
228
+ const folded: LeafBlock[] = []
229
+ for (const child of children) pushLeaf(folded, child.item, child.index)
230
+ out.push({
231
+ key: `task:${item.id}`,
232
+ task: item,
233
+ children: folded,
234
+ childIndices: children.map((child) => child.index),
235
+ index,
236
+ })
237
+ continue
238
+ }
239
+ }
240
+ pushLeaf(out as LeafBlock[], item, index)
241
+ }
242
+ return out
243
+ }
244
+
245
+ /** Spacing between two items: a blank line, unless the pair belongs together.
246
+ * Tool output already sits under its call, and a run of tool calls reads as one
247
+ * block — the CLI leaves no blank line inside either. */
248
+ export function needsBlank(previous: TranscriptItem, next: TranscriptItem): boolean {
249
+ if (previous.kind === 'tool_call' && next.kind === 'tool_call') return false
250
+ return true
251
+ }
252
+
253
+ /** The same rule over blocks: a run counts as the tool calls it folded, and a
254
+ * task block counts as the `Task` call it stands for — a collapsed task row
255
+ * sits flush with the tool rows of the same turn, exactly as the call itself
256
+ * did before it grew children. */
257
+ export function blockNeedsBlank(previous: TerminalBlock, next: TerminalBlock): boolean {
258
+ const kind = (block: TerminalBlock) => ('item' in block ? block.item.kind : 'tool_call')
259
+ return !(kind(previous) === 'tool_call' && kind(next) === 'tool_call')
260
+ }
@@ -6,9 +6,10 @@ import {
6
6
  formatDuration,
7
7
  toolInputPreview,
8
8
  } from '../../lib/format.ts'
9
- import type { TerminalBlock, ToolCallItem } from './items.tsx'
9
+ import { taskChildItems, type TerminalBlock, type ToolCallItem } from './blocks.ts'
10
+ import { IMAGE_BOX_LINES } from './image-box.ts'
10
11
  import { collapsedResult } from './result-preview.ts'
11
- import { runSummary } from './tool-run.ts'
12
+ import { runSummary, taskSummary } from './tool-run.ts'
12
13
 
13
14
  /**
14
15
  * The terminal theme's row-height calculator.
@@ -37,6 +38,26 @@ import { runSummary } from './tool-run.ts'
37
38
  * row is always collapsed — and an expanded row is by definition mounted,
38
39
  * which means the virtualizer has its real measurement. There is no
39
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.
40
61
  * - **Mutation is object replacement.** The transcript reducer never mutates an
41
62
  * item in place — streaming text, a result arriving, a patch attaching each
42
63
  * produce a new object — which is what lets {@link HeightEpoch}'s cache key on
@@ -97,7 +118,13 @@ export function createHeightEpoch(width: number, ch: number, line: number): Heig
97
118
  * The inter-row gap is the *pair's* business (`gapBefore`), not the row's, so
98
119
  * it is added by the caller. */
99
120
  export function estimateBlockPx(block: TerminalBlock, epoch: HeightEpoch): number {
100
- if ('run' in block) return blockHeight(block, epoch).px
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
101
128
  const hit = epoch.cache.get(block.item)
102
129
  if (hit) return hit.px
103
130
  const computed = itemHeight(block.item, epoch)
@@ -105,6 +132,30 @@ export function estimateBlockPx(block: TerminalBlock, epoch: HeightEpoch): numbe
105
132
  return computed.px
106
133
  }
107
134
 
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
+ export const BRIEF_LINES = 4
144
+
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
+ export function briefPx(text: string, m: CellMetrics): number {
155
+ const cols = Math.max(1, Math.floor(m.width / m.ch + EPS))
156
+ return (Math.min(textLines(text, cols).lines, BRIEF_LINES) + 1) * m.line
157
+ }
158
+
108
159
  /** Measure the advance of `0` on the live surface. A DOM read — call it from
109
160
  * the epoch's measurement pass (an effect / ResizeObserver callback), never
110
161
  * from render. The probe is absolutely positioned, so it contributes no layout
@@ -653,6 +704,15 @@ function toolRowHeight(item: ToolCallItem, m: CellMetrics, extraPx: number): Acc
653
704
  const backend = item.backend && item.backend !== 'server' ? ` · ${item.backend}` : ''
654
705
  let acc = rowH(`${item.name}(${preview})${backend}`, m, { gutterCells: 2, extraPx })
655
706
 
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
+ const images = item.result?.images
713
+ if (images?.length)
714
+ acc = add(acc, { px: images.length * IMAGE_BOX_LINES * m.line, exact: true })
715
+
656
716
  if (item.patch) return add(acc, diffHeight(item.patch, m, extraPx))
657
717
  const text = item.result?.text ?? ''
658
718
  if (!text) return acc
@@ -660,7 +720,7 @@ function toolRowHeight(item: ToolCallItem, m: CellMetrics, extraPx: number): Acc
660
720
  // the lines and the exact trailing label, so this cannot drift from what
661
721
  // `items.tsx` draws — which it previously could, and which its own comment
662
722
  // said only the dev audit was catching.
663
- const { shown, more } = collapsedResult(text.trimEnd().split('\n'))
723
+ const { shown, more } = collapsedResult(text.trimEnd().split('\n'), item.result?.totalChars)
664
724
  for (const line of shown) {
665
725
  // indent=1 with columns=3 resolves the indent against the row's own
666
726
  // --term-cell: 3ch of padding + 3ch of gutter.
@@ -716,9 +776,16 @@ export function itemHeight(item: TranscriptItem, m: CellMetrics): ComputedHeight
716
776
  }
717
777
  }
718
778
 
719
- /** A virtual row's height: an item, or a folded tool run (collapsed = its one
720
- * summary line, built by the same function the row draws). */
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. */
721
785
  export function blockHeight(block: TerminalBlock, m: CellMetrics): ComputedHeight {
786
+ if ('task' in block) {
787
+ return rowH(taskSummary(block.task, taskChildItems(block)), m)
788
+ }
722
789
  if ('run' in block) {
723
790
  const busy = block.run.some((item) => item.status === 'running' || item.status === 'pending')
724
791
  return rowH(runSummary(block.run, busy), m)
@@ -0,0 +1,53 @@
1
+ import { formatBytes } from '../../lib/format.ts'
2
+
3
+ /**
4
+ * The box a tool result's image is drawn in, and the words drawn in it before
5
+ * the bytes arrive.
6
+ *
7
+ * Its own module, and pure, for `result-preview.ts`'s reason with a constant
8
+ * standing where a string stood: `items.tsx` draws these boxes and `height.ts`
9
+ * predicts their pixel height for the virtualizer's `estimateSize` without a
10
+ * DOM. Two spellings of the box would be two different heights, and the row
11
+ * would grow or shrink the moment it mounted.
12
+ *
13
+ * **A fixed box, sized in whole lines, reserved from plan time.** An image's
14
+ * intrinsic dimensions are not knowable before its bytes are, and the
15
+ * alternative — an `exact: false` row corrected on mount — would demote *most*
16
+ * rows of an image-bearing session to estimates and bring back the growing
17
+ * scrollbar the calculator exists to kill. A box that does not depend on what is
18
+ * inside it is exact by definition, at the cost of some letterboxing.
19
+ */
20
+
21
+ /**
22
+ * Whole lines per image. 12 ≈ 240px at an 18px line — big enough that a
23
+ * screenshot is legible as *what it is* (which is the whole reason images became
24
+ * visible at all), small enough that a call returning four of them does not
25
+ * become a screenful.
26
+ */
27
+ export const IMAGE_BOX_LINES = 12
28
+
29
+ /**
30
+ * What the box says before the fetch lands.
31
+ *
32
+ * `bytes` is the decoded size the gateway stamped on the reference — the client
33
+ * holds no bytes at all until it asks for them, so this is a number it cannot
34
+ * compute, the same reason `total_chars` rides beside a truncated head.
35
+ * `formatBytes` rather than a spelling of its own: the panel says "336.0 KB"
36
+ * everywhere else, and a second byte formatter is a second thing to keep in
37
+ * step.
38
+ */
39
+ export function imagePlaceholder(image: { bytes: number }): string {
40
+ return `image · ${formatBytes(image.bytes)}`
41
+ }
42
+
43
+ /**
44
+ * What it says when the fetch failed — a stale address after a dormant wake
45
+ * (the route 404s rather than serving another call's pixels), a gateway too old
46
+ * to know the route, a dropped connection.
47
+ *
48
+ * It occupies the same box, because the alternative is the row changing height
49
+ * on a network failure. Silence is not an option here the way it is for
50
+ * `HostImage`: that card names the host path in its result text, so a reader can
51
+ * still find the picture; a replayed image part has no path to name.
52
+ */
53
+ export const IMAGE_UNAVAILABLE = 'image unavailable'