@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,118 @@
1
+ import { createContext, useContext, useState, type ReactNode } from 'react'
2
+ import { cn } from '../../lib/utils.ts'
3
+
4
+ /**
5
+ * The things a terminal cannot do, and this can.
6
+ *
7
+ * The theme's whole discipline is that it renders like a CLI — but it is not a
8
+ * CLI, and refusing every affordance a pointer makes possible would be cosplay
9
+ * rather than design. A terminal cannot highlight the row under your cursor and
10
+ * cannot put a copy button on a block of output; a web view can do both for free
11
+ * and they are genuinely useful.
12
+ *
13
+ * The rule that keeps this honest is that **each one costs no layout**. A hover
14
+ * fill is a background. An action button is an overlay at the row's right edge,
15
+ * one line tall, absolutely positioned so it displaces nothing. Turn them all
16
+ * off and every glyph is on exactly the same cell it was on — which is what
17
+ * makes `off` a real option rather than a degraded mode, and why it is the mode
18
+ * a host projecting to a real terminal would choose.
19
+ *
20
+ * Off by nothing in particular: both default **on**, because the surface these
21
+ * render on is a browser and pretending otherwise helps nobody. A host that
22
+ * wants the pure article passes `affordances={false}`.
23
+ */
24
+ export type TerminalAffordances = {
25
+ /** Fill the row under the pointer, on anything pressable. */
26
+ hover?: boolean
27
+ /** Reveal a row's actions (copy, and whatever a row adds) on hover or focus. */
28
+ actions?: boolean
29
+ }
30
+
31
+ const DEFAULTS: Required<TerminalAffordances> = { hover: true, actions: true }
32
+
33
+ const AffordanceContext = createContext<Required<TerminalAffordances>>(DEFAULTS)
34
+
35
+ export function useAffordances(): Required<TerminalAffordances> {
36
+ return useContext(AffordanceContext)
37
+ }
38
+
39
+ /** Resolve the surface's prop — `false` means none, `true`/absent means all. */
40
+ export function resolveAffordances(
41
+ value: TerminalAffordances | boolean | undefined,
42
+ ): Required<TerminalAffordances> {
43
+ if (value === false) return { hover: false, actions: false }
44
+ if (value === true || value === undefined) return DEFAULTS
45
+ return { ...DEFAULTS, ...value }
46
+ }
47
+
48
+ export function AffordanceProvider({
49
+ value,
50
+ children,
51
+ }: {
52
+ value: Required<TerminalAffordances>
53
+ children: ReactNode
54
+ }) {
55
+ return <AffordanceContext.Provider value={value}>{children}</AffordanceContext.Provider>
56
+ }
57
+
58
+ /**
59
+ * A block that reveals its actions on hover.
60
+ *
61
+ * Wraps rather than decorates because the actions belong to the *block* — a
62
+ * message is many rows and its copy button belongs at the top right of all of
63
+ * them, not on whichever row the pointer happens to be over.
64
+ */
65
+ export function WithActions({
66
+ actions,
67
+ children,
68
+ className,
69
+ }: {
70
+ actions: ReactNode
71
+ children: ReactNode
72
+ className?: string
73
+ }) {
74
+ const { actions: enabled } = useAffordances()
75
+ if (!enabled) return <>{children}</>
76
+ return (
77
+ <div className={cn('term-hoverable', className)}>
78
+ {children}
79
+ <div className='term-actions'>{actions}</div>
80
+ </div>
81
+ )
82
+ }
83
+
84
+ /**
85
+ * Copy, as a character.
86
+ *
87
+ * `⧉` and `✓` rather than an SVG for the same reason the rest of the theme uses
88
+ * glyphs: an icon drawn at some other size sits between the cells everything
89
+ * else is on, and reads as a button borrowed from another application. The tick
90
+ * replaces the glyph in place, so the confirmation costs no width either.
91
+ */
92
+ export function CopyAction({ text, label = 'Copy' }: { text: string; label?: string }) {
93
+ const [copied, setCopied] = useState(false)
94
+ return (
95
+ <button
96
+ type='button'
97
+ className='term-action'
98
+ title={label}
99
+ aria-label={label}
100
+ onClick={(event) => {
101
+ // The row underneath is usually pressable (a tool call expands); copying
102
+ // is not expanding.
103
+ event.stopPropagation()
104
+ void navigator.clipboard
105
+ ?.writeText(text)
106
+ .then(() => {
107
+ setCopied(true)
108
+ setTimeout(() => setCopied(false), 1200)
109
+ })
110
+ // No toast, no error row: a refused clipboard (an insecure origin, a
111
+ // denied permission) is the host's business, and a failed copy that
112
+ // says nothing is better than a transcript that grows an error.
113
+ .catch(() => {})
114
+ }}>
115
+ {copied ? '✓' : '⧉'}
116
+ </button>
117
+ )
118
+ }
@@ -0,0 +1,232 @@
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
+ /** Is this a row the transcript folds into a run? Any tool call is — see
71
+ * `tool-run.ts` for why this is no longer shell-only. */
72
+ export function isRunCall(item: TranscriptItem): item is ToolCallItem {
73
+ return item.kind === 'tool_call'
74
+ }
75
+
76
+ /** One transcript item as its own row. */
77
+ export type ItemBlock = { key: string; item: TranscriptItem; index: number }
78
+ /** A folded run of consecutive tool calls — one row for `run.length` items.
79
+ * At the top level its coverage is contiguous (`[index, index + run.length)`);
80
+ * inside a task block the members' global indices may be scattered (the run is
81
+ * consecutive in the *subagent's* stream, not the transcript's). */
82
+ export type RunBlock = {
83
+ key: string
84
+ run: ToolCallItem[]
85
+ /** Every member's global transcript index, in stream order — `childIndices`'
86
+ * sibling, and needed for the same reason: a run folded across an absorbed
87
+ * gap has no `[index, index + len)` coverage, so a member's ordinal within
88
+ * the run (what the scrubber anchors a failure by) is unrecoverable from
89
+ * `index` arithmetic. */
90
+ indices: number[]
91
+ index: number
92
+ }
93
+ /** What a task block's children fold into. Never a task block itself — the
94
+ * engines do not nest sidechains, and a hypothetical grandchild renders
95
+ * top-level rather than vanishing (see the module comment). */
96
+ export type LeafBlock = ItemBlock | RunBlock
97
+
98
+ /**
99
+ * A `Task` call and everything produced inside it, as ONE row — collapsed by
100
+ * default, pressable to expand, the same shape as the folded tool run.
101
+ *
102
+ * - `task` is the call itself; `index` its own transcript index, which is the
103
+ * row's address (rows stay ordered by `index`).
104
+ * - `children` are the absorbed items in stream order, folded exactly as
105
+ * top-level rows are; each leaf's `index` is its first member's *global*
106
+ * transcript index.
107
+ * - `childIndices` is the flat list of every absorbed item's global index, in
108
+ * stream order. It exists because absorption is the one exception to row
109
+ * contiguity: a child run's members can straddle other rows' starts, so no
110
+ * `[start, start + len)` arithmetic can say what this row covers —
111
+ * `rowIndexForItem` answers from this list instead.
112
+ */
113
+ export type TaskBlock = {
114
+ key: string
115
+ task: ToolCallItem
116
+ children: LeafBlock[]
117
+ childIndices: number[]
118
+ index: number
119
+ }
120
+
121
+ /**
122
+ * Fold consecutive tool calls into runs and absorb subagent items into task
123
+ * blocks, leaving everything else alone.
124
+ */
125
+ export type TerminalBlock = ItemBlock | RunBlock | TaskBlock
126
+
127
+ /** The absorbed items, flat and in stream order — what `taskSummary` counts
128
+ * and the collapsed row's one line is built from. */
129
+ export function taskChildItems(block: TaskBlock): TranscriptItem[] {
130
+ return block.children.flatMap((child) => ('run' in child ? child.run : [child.item]))
131
+ }
132
+
133
+ /** Append one item to a leaf-block list, folding it into the previous run when
134
+ * the membership rule allows — the one fold implementation, used for the
135
+ * top-level stream and for each task's children alike. */
136
+ function pushLeaf(out: LeafBlock[], item: TranscriptItem, index: number): void {
137
+ const previous = out.at(-1)
138
+ if (isRunCall(item)) {
139
+ if (previous && 'run' in previous && foldsTogether(previous.run[0]!, item)) {
140
+ previous.run.push(item)
141
+ previous.indices.push(index)
142
+ } else {
143
+ // Keyed by the run's *first* call, so the key is stable as the run grows.
144
+ out.push({ key: `run:${item.id}`, run: [item], indices: [index], index })
145
+ }
146
+ return
147
+ }
148
+ out.push({ key: `${item.kind}:${item.id}`, item, index })
149
+ }
150
+
151
+ /**
152
+ * @param offset What `items[0]`'s index is in the whole transcript — the
153
+ * virtualized shell folds each side of the recap boundary separately, and the
154
+ * rows still have to say where they sit for the catch-up dimming.
155
+ * @param fold Whether to group at all. `false` gives one block per item —
156
+ * no runs *and no task absorption* — which is what the cards variant
157
+ * renders: this is the terminal theme's rule and must not silently reshape
158
+ * another renderer's row list.
159
+ */
160
+ export function terminalBlocks(
161
+ items: readonly TranscriptItem[],
162
+ offset = 0,
163
+ fold = true,
164
+ ): TerminalBlock[] {
165
+ if (!fold) {
166
+ return items.map((item, position) => ({
167
+ key: `${item.kind}:${item.id}`,
168
+ item,
169
+ index: offset + position,
170
+ }))
171
+ }
172
+
173
+ // Which top-level tool calls have children in this slice, and what those
174
+ // children are. Collected over the whole slice before any block is built:
175
+ // membership is by parent id, not adjacency, so a call cannot know it is a
176
+ // task until every item has been seen.
177
+ const topLevelCalls = new Set<string>()
178
+ for (const item of items) {
179
+ if (item.kind === 'tool_call' && parentOf(item) === undefined) topLevelCalls.add(item.id)
180
+ }
181
+ const childrenOf = new Map<string, { item: TranscriptItem; index: number }[]>()
182
+ items.forEach((item, position) => {
183
+ const parent = parentOf(item)
184
+ if (parent !== undefined && topLevelCalls.has(parent)) {
185
+ const list = childrenOf.get(parent)
186
+ if (list) list.push({ item, index: offset + position })
187
+ else childrenOf.set(parent, [{ item, index: offset + position }])
188
+ }
189
+ })
190
+
191
+ const out: TerminalBlock[] = []
192
+ for (const [position, item] of items.entries()) {
193
+ const index = offset + position
194
+ const parent = parentOf(item)
195
+ // Absorbed into its task's row — it must not also appear as its own.
196
+ if (parent !== undefined && childrenOf.has(parent)) continue
197
+ if (item.kind === 'tool_call') {
198
+ const children = childrenOf.get(item.id)
199
+ if (children) {
200
+ const folded: LeafBlock[] = []
201
+ for (const child of children) pushLeaf(folded, child.item, child.index)
202
+ out.push({
203
+ key: `task:${item.id}`,
204
+ task: item,
205
+ children: folded,
206
+ childIndices: children.map((child) => child.index),
207
+ index,
208
+ })
209
+ continue
210
+ }
211
+ }
212
+ pushLeaf(out as LeafBlock[], item, index)
213
+ }
214
+ return out
215
+ }
216
+
217
+ /** Spacing between two items: a blank line, unless the pair belongs together.
218
+ * Tool output already sits under its call, and a run of tool calls reads as one
219
+ * block — the CLI leaves no blank line inside either. */
220
+ export function needsBlank(previous: TranscriptItem, next: TranscriptItem): boolean {
221
+ if (previous.kind === 'tool_call' && next.kind === 'tool_call') return false
222
+ return true
223
+ }
224
+
225
+ /** The same rule over blocks: a run counts as the tool calls it folded, and a
226
+ * task block counts as the `Task` call it stands for — a collapsed task row
227
+ * sits flush with the tool rows of the same turn, exactly as the call itself
228
+ * did before it grew children. */
229
+ export function blockNeedsBlank(previous: TerminalBlock, next: TerminalBlock): boolean {
230
+ const kind = (block: TerminalBlock) => ('item' in block ? block.item.kind : 'tool_call')
231
+ return !(kind(previous) === 'tool_call' && kind(next) === 'tool_call')
232
+ }
@@ -0,0 +1,130 @@
1
+ import type { FilePatch, PatchHunk } from '@workerdeck/protocol'
2
+ import { Row } from './row.tsx'
3
+
4
+ /**
5
+ * A file edit, drawn the way the CLI draws it: a right-aligned line-number
6
+ * column, a one-column `+`/`-` marker, and the line — with added and removed
7
+ * rows carrying a full-bleed wash.
8
+ *
9
+ * The line numbers are the engine's own, off the wire (protocol's
10
+ * {@link FilePatch}). Nothing here computes one: this component has never seen
11
+ * the file, and a number it invented would be worse than no number at all —
12
+ * it would look authoritative and send the reader to the wrong line.
13
+ *
14
+ * The whole row — number, marker and text — is one {@link Row}, with the number
15
+ * and marker as its gutter. That is what keeps a wrapped line hanging under the
16
+ * text rather than under the numbers, and it is why the gutter width is computed
17
+ * rather than fixed: a four-digit file and a two-digit one both put their first
18
+ * character of code on a whole column.
19
+ */
20
+
21
+ /** Which side of the edit a body line belongs to, from its unified-diff prefix. */
22
+ type LineKind = 'context' | 'add' | 'remove'
23
+
24
+ const kindOf = (line: string): LineKind =>
25
+ line.startsWith('+') ? 'add' : line.startsWith('-') ? 'remove' : 'context'
26
+
27
+ /**
28
+ * The rows of one hunk, each with the line number it has *in the file*.
29
+ *
30
+ * Two counters, because a diff is two files interleaved: an added line has a
31
+ * number only in the new file, a removed line only in the old one, and context
32
+ * advances both. Showing the new number for adds and the old number for removes
33
+ * is what makes the column mean "where do I find this line", which is the only
34
+ * reason to print it.
35
+ */
36
+ function hunkRows(hunk: PatchHunk): { kind: LineKind; number: number; text: string }[] {
37
+ let oldLine = hunk.oldStart
38
+ let newLine = hunk.newStart
39
+ const rows = []
40
+ for (const line of hunk.lines) {
41
+ const kind = kindOf(line)
42
+ // The prefix is diff syntax, not content — the marker column says it now.
43
+ const text = line.slice(1)
44
+ if (kind === 'add') rows.push({ kind, number: newLine++, text })
45
+ else if (kind === 'remove') rows.push({ kind, number: oldLine++, text })
46
+ else {
47
+ rows.push({ kind, number: newLine, text })
48
+ oldLine++
49
+ newLine++
50
+ }
51
+ }
52
+ return rows
53
+ }
54
+
55
+ const MARKER = { context: ' ', add: '+', remove: '-' } as const
56
+
57
+ /**
58
+ * A patch for a change that has **not happened yet** — an approval prompt's
59
+ * `Edit`, where the input names the old and new text but no line numbers exist
60
+ * because the edit has not been applied and this client has never read the file.
61
+ *
62
+ * `newStart: 0` is how it says so, and {@link TerminalDiff} reads that back: a
63
+ * diff whose hunks all start at zero renders without a number column rather than
64
+ * printing a column of zeroes or inventing an offset.
65
+ */
66
+ export function previewPatch(input: unknown): FilePatch | undefined {
67
+ const edit = input as { file_path?: unknown; old_string?: unknown; new_string?: unknown } | null
68
+ const before = typeof edit?.old_string === 'string' ? edit.old_string : undefined
69
+ const after = typeof edit?.new_string === 'string' ? edit.new_string : undefined
70
+ if (before === undefined && after === undefined) return undefined
71
+ const lines = [
72
+ ...(before ? before.split('\n').map((line) => `-${line}`) : []),
73
+ ...(after ? after.split('\n').map((line) => `+${line}`) : []),
74
+ ]
75
+ if (lines.length === 0) return undefined
76
+ return {
77
+ ...(typeof edit?.file_path === 'string' && { path: edit.file_path }),
78
+ hunks: [{ oldStart: 0, oldLines: 0, newStart: 0, newLines: 0, lines }],
79
+ }
80
+ }
81
+
82
+ export function TerminalDiff({ patch }: { patch: FilePatch }) {
83
+ const rows = patch.hunks.map(hunkRows)
84
+ // Numbers only when the engine gave any (see `previewPatch`): a column of
85
+ // zeroes would look like line 0 of the file, which is a lie about where to
86
+ // look — and a diff with no numbers is honest about having none.
87
+ const numbered = patch.hunks.some((hunk) => hunk.newStart > 0)
88
+ // Wide enough for the largest number any row will print, so the marker and the
89
+ // code start on the same column throughout — including across a hunk boundary,
90
+ // where the numbers jump.
91
+ const width = numbered ? String(Math.max(...rows.flat().map((row) => row.number), 1)).length : 0
92
+ // number + space + marker + space (or just the marker and a space)
93
+ const columns = numbered ? width + 3 : 2
94
+
95
+ return (
96
+ <div className='term-diff'>
97
+ {rows.map((hunk, index) => (
98
+ <div key={index}>
99
+ {/* Hunks are not adjacent in the file, and a blank line would say they
100
+ were merely a paragraph apart. The CLI's separator is the honest
101
+ one: a row that says lines were skipped. */}
102
+ {index > 0 ? (
103
+ <Row columns={columns} glyph={' '.repeat(width) + ' ⋮'} tone='faint' />
104
+ ) : null}
105
+ {hunk.map((row, line) => (
106
+ <Row
107
+ key={line}
108
+ columns={columns}
109
+ data-diff={row.kind}
110
+ glyph={
111
+ numbered
112
+ ? `${String(row.number).padStart(width)} ${MARKER[row.kind]}`
113
+ : MARKER[row.kind]
114
+ }
115
+ // `pre-wrap` on the body already keeps the code's own indentation;
116
+ // an empty line still has to occupy its row, hence the space.
117
+ >
118
+ {row.text || ' '}
119
+ </Row>
120
+ ))}
121
+ </div>
122
+ ))}
123
+ {patch.truncated ? (
124
+ <Row columns={columns} tone='faint'>
125
+ … diff truncated
126
+ </Row>
127
+ ) : null}
128
+ </div>
129
+ )
130
+ }