@workerdeck/ui 0.13.0 → 0.16.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 (69) hide show
  1. package/README.md +72 -0
  2. package/build/{SessionPanel-CZMA44NM.d.mts → SessionPanel-B9CHoq8x.d.mts} +213 -27
  3. package/build/{SessionPanel-CKQa4i0Y.mjs → SessionPanel-DII9MmQ8.mjs} +5192 -2542
  4. package/build/SessionPanel-DII9MmQ8.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 +494 -45
  9. package/build/index.mjs +182 -24
  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 +13 -1
  14. package/build/workspace.mjs +111 -5
  15. package/build/workspace.mjs.map +1 -1
  16. package/package.json +14 -7
  17. package/src/components/agent/Composer.tsx +251 -84
  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 -43
  23. package/src/components/agent/PermissionPrompt.tsx +0 -92
  24. package/src/components/agent/QuestionPrompt.tsx +0 -122
  25. package/src/components/agent/Reasoning.tsx +5 -19
  26. package/src/components/agent/Response.tsx +1 -132
  27. package/src/components/agent/SessionBrowser.tsx +35 -25
  28. package/src/components/agent/SessionPanel.tsx +312 -63
  29. package/src/components/agent/SessionWorkspace.tsx +36 -0
  30. package/src/components/agent/StatusBar.tsx +70 -15
  31. package/src/components/agent/ToolCallCard.tsx +17 -111
  32. package/src/components/agent/Transcript.tsx +710 -203
  33. package/src/components/agent/UsageDialog.tsx +20 -106
  34. package/src/components/agent/UsageMeters.tsx +133 -0
  35. package/src/components/agent/pulse.tsx +3 -2
  36. package/src/components/agent/transcript-rows.ts +82 -0
  37. package/src/components/agent/transcript-variant.tsx +43 -51
  38. package/src/components/agent/use-height-epoch.ts +60 -0
  39. package/src/components/agent/use-path-links.ts +147 -0
  40. package/src/components/agent/use-transcript-jumps.ts +190 -0
  41. package/src/components/prompt-area/cursor-helpers.ts +65 -0
  42. package/src/components/prompt-area/use-prompt-area.ts +16 -10
  43. package/src/components/terminal/PermissionPrompt.tsx +119 -0
  44. package/src/components/terminal/QuestionPrompt.tsx +322 -0
  45. package/src/components/terminal/StatusLine.tsx +159 -0
  46. package/src/components/terminal/TerminalTranscript.tsx +147 -0
  47. package/src/components/terminal/affordances.tsx +118 -0
  48. package/src/components/terminal/diff.tsx +130 -0
  49. package/src/components/terminal/height.ts +727 -0
  50. package/src/components/terminal/items.tsx +449 -0
  51. package/src/components/terminal/markdown.tsx +191 -0
  52. package/src/components/terminal/press.tsx +120 -0
  53. package/src/components/terminal/prompt.tsx +343 -0
  54. package/src/components/terminal/result-preview.ts +72 -0
  55. package/src/components/terminal/row.tsx +132 -0
  56. package/src/components/terminal/scrubber.tsx +663 -0
  57. package/src/components/terminal/surface.tsx +80 -0
  58. package/src/components/terminal/tool-run.ts +91 -0
  59. package/src/components/ui/Badge.tsx +6 -1
  60. package/src/components/ui/Empty.tsx +56 -0
  61. package/src/components/ui/Splitter.tsx +14 -0
  62. package/src/index.ts +36 -0
  63. package/src/lib/status.ts +59 -3
  64. package/src/lib/tool-icon.ts +14 -0
  65. package/src/styles/terminal.css +1011 -0
  66. package/src/styles/theme.css +73 -0
  67. package/build/SessionPanel-CKQa4i0Y.mjs.map +0 -1
  68. package/build/format.mjs.map +0 -1
  69. package/src/components/agent/line-prompt.tsx +0 -249
