@workerdeck/ui 0.22.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-DgwjH4Ve.mjs → SessionPanel-DDgBkHsi.mjs} +2196 -3364
  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 +80 -24
  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 +9 -124
  14. package/build/workspace.mjs +13 -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 +4 -6
  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 +345 -923
  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 +108 -215
  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 +136 -19
  141. package/src/workspace.ts +1 -24
  142. package/build/SessionPanel-13l25ubU.d.mts +0 -609
  143. package/build/SessionPanel-DgwjH4Ve.mjs.map +0 -1
  144. package/build/format-ljc3lKpA.d.mts +0 -59
  145. package/build/status-BE-zg88x.mjs.map +0 -1
@@ -1,53 +1,9 @@
1
1
  import { formatBytes } from '../../lib/format.ts'
2
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
3
  export const IMAGE_BOX_LINES = 12
28
4
 
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
5
  export function imagePlaceholder(image: { bytes: number }): string {
40
6
  return `image · ${formatBytes(image.bytes)}`
41
7
  }
42
8
 
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
9
  export const IMAGE_UNAVAILABLE = 'image unavailable'
@@ -3,7 +3,7 @@ import type { TranscriptItem } from '@workerdeck/react'
3
3
  import { formatBytes, formatCost, formatDuration, toolInputPreview } from '../../lib/format.ts'
4
4
  import { isMutatingTool } from '../../lib/tool-icon.ts'
5
5
  import { usePulse } from '../agent/pulse.tsx'
6
- import { CopyAction, WithActions } from './affordances.tsx'
6
+ import { BookmarkAction, CopyAction, WithActions } from './affordances.tsx'
7
7
  import { TerminalDiff } from './diff.tsx'
8
8
  import { TerminalMarkdown } from './markdown.tsx'
9
9
  import { Pressable, useRevealOnOpen } from './press.tsx'
@@ -12,76 +12,21 @@ import { collapsedResult } from './result-preview.ts'
12
12
  import { useToolResultFetcher } from '../agent/tool-result-fetch.tsx'
13
13
  import { useToolResultImageSrc } from '../agent/tool-result-image.tsx'
14
14
  import { runFailed, runSummary } from './tool-run.ts'
15
+ import { todoLine, todoPreview, type TodoPreview, type TodoStatus } from './todos.ts'
15
16
  import { type ToolCallItem } from './blocks.ts'
16
17
  import { Band, Blank, Ink, Row, type Tone } from './row.tsx'
17
18
 
18
- // The pure block model — which rows exist — lives in `blocks.ts` now that a
19
- // task block made it non-trivial; re-exported here because this file is where
20
- // consumers have always found it.
21
- export {
22
- blockNeedsBlank,
23
- isRunCall,
24
- needsBlank,
25
- parentOf,
26
- taskChildItems,
27
- terminalBlocks,
28
- type ItemBlock,
29
- type LeafBlock,
30
- type RunBlock,
31
- type TaskBlock,
32
- type TerminalBlock,
33
- type ToolCallItem,
34
- } from './blocks.ts'
35
-
36
- /**
37
- * One transcript item, drawn as terminal rows.
38
- *
39
- * Each renderer here answers the same two questions the CLI answers: which
40
- * marker goes in the gutter, and what the body says. Nothing chooses a spacing,
41
- * a radius or a border — a row is a row, and the space between blocks is a
42
- * {@link Blank} decided by the transcript, which is the only thing that knows
43
- * whether two blocks belong together.
44
- *
45
- * The markers are the CLI's:
46
- *
47
- * | glyph | means |
48
- * |-------|------------------------------------------|
49
- * | `❯` | what you typed |
50
- * | `●` | what the model said, or a tool it called |
51
- * | `⎿` | that tool's output, one level in |
52
- * | `✻` | thinking |
53
- * | `!` | a notice from the runner, not the model |
54
- */
55
-
56
- /**
57
- * The prompt marker, in the transcript and in the composer both.
58
- *
59
- * `❯` rather than `>`: it is the shell prompt of every terminal anyone has
60
- * configured this decade, and it reads as a *prompt* where `>` reads as a
61
- * quotation or a greater-than. Exported because the composer is the same
62
- * marker in the same gutter cell — that is the whole claim of the terminal
63
- * composer, and two spellings of it would put the caret one glyph off the
64
- * column the rows above start on.
65
- */
66
19
  export const PROMPT_GLYPH = '❯'
