@workerdeck/ui 0.23.0 → 1.0.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-DDgBkHsi.mjs} +2151 -3357
  2. package/build/SessionPanel-DDgBkHsi.mjs.map +1 -0
  3. package/build/SessionPanel-E_L_ldQ5.d.mts +207 -0
  4. package/build/format-B8ZM9LPy.d.mts +16 -0
  5. package/build/format.d.mts +8 -75
  6. package/build/format.mjs +2 -2
  7. package/build/index.d.mts +63 -1138
  8. package/build/index.mjs +4 -300
  9. package/build/index.mjs.map +1 -1
  10. package/build/scoped.css +15 -23
  11. package/build/{status-BE-zg88x.mjs → status-C0HSLZas.mjs} +14 -85
  12. package/build/status-C0HSLZas.mjs.map +1 -0
  13. package/build/workspace.d.mts +5 -126
  14. package/build/workspace.mjs +11 -143
  15. package/build/workspace.mjs.map +1 -1
  16. package/package.json +4 -4
  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
@@ -2,64 +2,25 @@ import { useEffect, useRef, type ReactNode } from 'react'
2
2
  import { cn } from '../../lib/utils.ts'
3
3
  import { Blank, Ink, Row } from './row.tsx'
4
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
5
  export function Rule({ dashed }: { dashed?: boolean }) {
27
6
  return <div className={cn('term-rule-row', dashed && 'term-rule-dashed')} aria-hidden />
28
7
  }
29
8
 
30
- /** The dim `·`-separated key legend under a prompt. */
31
9
  export function Hint({ children }: { children: ReactNode }) {
32
- return (
33
- <Row tone='faint'>
34
- {children}
35
- </Row>
36
- )
10
+ return <Row tone="faint">{children}</Row>
37
11
  }
38
12
 
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
13
  export function Box({ children, className }: { children: ReactNode; className?: string }) {
45
14
  return <div className={cn('term-box', className)}>{children}</div>
46
15
  }
47
16
 
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
17
  export function PromptTitle({ title, subject }: { title: string; subject?: string }) {
57
18
  return (
58
19
  <>
59
- <Row tone='blue' bold>
20
+ <Row tone="blue" bold>
60
21
  {title}
61
22
  </Row>
62
- {subject ? <Row tone='dim'>{subject}</Row> : null}
23
+ {subject ? <Row tone="dim">{subject}</Row> : null}
63
24
  </>
64
25
  )
65
26
  }