@@ -0,0 +1,120 @@
1
+ import { useEffect, useRef, type ReactNode } from 'react'
2
+ import { cn } from '../../lib/utils.ts'
3
+
4
+ /**
5
+ * A row you can open, that you can also select text out of.
6
+ *
7
+ * A `<button>` cannot be both: text inside one is selectable in principle, but
8
+ * the drag that selects it ends in a `click`, so releasing the mouse collapses
9
+ * the very block you were highlighting — and the selection is discarded with it.
10
+ * A transcript is *read* far more often than it is opened, so copying a command
11
+ * out of a row has to win over the affordance that expands it.
12
+ *
13
+ * So: a `div` with the button role and the keyboard behaviour restored by hand,
14
+ * and a press that is refused when the pointer travelled (a drag, not a click)
15
+ * or when a selection is standing. Both checks are cheap and neither is a
16
+ * heuristic about intent — a pointer that moved four pixels was dragging, and a
17
+ * non-collapsed selection *is* the user having selected something.
18
+ */
19
+ const DRAG_SLOP = 4
20
+
21
+ export function Pressable({
22
+ onPress,
23
+ expanded,
24
+ className,
25
+ children,
26
+ }: {
27
+ onPress: () => void
28
+ /** Mirrored to `aria-expanded` when this press opens something. */
29
+ expanded?: boolean
30
+ className?: string
31
+ children: ReactNode
32
+ }) {
33
+ const origin = useRef<{ x: number; y: number } | null>(null)
34
+ return (
35
+ <div
36
+ role='button'
37
+ tabIndex={0}
38
+ aria-expanded={expanded}
39
+ className={cn('term-press', className)}
40
+ onPointerDown={(event) => {
41
+ origin.current = { x: event.clientX, y: event.clientY }
42
+ }}
43
+ onClick={(event) => {
44
+ const from = origin.current
45
+ origin.current = null
46
+ if (from && Math.abs(event.clientX - from.x) + Math.abs(event.clientY - from.y) > DRAG_SLOP)
47
+ return
48
+ // A click that merely *ends* a selection elsewhere on the page still
49
+ // reads as a click; one that ends a selection inside this row is the
50
+ // tail of a drag the slop check may have missed (a slow, short drag).
51
+ const selection = window.getSelection?.()
52
+ if (selection && !selection.isCollapsed && selection.containsNode(event.currentTarget, true))
53
+ return
54
+ onPress()
55
+ }}
56
+ onKeyDown={(event) => {
57
+ if (event.key !== 'Enter' && event.key !== ' ') return
58
+ event.preventDefault()
59
+ onPress()
60
+ }}>
61
+ {children}
62
+ </div>
63
+ )
64
+ }
65
+
66
+ /**
67
+ * Keep an expanding block's *first* line reachable.
68
+ *
69
+ * A row that grows from one line to eighty pushes its own top off the screen:
70
+ * the reader presses a summary and lands somewhere in the middle of what they
71
+ * opened, with no clue that the beginning is above them. The fix is not a
72
+ * scroll-into-view on every expand — that would yank a block already fully in
73
+ * view — but the narrow one: if the block now starts above the fold, bring its
74
+ * first line back to the top edge.
75
+ *
76
+ * Deliberately one-directional and only on the open transition. Collapsing needs
77
+ * nothing (the block shrinks toward its own top, which is already on screen),
78
+ * and a block whose top is already visible must not move at all.
79
+ */
80
+ export function useRevealOnOpen(open: boolean) {
81
+ const ref = useRef<HTMLDivElement>(null)
82
+ const previous = useRef(open)
83
+ useEffect(() => {
84
+ const opened = open && !previous.current
85
+ previous.current = open
86
+ if (!opened) return
87
+ const element = ref.current
88
+ if (!element) return
89
+ // After paint: the rows this block just grew by have to be laid out, and
90
+ // the virtualizer's own size-change correction has to have run, before an
91
+ // offset read here means anything.
92
+ const frame = requestAnimationFrame(() => {
93
+ const scroller = scrollParent(element)
94
+ if (!scroller) return
95
+ const top =
96
+ element.getBoundingClientRect().top -
97
+ scroller.getBoundingClientRect().top +
98
+ scroller.scrollTop
99
+ if (top >= scroller.scrollTop) return
100
+ // One line of air above it, so the first row isn't flush against the
101
+ // scroller's edge — the same blank line every block gets.
102
+ const line = Number.parseFloat(getComputedStyle(element).lineHeight) || 0
103
+ scroller.scrollTop = Math.max(0, top - line)
104
+ })
105
+ return () => cancelAnimationFrame(frame)
106
+ }, [open])
107
+ return ref
108
+ }
109
+
110
+ /** The nearest ancestor that actually scrolls. */
111
+ function scrollParent(from: HTMLElement): HTMLElement | null {
112
+ let node = from.parentElement
113
+ while (node) {
114
+ const overflow = getComputedStyle(node).overflowY
115
+ if ((overflow === 'auto' || overflow === 'scroll') && node.scrollHeight > node.clientHeight)
116
+ return node
117
+ node = node.parentElement
118
+ }
119
+ return null
120
+ }
@@ -0,0 +1,343 @@
1
+ import { useEffect, useRef, type ReactNode } from 'react'
2
+ import { cn } from '../../lib/utils.ts'
3
+ import { Blank, Ink, Row } from './row.tsx'
4
+
5
+ /**
6
+ * The parts a terminal prompt is built from.
7
+ *
8
+ * An approval and a question are the two places the transcript stops being a log
9
+ * and becomes a form, and "no boxes" has to be paid for by something. In the CLI
10
+ * it is paid for three ways, and all three are here: a **rule** marks where the
11
+ * run stops and the decision starts, the options are **numbered** so a key press
12
+ * is an answer, and a **hint line** says which keys. That is what makes a prompt
13
+ * answerable without reaching for the mouse — which is the whole reason a
14
+ * terminal UI can be faster than a dialog.
15
+ *
16
+ * Everything stays on the grid: the rules are one line tall with the stroke
17
+ * drawn through the middle by a background (a border would cost layout), and the
18
+ * roving `❯` lives in the same gutter cell every other row uses.
19
+ */
20
+
21
+ /**
22
+ * The boundary above a prompt. Solid separates the run from the decision; dashed
23
+ * separates parts *within* it (the CLI puts one between a diff and its question),
24
+ * which is why there are two weights and not one.
25
+ */
26
+ export function Rule({ dashed }: { dashed?: boolean }) {
27
+ return <div className={cn('term-rule-row', dashed && 'term-rule-dashed')} aria-hidden />
28
+ }
29
+
30
+ /** The dim `·`-separated key legend under a prompt. */
31
+ export function Hint({ children }: { children: ReactNode }) {
32
+ return (
33
+ <Row tone='faint'>
34
+ {children}
35
+ </Row>
36
+ )
37
+ }
38
+
39
+ /**
40
+ * A framed payload — a preview, a snippet. The frame is drawn with four
41
+ * background gradients rather than a border, so it costs no layout: a 1px border
42
+ * would push its contents a pixel off the column every other row sits on.
43
+ */
44
+ export function Box({ children, className }: { children: ReactNode; className?: string }) {
45
+ return <div className={cn('term-box', className)}>{children}</div>
46
+ }
47
+
48
+ /**
49
+ * The prompt's heading: what is being asked, and about what.
50
+ *
51
+ * Two lines because the engine gives two — `displayName` ("Edit file") is the
52
+ * action, and the subject (the path) is the thing it acts on. The CLI shows them
53
+ * exactly this way, and it is the one place in the theme where colour is used
54
+ * for emphasis rather than for state.
55
+ */
56
+ export function PromptTitle({ title, subject }: { title: string; subject?: string }) {
57
+ return (
58
+ <>
59
+ <Row tone='blue' bold>
60
+ {title}
61
+ </Row>
62
+ {subject ? <Row tone='dim'>{subject}</Row> : null}
63
+ </>
64
+ )
65
+ }
66
+
67
+ export type Choice = {
68
+ key: string
69
+ label: string
70
+ /** Rendered dim on its own row under the label, as the CLI does for a
71
+ * multi-select's options — never appended to the label, which would make the
72
+ * row wrap and cost the list its scannability. */
73
+ description?: string
74
+ /**
75
+ * Present → the row carries selection state and draws it. `marker` says in
76
+ * which idiom: `[x]` for a multi-select, `(•)` for a one-of. Absent → the row
77
+ * is an action (Allow, Cancel), which has no state to show.
78
+ */
79
+ checked?: boolean
80
+ marker?: 'check' | 'radio'
81
+ /**
82
+ * Chosen, in a list that draws no markers (a one-of). The colour is the whole
83
+ * signal there: without it, tabbing back to an answered question would show no
84
+ * trace of the answer given.
85
+ */
86
+ selected?: boolean
87
+ danger?: boolean
88
+ /** Rendered under the row, outside the button — a preview, a text field. The
89
+ * caller decides when it exists (focused, checked); a button may not hold one. */
90
+ detail?: ReactNode
91
+ }
92
+
93
+ /** The two-state glyph pairs, in the forms a terminal would use. */
94
+ const MARKERS = {
95
+ check: ['[ ]', '[✓]'],
96
+ radio: ['( )', '(•)'],
97
+ } as const
98
+
99
+ export interface ChoicesProps {
100
+ options: Choice[]
101
+ /** Roving index: the one row that is tab-reachable and wears the `❯`. */
102
+ focused: number
103
+ onFocus: (index: number) => void
104
+ onChoose: (index: number) => void
105
+ /**
106
+ * Own the DOM focus, moving it with the roving index. False while something
107
+ * else inside the prompt holds it (a text field, another question's list) —
108
+ * two lists both chasing `focused` would tear the caret back and forth.
109
+ */
110
+ active?: boolean
111
+ /**
112
+ * Take the keyboard when the list first appears. True by default — a prompt
113
+ * whose whole affordance is "press 1" is useless if the keys go somewhere
114
+ * else, and the CLI hands the keyboard over the moment it asks.
115
+ *
116
+ * It is a *first mount* decision only, and it declines when the reader is
117
+ * already typing (see {@link isTyping}): an approval landing mid-sentence must
118
+ * not pull the caret out of the composer and scatter the rest of the sentence
119
+ * across an option list.
120
+ */
121
+ autoFocus?: boolean
122
+ label: string
123
+ }
124
+
125
+ /** Is the reader mid-keystroke somewhere that keeps its own caret? */
126
+ function isTyping(element: Element | null): boolean {
127
+ if (!(element instanceof HTMLElement)) return false
128
+ const editable =
129
+ element.tagName === 'INPUT' || element.tagName === 'TEXTAREA' || element.isContentEditable
130
+ if (!editable) return false
131
+ // Focus in a field is not the same as a message in progress, and only the
132
+ // second is worth protecting. This used to return true for any focused
133
+ // editable, which read fine until a host that keeps the composer focused at
134
+ // all times ran it: VS Code puts the caret in the composer when a session is
135
+ // shown and again on any click in dead space, so the field was *always* the
136
+ // active element and the prompt therefore *never* took the keyboard. The
137
+ // approval that has to be answered was the one thing you could not answer
138
+ // without reaching for the mouse.
139
+ //
140
+ // An empty field has nothing to lose, so the takeover proceeds; a half-typed
141
+ // message still wins, which is the case the guard was written for.
142
+ const text =
143
+ element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement
144
+ ? element.value
145
+ : (element.textContent ?? '')
146
+ return text.trim().length > 0
147
+ }
148
+
149
+ /**
150
+ * A keyboard-first list of choices, as rows: `↑`/`↓` move, `1`–`9` pick
151
+ * directly, `Enter`/`Space` take the focused one (the button does that itself).
152
+ *
153
+ * The number is part of the gutter, not the label, so every option's text starts
154
+ * on the same column and the list reads as a column of answers rather than a
155
+ * ragged paragraph.
156
+ */
157
+ export function Choices({
158
+ options,
159
+ focused,
160
+ onFocus,
161
+ onChoose,
162
+ active = true,
163
+ autoFocus = true,
164
+ label,
165
+ }: ChoicesProps) {
166
+ const refs = useRef<Array<HTMLButtonElement | null>>([])
167
+
168
+ useEffect(() => {
169
+ if (!active) return
170
+ // Two different jobs, told apart by where the keyboard already is rather
171
+ // than by how many times this has run.
172
+ //
173
+ // If focus is already on one of these rows, the roving cursor is moving and
174
+ // the DOM must follow `focused` unconditionally — otherwise the `❯` and the
175
+ // real caret drift apart. If it is not, this is the initial takeover, which
176
+ // is refusable so it cannot snatch a half-written message.
177
+ //
178
+ // This used to be a `mounted` ref: refuse on the first pass, follow on
179
+ // every pass after. That is not safe under StrictMode, which mounts,
180
+ // unmounts and remounts in development — the ref survives the simulated
181
+ // remount, so the second pass saw `mounted === true`, skipped the guard
182
+ // entirely and stole focus from whatever you were typing. It read as
183
+ // correct in production and wrong in dev, which is the worst way round.
184
+ const focusIsInList = refs.current.some(
185
+ (row) => row !== null && row === document.activeElement,
186
+ )
187
+ if (!focusIsInList && (!autoFocus || isTyping(document.activeElement))) return
188
+ refs.current[focused]?.focus()
189
+ }, [active, focused, autoFocus])
190
+
191
+ const move = (delta: number) => {
192
+ if (options.length > 0) onFocus((focused + delta + options.length) % options.length)
193
+ }
194
+
195
+ return (
196
+ <div
197
+ role='group'
198
+ aria-label={label}
199
+ onKeyDown={(event) => {
200
+ if (event.key === 'ArrowDown') {
201
+ move(1)
202
+ event.preventDefault()
203
+ return
204
+ }
205
+ if (event.key === 'ArrowUp') {
206
+ move(-1)
207
+ event.preventDefault()
208
+ return
209
+ }
210
+ // Digits are the whole point of numbering the rows — but only as far as
211
+ // the rows that exist, so `9` on a three-option prompt stays a no-op
212
+ // rather than a silent miss.
213
+ const digit = Number(event.key)
214
+ if (Number.isInteger(digit) && digit >= 1 && digit <= Math.min(options.length, 9)) {
215
+ onFocus(digit - 1)
216
+ onChoose(digit - 1)
217
+ event.preventDefault()
218
+ }
219
+ }}>
220
+ {options.map((option, index) => {
221
+ const isFocused = index === focused
222
+ return (
223
+ <div key={option.key}>
224
+ <button
225
+ ref={(element) => {
226
+ refs.current[index] = element
227
+ }}
228
+ type='button'
229
+ tabIndex={isFocused ? 0 : -1}
230
+ aria-pressed={option.checked}
231
+ onFocus={() => onFocus(index)}
232
+ onClick={() => onChoose(index)}
233
+ className='term-press'>
234
+ {/* `❯ 1.` is the gutter: marker and number together, so the label
235
+ starts on one column whether or not the row is focused. */}
236
+ <Row
237
+ columns={5}
238
+ glyph={`${isFocused ? '❯' : ' '} ${index + 1}.`}
239
+ glyphTone={isFocused ? 'fg' : 'faint'}
240
+ tone={option.danger ? 'red' : option.selected ? 'green' : 'fg'}
241
+ data-focused={isFocused ? '' : undefined}>
242
+ {option.checked !== undefined ? (
243
+ <Ink tone={option.checked ? 'green' : 'faint'}>
244
+ {MARKERS[option.marker ?? 'check'][option.checked ? 1 : 0]}{' '}
245
+ </Ink>
246
+ ) : null}
247
+ <Ink bold={isFocused || option.selected}>{option.label}</Ink>
248
+ </Row>
249
+ </button>
250
+ {option.description ? (
251
+ <Row columns={5} tone='dim'>
252
+ {option.description}
253
+ </Row>
254
+ ) : null}
255
+ {option.detail ? <div className='term-detail'>{option.detail}</div> : null}
256
+ </div>
257
+ )
258
+ })}
259
+ </div>
260
+ )
261
+ }
262
+
263
+ /**
264
+ * A single-line text field in the terminal idiom: a caret and a rule, no box.
265
+ * `Enter` commits, `Escape` backs out — the caller says what those mean.
266
+ */
267
+ export function PromptInput({
268
+ value,
269
+ onChange,
270
+ onSubmit,
271
+ onCancel,
272
+ placeholder,
273
+ }: {
274
+ value: string
275
+ onChange: (value: string) => void
276
+ onSubmit: () => void
277
+ onCancel: () => void
278
+ placeholder?: string
279
+ }) {
280
+ return (
281
+ <Row columns={5} glyph=' ›' glyphTone='dim'>
282
+ <input
283
+ autoFocus
284
+ value={value}
285
+ placeholder={placeholder}
286
+ onChange={(event) => onChange(event.target.value)}
287
+ onKeyDown={(event) => {
288
+ if (event.key === 'Enter') {
289
+ event.preventDefault()
290
+ onSubmit()
291
+ }
292
+ if (event.key === 'Escape') {
293
+ // The prompt's own Escape means deny/dismiss; inside the field it
294
+ // only closes the field, so it must not travel further.
295
+ event.stopPropagation()
296
+ onCancel()
297
+ }
298
+ }}
299
+ className='term-input'
300
+ />
301
+ </Row>
302
+ )
303
+ }
304
+
305
+ /**
306
+ * The question strip: one chip per question plus the submit step, with the
307
+ * active one filled.
308
+ *
309
+ * It exists because the CLI asks **one question at a time**, and a form that
310
+ * hides two of its three questions has to say so — otherwise answering the first
311
+ * looks like finishing. The arrows are not controls, they are the legend for
312
+ * `Tab`/`Shift+Tab`, which is what actually moves between them.
313
+ */
314
+ export function TabStrip({
315
+ tabs,
316
+ active,
317
+ onSelect,
318
+ }: {
319
+ /** `glyph` rather than a derived done/not-done mark: the submit step is always
320
+ * a `✓` (it is the act of finishing, not a thing to answer), and deriving it
321
+ * would make it a hollow box until every question was done. */
322
+ tabs: { key: string; label: string; glyph: string }[]
323
+ active: number
324
+ onSelect: (index: number) => void
325
+ }) {
326
+ return (
327
+ <Row glyph='←' glyphTone='faint'>
328
+ {tabs.map((tab, index) => (
329
+ <button
330
+ key={tab.key}
331
+ type='button'
332
+ tabIndex={-1}
333
+ onClick={() => onSelect(index)}
334
+ className={cn('term-tab', index === active && 'term-tab-active')}>
335
+ {tab.glyph} {tab.label}
336
+ </button>
337
+ ))}
338
+ <Ink tone='faint'> →</Ink>
339
+ </Row>
340
+ )
341
+ }
342
+
343
+ export { Blank }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * How much of a tool result a collapsed row shows, and what it says it hid.
3
+ *
4
+ * Its own module, and pure, because **two** consumers must agree on it to the
5
+ * character: `items.tsx` draws these rows, and `height.ts` predicts their pixel
6
+ * height for the virtualizer's `estimateSize` without a DOM. The budget used to
7
+ * be a private constant in `items.tsx` restated as a copy in `height.ts` with a
8
+ * comment admitting the drift risk — this is that comment's fix.
9
+ *
10
+ * **Two budgets, not one.** Lines alone was the old rule and it has an exact
11
+ * blind spot: a minified JSON reply — which is every MCP tool's reply — is ONE
12
+ * line, so a four-line slice kept all thirty thousand characters of it and the
13
+ * row wrapped to a screenful. `hidden` was computed as `lines.length -
14
+ * shown.length`, so it came out zero and the row did not even offer the "+N"
15
+ * affordance: the whole blob was simply the transcript now. Characters alone
16
+ * would be wrong the other way, cutting an ordinary short-line result mid-way
17
+ * for no reason. So both apply, and a *first* line longer than the budget is
18
+ * truncated rather than shown whole — a row has to show something or it opens
19
+ * onto nothing.
20
+ */
21
+
22
+ /** At most this many lines, however short they are. */
23
+ const PREVIEW_LINES = 4
24
+ /**
25
+ * …and at most this many characters, however few lines they are. Four lines'
26
+ * worth at any realistic terminal width — the row is indented six cells, so a
27
+ * 100ch panel fits ~94 per line — which keeps the budget honest whether the
28
+ * result arrives as four lines or as one long one.
29
+ */
30
+ const PREVIEW_CHARS = 400
31
+
32
+ export type CollapsedResult = {
33
+ /** The lines to draw. The last may be truncated (it ends in `…`). */
34
+ shown: string[]
35
+ /**
36
+ * The trailing "there is more" row, already spelled. The *string* rather than
37
+ * a count, because `height.ts` wraps this exact text to size the row, and two
38
+ * spellings would be two different heights.
39
+ */
40
+ more?: string
41
+ }
42
+
43
+ /**
44
+ * Reported in characters when the truncation happened *inside* a line and in
45
+ * lines otherwise — a one-line JSON blob has no hidden lines to count, and
46
+ * "+0 lines" under a visibly cut-off row is worse than saying nothing.
47
+ */
48
+ export function collapsedResult(lines: string[]): CollapsedResult {
49
+ const shown: string[] = []
50
+ let chars = 0
51
+ let cut = false
52
+
53
+ for (const line of lines.slice(0, PREVIEW_LINES)) {
54
+ if (shown.length === 0 && line.length > PREVIEW_CHARS) {
55
+ shown.push(`${line.slice(0, PREVIEW_CHARS)}…`)
56
+ chars = PREVIEW_CHARS
57
+ cut = true
58
+ break
59
+ }
60
+ if (shown.length > 0 && chars + line.length > PREVIEW_CHARS) break
61
+ shown.push(line)
62
+ chars += line.length + 1
63
+ }
64
+
65
+ if (cut) {
66
+ // `join` because the newlines are part of what is not being shown.
67
+ const hidden = lines.join('\n').length - chars
68
+ return { shown, more: `… +${hidden.toLocaleString()} chars` }
69
+ }
70
+ const hidden = lines.length - shown.length
71
+ return { shown, more: hidden > 0 ? `… +${hidden} line${hidden === 1 ? '' : 's'}` : undefined }
72
+ }
@@ -0,0 +1,132 @@
1
+ import type { CSSProperties, HTMLAttributes, ReactNode } from 'react'
2
+ import { cn } from '../../lib/utils.ts'
3
+
4
+ /**
5
+ * The primitives every terminal row is built from.
6
+ *
7
+ * There are three, and that is the whole vocabulary: a {@link Row} (a gutter
8
+ * cell and a body cell), a {@link Blank} (one empty line), and a {@link Band} (a
9
+ * run of rows carrying a full-bleed background). Anything the theme draws — a
10
+ * message, a tool call, a diff hunk, an approval prompt — is some arrangement of
11
+ * those, which is what keeps the grid a property of the renderer rather than a
12
+ * thing each component re-derives.
13
+ *
14
+ * Geometry is in `styles/terminal.css`. These components choose a class, a
15
+ * marker and a tone; they never carry a measurement.
16
+ */
17
+
18
+ /** The palette, as a name. See the `[data-tone]` rules in `terminal.css`. */
19
+ export type Tone =
20
+ | 'fg'
21
+ | 'bright'
22
+ | 'dim'
23
+ | 'faint'
24
+ | 'mark'
25
+ | 'blue'
26
+ | 'green'
27
+ | 'red'
28
+ | 'yellow'
29
+ | 'magenta'
30
+
31
+ export interface RowProps extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {
32
+ /**
33
+ * What sits in the gutter — `●`, `⎿`, `>`, a list bullet, or nothing. Kept to
34
+ * the width of the gutter (`--term-cell`, two columns by default): a wider
35
+ * marker would push its own body off the column every other row starts on.
36
+ * Omitted, the gutter is still drawn as empty space, so an unmarked row's
37
+ * text lines up with a marked one's.
38
+ */
39
+ glyph?: ReactNode
40
+ /** The marker's colour. Defaults to dim — the marker is structure, not content. */
41
+ glyphTone?: Tone
42
+ /** The body's colour. */
43
+ tone?: Tone
44
+ bold?: boolean
45
+ /** Indent levels, one character cell each. A child row's marker then sits
46
+ * exactly under its parent row's first letter. */
47
+ indent?: 0 | 1 | 2 | 3
48
+ /**
49
+ * Gutter width in columns, when the marker needs other than two — an ordered
50
+ * list's `10.` is four, a prompt's `❯ 1.` is five, and a framed payload wants
51
+ * `0`. Changes only this row's split, so the body still starts on a whole
52
+ * column.
53
+ */
54
+ columns?: number
55
+ children?: ReactNode
56
+ }
57
+
58
+ export function Row({
59
+ glyph,
60
+ glyphTone,
61
+ tone,
62
+ bold,
63
+ indent,
64
+ columns,
65
+ className,
66
+ children,
67
+ style,
68
+ ...props
69
+ }: RowProps) {
70
+ return (
71
+ <div
72
+ className={cn('term-row', className)}
73
+ data-indent={indent ? String(indent) : undefined}
74
+ data-tone={tone}
75
+ data-weight={bold ? 'bold' : undefined}
76
+ // `!== undefined`, not truthiness: `columns={0}` is a real request for a
77
+ // gutterless row (a framed payload) and must not fall back to the default.
78
+ style={
79
+ columns === undefined
80
+ ? style
81
+ : ({ ...style, '--term-cell': `${columns}ch` } as CSSProperties)
82
+ }
83
+ {...props}>
84
+ <span className='term-gutter' data-tone={glyphTone} aria-hidden>
85
+ {glyph ?? ' '}
86
+ </span>
87
+ {/* A div, not a span: a body holds block content (a markdown message, a
88
+ band of output) as often as it holds a line of text. */}
89
+ <div className='term-body'>{children}</div>
90
+ </div>
91
+ )
92
+ }
93
+
94
+ /**
95
+ * One empty line — the theme's only vertical spacing.
96
+ *
97
+ * A terminal separates blocks with a blank line, not with padding, and saying it
98
+ * that way has a practical payoff: spacing is part of the row list, so it can be
99
+ * decided by whoever knows whether two blocks belong together (a tool call and
100
+ * its output do not get one; two assistant turns do), instead of by a margin
101
+ * rule that cannot tell them apart.
102
+ */
103
+ export function Blank() {
104
+ return <div className='term-blank' aria-hidden />
105
+ }
106
+
107
+ /**
108
+ * A run of rows under a background: a code block, a command's output, a diff
109
+ * hunk. Full-bleed — the wash reaches the scroller's edges, because in a
110
+ * terminal the line is the full width of the screen. See `--term-bleed` on
111
+ * {@link TerminalSurface}.
112
+ */
113
+ export function Band({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
114
+ return <div className={cn('term-band', className)} {...props} />
115
+ }
116
+
117
+ /** Inline colour/weight inside a row's body. */
118
+ export function Ink({
119
+ tone,
120
+ bold,
121
+ className,
122
+ ...props
123
+ }: HTMLAttributes<HTMLSpanElement> & { tone?: Tone; bold?: boolean }) {
124
+ return (
125
+ <span
126
+ data-tone={tone}
127
+ data-weight={bold ? 'bold' : undefined}
128
+ className={className}
129
+ {...props}
130
+ />
131
+ )
132
+ }