67
20
 
68
- /** How much the expanded row shows before offering the rest. The collapsed
69
- * budget is `collapsedResult`'s, shared with the height calculator.
70
- *
71
- * Exported for one test and not from the package: protocol's
72
- * `TOOL_RESULT_HEAD_CHARS` is chosen to exceed it, so that a truncated result's
73
- * open state is byte-identical to an untruncated one and only the uncapped
74
- * `full` press ever fetches. That relationship is asserted, not assumed. */
75
21
  export const RESULT_PREVIEW_CHARS = 2000
76
22
 
77
- /** Whole lines up to a character budget — never zero, because a single line
78
- * longer than the budget still has to be shown or the row would open onto
79
- * nothing. */
80
23
  function clipToChars(lines: string[], maxChars: number): string[] {
81
24
  const out: string[] = []
82
25
  let chars = 0
83
26
  for (const line of lines) {
84
- if (out.length > 0 && chars + line.length > maxChars) break
27
+ if (out.length > 0 && chars + line.length > maxChars) {
28
+ break
29
+ }
85
30
  out.push(line)
86
31
  chars += line.length + 1
87
32
  }
@@ -90,42 +35,36 @@ function clipToChars(lines: string[], maxChars: number): string[] {
90
35
 
91
36
  export function UserRow({ item }: { item: Extract<TranscriptItem, { kind: 'user' }> }) {
92
37
  return (
93
- <div className='term-user'>
94
- {item.attachments?.length ? (
95
- <Row glyph={PROMPT_GLYPH} glyphTone='dim' tone='dim'>
96
- {item.attachments.map((attachment) => attachment.name).join(', ')}
97
- </Row>
98
- ) : null}
99
- {/* Every line of a multi-line prompt keeps the band and the column; only
100
- the first keeps the marker, exactly as a shell continuation does. */}
101
- {item.text
102
- ? item.text.split('\n').map((line, index) => (
103
- <Row
104
- key={index}
105
- glyph={index === 0 ? PROMPT_GLYPH : undefined}
106
- glyphTone='dim'
107
- tone='fg'>
108
- {line || ' '}
109
- </Row>
110
- ))
111
- : null}
112
- </div>
38
+ <WithActions actions={<BookmarkAction id={item.id} />}>
39
+ <div className="term-user">
40
+ {item.attachments?.length ? (
41
+ <Row glyph={PROMPT_GLYPH} glyphTone="dim" tone="dim">
42
+ {item.attachments.map((attachment) => attachment.name).join(', ')}
43
+ </Row>
44
+ ) : null}
45
+ {item.text
46
+ ? item.text.split('\n').map((line, index) => (
47
+ <Row key={index} glyph={index === 0 ? PROMPT_GLYPH : undefined} glyphTone="dim" tone="fg">
48
+ {line || ' '}
49
+ </Row>
50
+ ))
51
+ : null}
52
+ </div>
53
+ </WithActions>
113
54
  )
114
55
  }
115
56
 
116
- export function AssistantRow({
117
- item,
118
- }: {
119
- item: Extract<TranscriptItem, { kind: 'assistant_text' }>
120
- }) {
57
+ export function AssistantRow({ item }: { item: Extract<TranscriptItem, { kind: 'assistant_text' }> }) {
121
58
  return (
122
- // Copy the markdown source, not the rendered text: what you paste into an
123
- // issue or a commit message should keep its lists and its code fences.
124
- // Absent while streaming — half a message is not a thing anyone wants on
125
- // their clipboard, and the button would appear mid-sentence.
126
59
  <WithActions
127
- actions={item.streaming ? null : <CopyAction text={item.text} label='Copy message' />}>
128
- <Row glyph='●' glyphTone='fg' tone='fg'>
60
+ actions={
61
+ <>
62
+ <BookmarkAction id={item.id} />
63
+ {item.streaming ? null : <CopyAction text={item.text} label="Copy message" />}
64
+ </>
65
+ }
66
+ >
67
+ <Row glyph="●" glyphTone="fg" tone="fg">
129
68
  <TerminalMarkdown streaming={item.streaming}>{item.text}</TerminalMarkdown>
130
69
  </Row>
131
70
  </WithActions>
@@ -134,13 +73,12 @@ export function AssistantRow({
134
73
 
135
74
  export function ThinkingRow({ item }: { item: Extract<TranscriptItem, { kind: 'thinking' }> }) {
136
75
  return (
137
- <Row glyph='' glyphTone='dim' tone='dim'>
138
- <span className='term-em'>{item.text}</span>
76
+ <Row glyph="" glyphTone="dim" tone="dim">
77
+ <span className="term-em">{item.text}</span>
139
78
  </Row>
140
79
  )
141
80
  }
142
81
 
143
- /** The gutter dot's colour: the call's state, said without a badge. */
144
82
  const TOOL_TONE: Record<string, Tone> = {
145
83
  running: 'blue',
146
84
  pending: 'blue',
@@ -152,208 +90,151 @@ const TOOL_TONE: Record<string, Tone> = {
152
90
  export function ToolRow({ item }: { item: ToolCallItem }) {
153
91
  const [open, setOpen] = useState(false)
154
92
  const [full, setFull] = useState(false)
155
- // Set while the rest of a truncated result is in flight. Row-local, unlike the
156
- // text itself, which lands in transcript state — this is a spinner, not a
157
- // fact about the session.
158
93
  const [fetching, setFetching] = useState(false)
159
94
  const fetchResult = useToolResultFetcher()
160
95
  const reveal = useRevealOnOpen(open)
161
96
  const status = item.status ?? (item.result === undefined ? 'running' : 'settled')
162
97
  const busy = status === 'running' || status === 'pending'
163
98
  const isError = status === 'failed' || item.result?.isError === true
164
- // Ticks only while this row is really running: an idle transcript of a hundred
165
- // settled calls starts no timers at all.
166
99
  const pulse = usePulse(busy)
167
100
 
168
101
  const text = item.result?.text ?? ''
169
102
  const lines = text.trimEnd().split('\n')
170
- // Three states, not two. Collapsed shows a few lines; open shows the output up
171
- // to a character budget; `full` lifts the budget. The middle one is the reason
172
- // the budget exists at all: a tool result can be a hundred thousand characters
173
- // (a test run, a `find /`), and the whole of it lands in **one** virtual row —
174
- // the virtualizer mounts rows, so it cannot help with what is inside a single
175
- // one. Without the clip, expanding one row commits thousands of DOM nodes and
176
- // the transcript stops being smooth for the rest of the session.
177
- // The true total when the replay delivered only a head — the row must count
178
- // what is missing, not what it happens to hold.
179
103
  const collapsed = collapsedResult(lines, item.result?.totalChars)
180
- const preview = open
181
- ? full
182
- ? lines
183
- : clipToChars(lines, RESULT_PREVIEW_CHARS)
184
- : collapsed.shown
104
+ const preview = open ? (full ? lines : clipToChars(lines, RESULT_PREVIEW_CHARS)) : collapsed.shown
185
105
  const hidden = lines.length - preview.length
186
106
  const clipped = open && !full && hidden > 0
187
- // The replay sent a head. `full` then means "fetch the rest", not "lift the
188
- // clip" — and the marker outlives the clip, because a head short enough to fit
189
- // the open budget still is not the result.
190
107
  const truncated = item.result?.truncated === true
191
108
  const missing = truncated ? (item.result?.totalChars ?? 0) - text.length : 0
192
109
 
193
- const tone: Tone = isError
194
- ? 'red'
195
- : // A settled write is green: skimming a run, "what did it change" is the
196
- // question you come back to, and the one you might need to undo.
197
- status === 'settled' && isMutatingTool(item.name)
198
- ? 'green'
199
- : (TOOL_TONE[status] ?? 'dim')
110
+ const tone: Tone = isError ? 'red' : status === 'settled' && isMutatingTool(item.name) ? 'green' : (TOOL_TONE[status] ?? 'dim')
200
111
 
201
- // What is worth having on the clipboard from a tool call is the command you
202
- // would re-run, when there is one, and otherwise its output.
203
112
  const command = (item.input as { command?: unknown } | null)?.command
204
113
  const copyable = typeof command === 'string' ? command : text
114
+ const todos = todoPreview(item.name, item.input)
205
115
 
206
116
  return (
207
- // Open, the whole block keeps a fill: an expansion that runs past the top of
208
- // the screen otherwise leaves no mark of where it began, and the reader has
209
- // to guess which rows they opened.
210
117
  <div ref={reveal} className={open ? 'term-open' : undefined}>
211
- <WithActions
212
- actions={copyable ? <CopyAction text={copyable} label='Copy' /> : null}>
213
- <Pressable onPress={() => setOpen((v) => !v)} expanded={open}>
214
- <Row glyph={busy ? pulse : '●'} glyphTone={tone} tone='fg'>
215
- <Ink bold tone='bright'>
216
- {item.name}
217
- </Ink>
218
- <Ink tone='dim'>({toolInputPreview(item.input)})</Ink>
219
- {item.backend && item.backend !== 'server' ? (
220
- <Ink tone='faint'> · {item.backend}</Ink>
221
- ) : null}
118
+ <WithActions
119
+ actions={
120
+ <>
121
+ <BookmarkAction id={item.id} />
122
+ {copyable ? <CopyAction text={copyable} label="Copy" /> : null}
123
+ </>
124
+ }
125
+ >
126
+ <Pressable onPress={() => setOpen((v) => !v)} expanded={open}>
127
+ <Row glyph={busy ? pulse : ''} glyphTone={tone} tone="fg">
128
+ <Ink bold tone="bright">
129
+ {item.name}
130
+ </Ink>
131
+ <Ink tone="dim">({todos ? todos.summary : toolInputPreview(item.input)})</Ink>
132
+ {item.backend && item.backend !== 'server' ? <Ink tone="faint"> · {item.backend}</Ink> : null}
133
+ </Row>
134
+ </Pressable>
135
+ {item.result?.images?.map((image) => (
136
+ <TerminalImage key={image.partIndex} toolUseId={item.id} image={image} />
137
+ ))}
138
+ {item.patch && !open ? (
139
+ <TerminalDiff patch={item.patch} />
140
+ ) : todos && !open ? (
141
+ <TerminalTodos preview={todos} />
142
+ ) : text ? (
143
+ <>
144
+ {preview.map((line, index) => (
145
+ <Row key={index} indent={1} columns={3} glyph={index === 0 ? '⎿' : undefined} tone={isError ? 'red' : 'dim'}>
146
+ {line || ' '}
147
+ </Row>
148
+ ))}
149
+ {!open ? (
150
+ collapsed.more ? (
151
+ <Row indent={1} columns={3} tone="faint">
152
+ {collapsed.more}
153
+ </Row>
154
+ ) : null
155
+ ) : clipped || truncated ? (
156
+ <Row indent={1} columns={3} tone="faint">
157
+ {fetching ? (
158
+ <>… fetching {(item.result?.totalChars ?? 0).toLocaleString()} chars</>
159
+ ) : clipped || truncated ? (
160
+ <button
161
+ type="button"
162
+ className="term-press term-link"
163
+ onClick={() => {
164
+ setFull(true)
165
+ if (!truncated) {
166
+ return
167
+ }
168
+ setFetching(true)
169
+ void fetchResult(item.id).finally(() => setFetching(false))
170
+ }}
171
+ >
172
+ {truncated
173
+ ? `… +${missing.toLocaleString()} chars — fetch the rest`
174
+ : `… +${hidden} line${hidden === 1 ? '' : 's'} — show all ${text.length.toLocaleString()} chars`}
175
+ </button>
176
+ ) : (
177
+ <>
178
+ … +{hidden} line{hidden === 1 ? '' : 's'}
179
+ </>
180
+ )}
181
+ </Row>
182
+ ) : hidden > 0 ? (
183
+ <Row indent={1} columns={3} tone="faint">
184
+ … +{hidden} line{hidden === 1 ? '' : 's'}
185
+ </Row>
186
+ ) : null}
187
+ </>
188
+ ) : null}
189
+ </WithActions>
190
+ </div>
191
+ )
192
+ }
193
+
194
+ const TODO_TONE: Record<TodoStatus, Tone> = {
195
+ pending: 'dim',
196
+ in_progress: 'blue',
197
+ completed: 'faint',
198
+ }
199
+
200
+ function TerminalTodos({ preview }: { preview: TodoPreview }) {
201
+ return (
202
+ <>
203
+ {preview.shown.map((todo, index) => (
204
+ <Row key={index} indent={1} columns={3} glyph={index === 0 ? '⎿' : undefined} tone={TODO_TONE[todo.status]}>
205
+ {todoLine(todo)}
222
206
  </Row>
223
- </Pressable>
224
- {/* Above the output, because when a call returned a picture the picture is
225
- what the call was: a screenshot's result text is "took a screenshot".
226
- Drawn collapsed as well as open — this is not detail behind a press,
227
- it is the answer. */}
228
- {item.result?.images?.map((image) => (
229
- <TerminalImage key={image.partIndex} toolUseId={item.id} image={image} />
230
207
  ))}
231
- {/* A file edit shows its diff, not its result prose: "The file has been
232
- updated" is what the *model* needed to hear, and the change is what the
233
- reader did. The text stays reachable by expanding. */}
234
- {item.patch && !open ? (
235
- <TerminalDiff patch={item.patch} />
236
- ) : text ? (
237
- <>
238
- {preview.map((line, index) => (
239
- <Row
240
- key={index}
241
- indent={1}
242
- columns={3}
243
- glyph={index === 0 ? '⎿' : undefined}
244
- tone={isError ? 'red' : 'dim'}>
245
- {line || ' '}
246
- </Row>
247
- ))}
248
- {/* One row for "there is more", pressable exactly when pressing it
249
- would do something. Collapsed, the count is a label — the header
250
- above is already the toggle, and a second control for the same act
251
- is one too many. Open and clipped, it is the way to the rest.
252
- Collapsed spells its own label (it may be counting characters
253
- rather than lines, having cut inside one), and it is the string
254
- `height.ts` sizes the row from. */}
255
- {!open ? (
256
- collapsed.more ? (
257
- <Row indent={1} columns={3} tone='faint'>
258
- {collapsed.more}
259
- </Row>
260
- ) : null
261
- ) : clipped || truncated ? (
262
- <Row indent={1} columns={3} tone='faint'>
263
- {fetching ? (
264
- // Never a row that does nothing when pressed: it says what it is
265
- // doing instead. See `planToolCall`'s comment on the same rule.
266
- <>… fetching {(item.result?.totalChars ?? 0).toLocaleString()} chars</>
267
- ) : clipped || truncated ? (
268
- <button
269
- type='button'
270
- className='term-press term-link'
271
- onClick={() => {
272
- // One press, two acts, in the order that keeps the row
273
- // honest: lift the clip immediately (that part is local and
274
- // instant), and fetch the rest when there is a rest. The
275
- // fetched text lands in transcript state, so the row
276
- // re-renders with the marker gone.
277
- setFull(true)
278
- if (!truncated) return
279
- setFetching(true)
280
- void fetchResult(item.id).finally(() => setFetching(false))
281
- }}>
282
- {truncated
283
- ? `… +${missing.toLocaleString()} chars — fetch the rest`
284
- : `… +${hidden} line${hidden === 1 ? '' : 's'} — show all ${text.length.toLocaleString()} chars`}
285
- </button>
286
- ) : (
287
- <>
288
- … +{hidden} line{hidden === 1 ? '' : 's'}
289
- </>
290
- )}
291
- </Row>
292
- ) : hidden > 0 ? (
293
- <Row indent={1} columns={3} tone='faint'>
294
- … +{hidden} line{hidden === 1 ? '' : 's'}
295
- </Row>
296
- ) : null}
297
- </>
208
+ {preview.more ? (
209
+ <Row indent={1} columns={3} tone="faint">
210
+ {preview.more}
211
+ </Row>
298
212
  ) : null}
299
- </WithActions>
300
- </div>
213
+ </>
301
214
  )
302
215
  }
303
216
 
304
- /** One image part of a tool result, as the reducer holds it. */
305
217
  type ToolResultImage = NonNullable<NonNullable<ToolCallItem['result']>['images']>[number]
306
218
 
307
- /**
308
- * A picture a tool returned, in a box of {@link IMAGE_BOX_LINES} whole lines.
309
- *
310
- * **Three states, one height.** Before the fetch lands the box is a wash and the
311
- * size the gateway declared; after it, the picture, letterboxed inside the same
312
- * box; on a refusal, `image unavailable` in it. Nothing here may ever collapse
313
- * to nothing — that is `HostImage`'s return-null-then-pop, which in a
314
- * *virtualized* list is not a flicker but a reflow of every row below it, and
315
- * the height calculator would have been lying about the row from plan time.
316
- *
317
- * The box is why the calculator can stay exact: it is a constant, not a function
318
- * of pixels nobody has downloaded yet.
319
- */
320
219
  function TerminalImage({ toolUseId, image }: { toolUseId: string; image: ToolResultImage }) {
321
220
  const { src, failed } = useToolResultImageSrc({ toolUseId, ...image })
322
221
  return (
323
222
  <Row indent={1} columns={3}>
324
223
  <div
325
- className='term-image'
224
+ className="term-image"
326
225
  data-state={src ? 'loaded' : failed ? 'failed' : 'pending'}
327
- // The one measurement in this file, and it is the shared constant
328
- // spelled once — `height.ts` adds exactly this many lines for exactly
329
- // this box.
330
- style={{ height: `calc(var(--term-line) * ${IMAGE_BOX_LINES})` }}>
226
+ style={{ height: `calc(var(--term-line) * ${IMAGE_BOX_LINES})` }}
227
+ >
331
228
  {src ? (
332
229
  <img src={src} alt={imagePlaceholder(image)} />
333
230
  ) : (
334
- <Ink tone='faint'>{failed ? IMAGE_UNAVAILABLE : imagePlaceholder(image)}</Ink>
231
+ <Ink tone="faint">{failed ? IMAGE_UNAVAILABLE : imagePlaceholder(image)}</Ink>
335
232
  )}
336
233
  </div>
337
234
  </Row>
338
235
  )
339
236
  }
340
237
 
341
- /**
342
- * A run of tool calls, as one line.
343
- *
344
- * The CLI's own compression, and the reason it works: a tool call is almost
345
- * never what you came back to read. `Bash(pnpm -w typecheck)` and forty lines of
346
- * its output say nothing the model's next sentence doesn't say better, and six
347
- * of them in a row bury that sentence a screen and a half down. So a run
348
- * collapses to its count and gets out of the way — and opens, in full, the
349
- * moment it is the thing you actually want.
350
- *
351
- * The membership, wording and failure rules live in `tool-run.ts`, shared with
352
- * the height calculator. A failure never breaks the run — fragmenting it around
353
- * one would hide the failure in a longer list rather than surface it — but only
354
- * the run's **last** call colours it, because that is the run's outcome and an
355
- * outcome is what a collapsed row can honestly claim (see `runFailed`).
356
- */
357
238
  export function ToolRunRow({ items }: { items: ToolCallItem[] }) {
358
239
  const [open, setOpen] = useState(false)
359
240
  const reveal = useRevealOnOpen(open)
@@ -367,13 +248,7 @@ export function ToolRunRow({ items }: { items: ToolCallItem[] }) {
367
248
  return (
368
249
  <div ref={reveal} className={open ? 'term-open' : undefined}>
369
250
  <Pressable onPress={() => setOpen((v) => !v)} expanded={open}>
370
- {/* No marker once settled: a run of calls is an aside, and a bullet
371
- would give it the weight of something the model said. While one is
372
- running the pulse earns the gutter — that much is news. */}
373
- <Row
374
- glyph={busy ? pulse : undefined}
375
- glyphTone={busy ? 'mark' : undefined}
376
- tone={failed ? 'red' : 'dim'}>
251
+ <Row glyph={busy ? pulse : undefined} glyphTone={busy ? 'mark' : undefined} tone={failed ? 'red' : 'dim'}>
377
252
  {runSummary(items, busy)}
378
253
  </Row>
379
254
  </Pressable>
@@ -388,21 +263,14 @@ export function ToolRunRow({ items }: { items: ToolCallItem[] }) {
388
263
  )
389
264
  }
390
265
 
391
- export function TurnResultRow({
392
- item,
393
- }: {
394
- item: Extract<TranscriptItem, { kind: 'turn_result' }>
395
- }) {
266
+ export function TurnResultRow({ item }: { item: Extract<TranscriptItem, { kind: 'turn_result' }> }) {
396
267
  return (
397
268
  <div>
398
269
  <Row tone={item.isError ? 'red' : 'faint'}>
399
- {item.isError ? item.subtype : 'done'} · {formatDuration(item.durationMs)} ·{' '}
400
- {formatCost(item.totalCostUsd)}
270
+ {item.isError ? item.subtype : 'done'} · {formatDuration(item.durationMs)} · {formatCost(item.totalCostUsd)}
401
271
  </Row>
402
- {/* A failed turn's reasons are the whole point of the row — dropping them
403
- leaves "error_during_execution" and nothing to act on. */}
404
272
  {item.errors?.map((message, index) => (
405
- <Row key={index} tone='red'>
273
+ <Row key={index} tone="red">
406
274
  {message}
407
275
  </Row>
408
276
  ))}
@@ -413,72 +281,49 @@ export function TurnResultRow({
413
281
  export function NoticeRow({ item }: { item: Extract<TranscriptItem, { kind: 'notice' }> }) {
414
282
  const error = item.level === 'error'
415
283
  return (
416
- <Row glyph='!' glyphTone={error ? 'red' : 'yellow'} tone={error ? 'red' : 'dim'}>
284
+ <Row glyph="!" glyphTone={error ? 'red' : 'yellow'} tone={error ? 'red' : 'dim'}>
417
285
  {item.text}
418
286
  </Row>
419
287
  )
420
288
  }
421
289
 
422
- export function FileRow({
423
- item,
424
- href,
425
- }: {
426
- item: Extract<TranscriptItem, { kind: 'file_delivered' }>
427
- href?: string
428
- }) {
290
+ export function FileRow({ item, href }: { item: Extract<TranscriptItem, { kind: 'file_delivered' }>; href?: string }) {
429
291
  return (
430
- <Row glyph='' glyphTone='blue' tone='dim'>
292
+ <Row glyph="" glyphTone="blue" tone="dim">
431
293
  {href ? (
432
- <a className='term-link' data-tone='blue' href={href} download>
294
+ <a className="term-link" data-tone="blue" href={href} download>
433
295
  {item.path}
434
296
  </a>
435
297
  ) : (
436
- <Ink tone='blue'>{item.path}</Ink>
298
+ <Ink tone="blue">{item.path}</Ink>
437
299
  )}
438
- <Ink tone='faint'> · {formatBytes(item.bytes)}</Ink>
439
- {item.description ? <Ink tone='faint'> · {item.description}</Ink> : null}
300
+ <Ink tone="faint"> · {formatBytes(item.bytes)}</Ink>
301
+ {item.description ? <Ink tone="faint"> · {item.description}</Ink> : null}
440
302
  </Row>
441
303
  )
442
304
  }
443
305
 
444
- /** A once-a-second clock, running only while `on`. */
445
306
  export function useTicker(on: boolean): number {
446
307
  const [now, setNow] = useState(() => Date.now())
447
308
  useEffect(() => {
448
- if (!on) return
309
+ if (!on) {
310
+ return
311
+ }
449
312
  const timer = setInterval(() => setNow(Date.now()), 1000)
450
313
  return () => clearInterval(timer)
451
314
  }, [on])
452
315
  return now
453
316
  }
454
317
 
455
- /**
456
- * The working line: the mark's pulse, the word, and the run's readings — the
457
- * CLI's own status line, which is a *row of the transcript* rather than a
458
- * spinner floating over it.
459
- */
460
- export function WorkingRow({
461
- label,
462
- startedAt,
463
- tokens,
464
- }: {
465
- label: string
466
- startedAt?: number
467
- tokens?: number
468
- }) {
318
+ export function WorkingRow({ label, startedAt, tokens }: { label: string; startedAt?: number; tokens?: number }) {
469
319
  const pulse = usePulse(true)
470
- // The row owns its clock rather than taking `now` from above, because it is
471
- // mounted only while a turn is in flight: the ticking starts and stops with
472
- // the thing being timed, and an idle transcript runs no interval at all.
473
320
  const now = useTicker(startedAt !== undefined)
474
321
  const elapsed = startedAt === undefined ? undefined : formatDuration(now - startedAt)
475
- const readings = [elapsed, tokens ? `↓ ${(tokens / 1000).toFixed(1)}k tokens` : undefined].filter(
476
- Boolean,
477
- )
322
+ const readings = [elapsed, tokens ? `↓ ${(tokens / 1000).toFixed(1)}k tokens` : undefined].filter(Boolean)
478
323
  return (
479
- <Row glyph={pulse} glyphTone='mark' tone='mark'>
324
+ <Row glyph={pulse} glyphTone="mark" tone="mark">
480
325
  {label}
481
- {readings.length ? <Ink tone='faint'> ({readings.join(' · ')})</Ink> : null}
326
+ {readings.length ? <Ink tone="faint"> ({readings.join(' · ')})</Ink> : null}
482
327
  </Row>
483
328
  )
484
329
  }