@@ -67,30 +28,14 @@ export function PromptTitle({ title, subject }: { title: string; subject?: strin
67
28
  export type Choice = {
68
29
  key: string
69
30
  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
31
  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
32
  checked?: boolean
80
33
  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
34
  selected?: boolean
87
35
  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
36
  detail?: ReactNode
91
37
  }
92
38
 
93
- /** The two-state glyph pairs, in the forms a terminal would use. */
94
39
  const MARKERS = {
95
40
  check: ['[ ]', '[✓]'],
96
41
  radio: ['( )', '(•)'],
@@ -98,103 +43,49 @@ const MARKERS = {
98
43
 
99
44
  export interface ChoicesProps {
100
45
  options: Choice[]
101
- /** Roving index: the one row that is tab-reachable and wears the `❯`. */
102
46
  focused: number
103
47
  onFocus: (index: number) => void
104
48
  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
49
  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
50
  autoFocus?: boolean
122
51
  label: string
123
52
  }
124
53
 
125
- /** Is the reader mid-keystroke somewhere that keeps its own caret? */
126
54
  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 ?? '')
55
+ if (!(element instanceof HTMLElement)) {
56
+ return false
57
+ }
58
+ const editable = element.tagName === 'INPUT' || element.tagName === 'TEXTAREA' || element.isContentEditable
59
+ if (!editable) {
60
+ return false
61
+ }
62
+ const text = element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement ? element.value : (element.textContent ?? '')
146
63
  return text.trim().length > 0
147
64
  }
148
65
 
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) {
66
+ export function Choices({ options, focused, onFocus, onChoose, active = true, autoFocus = true, label }: ChoicesProps) {
166
67
  const refs = useRef<Array<HTMLButtonElement | null>>([])
167
68
 
168
69
  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
70
+ if (!active) {
71
+ return
72
+ }
73
+ const focusIsInList = refs.current.some((row) => row !== null && row === document.activeElement)
74
+ if (!focusIsInList && (!autoFocus || isTyping(document.activeElement))) {
75
+ return
76
+ }
188
77
  refs.current[focused]?.focus()
189
78
  }, [active, focused, autoFocus])
190
79
 
191
80
  const move = (delta: number) => {
192
- if (options.length > 0) onFocus((focused + delta + options.length) % options.length)
81
+ if (options.length > 0) {
82
+ onFocus((focused + delta + options.length) % options.length)
83
+ }
193
84
  }
194
85
 
195
86
  return (
196
87
  <div
197
- role='group'
88
+ role="group"
198
89
  aria-label={label}
199
90
  onKeyDown={(event) => {
200
91
  if (event.key === 'ArrowDown') {
@@ -207,16 +98,14 @@ export function Choices({
207
98
  event.preventDefault()
208
99
  return
209
100
  }
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
101
  const digit = Number(event.key)
214
102
  if (Number.isInteger(digit) && digit >= 1 && digit <= Math.min(options.length, 9)) {
215
103
  onFocus(digit - 1)
216
104
  onChoose(digit - 1)
217
105
  event.preventDefault()
218
106
  }
219
- }}>
107
+ }}
108
+ >
220
109
  {options.map((option, index) => {
221
110
  const isFocused = index === focused
222
111
  return (
@@ -225,34 +114,32 @@ export function Choices({
225
114
  ref={(element) => {
226
115
  refs.current[index] = element
227
116
  }}
228
- type='button'
117
+ type="button"
229
118
  tabIndex={isFocused ? 0 : -1}
230
119
  aria-pressed={option.checked}
231
120
  onFocus={() => onFocus(index)}
232
121
  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. */}
122
+ className="term-press"
123
+ >
236
124
  <Row
237
125
  columns={5}
238
126
  glyph={`${isFocused ? '❯' : ' '} ${index + 1}.`}
239
127
  glyphTone={isFocused ? 'fg' : 'faint'}
240
128
  tone={option.danger ? 'red' : option.selected ? 'green' : 'fg'}
241
- data-focused={isFocused ? '' : undefined}>
129
+ data-focused={isFocused ? '' : undefined}
130
+ >
242
131
  {option.checked !== undefined ? (
243
- <Ink tone={option.checked ? 'green' : 'faint'}>
244
- {MARKERS[option.marker ?? 'check'][option.checked ? 1 : 0]}{' '}
245
- </Ink>
132
+ <Ink tone={option.checked ? 'green' : 'faint'}>{MARKERS[option.marker ?? 'check'][option.checked ? 1 : 0]} </Ink>
246
133
  ) : null}
247
134
  <Ink bold={isFocused || option.selected}>{option.label}</Ink>
248
135
  </Row>
249
136
  </button>
250
137
  {option.description ? (
251
- <Row columns={5} tone='dim'>
138
+ <Row columns={5} tone="dim">
252
139
  {option.description}
253
140
  </Row>
254
141
  ) : null}
255
- {option.detail ? <div className='term-detail'>{option.detail}</div> : null}
142
+ {option.detail ? <div className="term-detail">{option.detail}</div> : null}
256
143
  </div>
257
144
  )
258
145
  })}
@@ -260,10 +147,6 @@ export function Choices({
260
147
  )
261
148
  }
262
149
 
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
150
  export function PromptInput({
268
151
  value,
269
152
  onChange,
@@ -278,7 +161,7 @@ export function PromptInput({
278
161
  placeholder?: string
279
162
  }) {
280
163
  return (
281
- <Row columns={5} glyph='' glyphTone='dim'>
164
+ <Row columns={5} glyph="" glyphTone="dim">
282
165
  <input
283
166
  autoFocus
284
167
  value={value}
@@ -290,52 +173,39 @@ export function PromptInput({
290
173
  onSubmit()
291
174
  }
292
175
  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
176
  event.stopPropagation()
296
177
  onCancel()
297
178
  }
298
179
  }}
299
- className='term-input'
180
+ className="term-input"
300
181
  />
301
182
  </Row>
302
183
  )
303
184
  }
304
185
 
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
186
  export function TabStrip({
315
187
  tabs,
316
188
  active,
317
189
  onSelect,
318
190
  }: {
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
191
  tabs: { key: string; label: string; glyph: string }[]
323
192
  active: number
324
193
  onSelect: (index: number) => void
325
194
  }) {
326
195
  return (
327
- <Row glyph='' glyphTone='faint'>
196
+ <Row glyph="" glyphTone="faint">
328
197
  {tabs.map((tab, index) => (
329
198
  <button
330
199
  key={tab.key}
331
- type='button'
200
+ type="button"
332
201
  tabIndex={-1}
333
202
  onClick={() => onSelect(index)}
334
- className={cn('term-tab', index === active && 'term-tab-active')}>
203
+ className={cn('term-tab', index === active && 'term-tab-active')}
204
+ >
335
205
  {tab.glyph} {tab.label}
336
206
  </button>
337
207
  ))}
338
- <Ink tone='faint'> →</Ink>
208
+ <Ink tone="faint"> →</Ink>
339
209
  </Row>
340
210
  )
341
211
  }
@@ -1,59 +1,11 @@
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
1
  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
2
  const PREVIEW_CHARS = 400
31
3
 
32
4
  export type CollapsedResult = {
33
- /** The lines to draw. The last may be truncated (it ends in `…`). */
34
5
  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
6
  more?: string
41
7
  }
42
8
 
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
- /**
49
- * `totalChars` is the **untruncated** length when the replay delivered only a
50
- * head (protocol's `ToolResultBlock.total_chars`). Passing it is not cosmetic:
51
- * computed from the head this row would say "… +7,600 chars" where the truth is
52
- * 641,003, and the wrong string is a *different pixel height* — which is exactly
53
- * the drift this module exists to prevent, since `height.ts` sizes the row by
54
- * wrapping this same text. Omitted for a whole result, where the lines are the
55
- * whole truth.
56
- */
57
9
  export function collapsedResult(lines: string[], totalChars?: number): CollapsedResult {
58
10
  const shown: string[] = []
59
11
  let chars = 0
@@ -66,21 +18,21 @@ export function collapsedResult(lines: string[], totalChars?: number): Collapsed
66
18
  cut = true
67
19
  break
68
20
  }
69
- if (shown.length > 0 && chars + line.length > PREVIEW_CHARS) break
21
+ if (shown.length > 0 && chars + line.length > PREVIEW_CHARS) {
22
+ break
23
+ }
70
24
  shown.push(line)
71
25
  chars += line.length + 1
72
26
  }
73
27
 
74
- // `join` because the newlines are part of what is not being shown — and
75
- // `totalChars` wins when it exists, because the lines in hand are then a head
76
- // rather than the result.
77
28
  const held = lines.join('\n').length
78
29
  const total = totalChars ?? held
79
- if (cut) return { shown, more: `… +${(total - chars).toLocaleString()} chars` }
80
- // A truncated result always has more, even when its head happened to fit the
81
- // line budget: the row must never claim to be showing everything.
82
- if (totalChars !== undefined && total > held)
30
+ if (cut) {
83
31
  return { shown, more: `… +${(total - chars).toLocaleString()} chars` }
32
+ }
33
+ if (totalChars !== undefined && total > held) {
34
+ return { shown, more: `… +${(total - chars).toLocaleString()} chars` }
35
+ }
84
36
  const hidden = lines.length - shown.length
85
37
  return { shown, more: hidden > 0 ? `… +${hidden} line${hidden === 1 ? '' : 's'}` : undefined }
86
38
  }
@@ -1,132 +1,45 @@
1
1
  import type { CSSProperties, HTMLAttributes, ReactNode } from 'react'
2
2
  import { cn } from '../../lib/utils.ts'
3
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'
4
+ export type Tone = 'fg' | 'bright' | 'dim' | 'faint' | 'mark' | 'blue' | 'green' | 'red' | 'yellow' | 'magenta'
30
5
 
31
6
  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
7
  glyph?: ReactNode
40
- /** The marker's colour. Defaults to dim — the marker is structure, not content. */
41
8
  glyphTone?: Tone
42
- /** The body's colour. */
43
9
  tone?: Tone
44
10
  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
11
  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
12
  columns?: number
55
13
  children?: ReactNode
56
14
  }
57
15
 
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) {
16
+ export function Row({ glyph, glyphTone, tone, bold, indent, columns, className, children, style, ...props }: RowProps) {
70
17
  return (
71
18
  <div
72
19
  className={cn('term-row', className)}
73
20
  data-indent={indent ? String(indent) : undefined}
74
21
  data-tone={tone}
75
22
  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>
23
+ // `!== undefined`, not truthiness: `columns={0}` is a real request for a gutterless row.
24
+ style={columns === undefined ? style : ({ ...style, '--term-cell': `${columns}ch` } as CSSProperties)}
25
+ {...props}
26
+ >
27
+ <span className="term-gutter" data-tone={glyphTone} aria-hidden>
85
28
  {glyph ?? ' '}
86
29
  </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>
30
+ <div className="term-body">{children}</div>
90
31
  </div>
91
32
  )
92
33
  }
93
34
 
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
35
  export function Blank() {
104
- return <div className='term-blank' aria-hidden />
36
+ return <div className="term-blank" aria-hidden />
105
37
  }
106
38
 
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
39
  export function Band({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
114
40
  return <div className={cn('term-band', className)} {...props} />
115
41
  }
116
42
 
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
- )
43
+ export function Ink({ tone, bold, className, ...props }: HTMLAttributes<HTMLSpanElement> & { tone?: Tone; bold?: boolean }) {
44
+ return <span data-tone={tone} data-weight={bold ? 'bold' : undefined} className={className} {...props} />
132
45
  }