@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
@@ -3,21 +3,8 @@ import type { TranscriptItem, TranscriptState } from '@workerdeck/react'
3
3
  import { cn } from '../../lib/utils.ts'
4
4
  import type { TerminalAffordances } from './affordances.tsx'
5
5
  import { OpenSubagentAction, WithActions } from './affordances.tsx'
6
- import {
7
- AssistantRow,
8
- FileRow,
9
- NoticeRow,
10
- ToolRunRow,
11
- ThinkingRow,
12
- ToolRow,
13
- TurnResultRow,
14
- UserRow,
15
- WorkingRow,
16
- blockNeedsBlank,
17
- taskChildItems,
18
- terminalBlocks,
19
- type TaskBlock,
20
- } from './items.tsx'
6
+ import { AssistantRow, FileRow, NoticeRow, ToolRunRow, ThinkingRow, ToolRow, TurnResultRow, UserRow, WorkingRow } from './items.tsx'
7
+ import { blockNeedsBlank, taskChildItems, terminalBlocks, type TaskBlock } from './blocks.ts'
21
8
  import { usePulse } from '../agent/pulse.tsx'
22
9
  import { Pressable, useRevealOnOpen } from './press.tsx'
23
10
  import { taskBrief, taskBusy, taskFailed, taskSummary } from './tool-run.ts'
@@ -25,122 +12,61 @@ import { BRIEF_LINES } from './height.ts'
25
12
  import { Blank, Row } from './row.tsx'
26
13
  import { TerminalSurface } from './surface.tsx'
27
14
 
28
- /**
29
- * The transcript, drawn as a terminal.
30
- *
31
- * The whole of the layout is here and it is four lines long, which is the point:
32
- * items become row blocks, a blank line goes between blocks that do not belong
33
- * together, and the working line is one more row at the end. There is no gap
34
- * scale, no density knob and no variant branch — a terminal has one line height
35
- * and one type size, and everything the theme can express is expressed in which
36
- * rows exist and what marker each carries.
37
- *
38
- * Not virtualized yet: the row blocks are the part that has to be right first,
39
- * and the existing `Transcript` already owns a hard-won virtualizer plus the
40
- * scroll-regime rules it needs. This renders into that shell when the rows are
41
- * settled, rather than growing a second copy of that logic.
42
- */
43
15
  export interface TerminalTranscriptProps {
44
16
  state: TranscriptState
45
- /** Builds the download URL for a delivered file. */
46
17
  fileUrl?: (path: string) => string
47
- /** Cell metrics, in whole pixels. See {@link TerminalSurface}. */
48
18
  fontSize?: number
49
19
  lineHeight?: number
50
- /** The pointer affordances a real terminal has no way to offer — hover fill,
51
- * hover-revealed copy. `false` for none. See {@link TerminalAffordances}. */
52
20
  affordances?: TerminalAffordances | boolean
53
21
  className?: string
54
22
  }
55
23
 
56
- /**
57
- * One transcript item as terminal rows. Exported because the virtualized shell
58
- * in `agent/Transcript.tsx` renders rows through it: the theme owns how a row
59
- * looks, that component owns which rows are mounted, and neither needs a copy
60
- * of the other.
61
- */
62
- export function TerminalItemView({
63
- item,
64
- fileUrl,
65
- }: {
66
- item: TranscriptItem
67
- fileUrl?: (path: string) => string
68
- }) {
24
+ export function TerminalItemView({ item, fileUrl }: { item: TranscriptItem; fileUrl?: (path: string) => string }) {
69
25
  switch (item.kind) {
70
- case 'user':
26
+ case 'user': {
71
27
  return <UserRow item={item} />
72
- case 'assistant_text':
28
+ }
29
+ case 'assistant_text': {
73
30
  return <AssistantRow item={item} />
74
- case 'thinking':
31
+ }
32
+ case 'thinking': {
75
33
  return <ThinkingRow item={item} />
76
- case 'tool_call':
34
+ }
35
+ case 'tool_call': {
77
36
  return <ToolRow item={item} />
78
- case 'turn_result':
37
+ }
38
+ case 'turn_result': {
79
39
  return <TurnResultRow item={item} />
80
- case 'notice':
40
+ }
41
+ case 'notice': {
81
42
  return <NoticeRow item={item} />
82
- case 'file_delivered':
43
+ }
44
+ case 'file_delivered': {
83
45
  return <FileRow item={item} href={fileUrl?.(item.path)} />
84
- default:
46
+ }
47
+ default: {
85
48
  return null
49
+ }
86
50
  }
87
51
  }
88
52
 
89
- /**
90
- * A `Task` and everything the subagent it spawned produced, as one row.
91
- *
92
- * The same claim the tool-run fold makes, and a stronger one: a subagent is
93
- * *sixty* rows of somebody else's working — a brief, a dozen greps, its own
94
- * thinking — and none of it is what you came back to read. What you came back
95
- * to read is the report, and the report is the model's next sentence. So the
96
- * whole frame collapses to one line saying what was asked and how big the
97
- * answer was, and opens in full the moment it is the thing you want.
98
- *
99
- * **Always collapsed when unmounted**, and that is load-bearing rather than
100
- * tidy: `height.ts` predicts this row as exactly one wrapped `taskSummary`, and
101
- * expansion is component-local state that dies with the row. A row auto-opening
102
- * because its subagent happens to be running would make its own height
103
- * unpredictable — which is why the live signal is *in* the collapsed line (the
104
- * pulse, and a count that climbs) rather than in an open block.
105
- *
106
- * The children are the theme's ordinary rows, stepped in behind a rule, and
107
- * they fold among themselves: a subagent's consecutive tool calls are as much
108
- * an aside inside its frame as they are in the main thread.
109
- */
110
- /**
111
- * **What the agent was asked** — the sub-agent's brief, clipped to
112
- * {@link BRIEF_LINES} and pressable for the whole of it.
113
- *
114
- * It leads the takeover's frame and the inline task expansion alike, because
115
- * both are answering the same question and an answer without its question is
116
- * half a transcript. This is the one row here built from something other than
117
- * the stream, and it exists for the case the stream does not cover: a
118
- * **background** agent forwards no brief, where a foreground `Task` forwards a
119
- * real nested user item that renders as an ordinary prompt row. The callers
120
- * splice this in only when that row is absent. Codex draws nothing either way —
121
- * its spawn message is encrypted on the wire, so there is no brief to give.
122
- *
123
- * `>` and blue, the prompt's own marker and colour, because that is what this
124
- * is: somebody's instruction, one level in. The clip is `line-clamp`, which
125
- * cuts on the same wrapped lines `briefPx` counts — one rule, so the reserved
126
- * height and the drawn height cannot disagree.
127
- */
128
53
  export function BriefRow({ text, terminal }: { text: string; terminal?: boolean }) {
129
54
  const [open, setOpen] = useState(false)
130
55
  if (!terminal) {
131
56
  return (
132
- <div data-slot='brief' className='px-4 py-2 text-body-sm whitespace-pre-wrap text-fg-2'>
57
+ <div data-slot="brief" className="px-4 py-2 text-body-sm whitespace-pre-wrap text-fg-2">
133
58
  {text}
134
59
  </div>
135
60
  )
136
61
  }
137
62
  return (
138
- <div data-slot='brief'>
63
+ <div data-slot="brief">
139
64
  <Pressable onPress={() => setOpen((v) => !v)} expanded={open}>
140
- <Row glyph='>' glyphTone='blue' tone='dim'>
65
+ <Row glyph=">" glyphTone="blue" tone="dim">
141
66
  <span
142
67
  className={cn('whitespace-pre-wrap', !open && 'term-brief-clip')}
143
- style={open ? undefined : { WebkitLineClamp: BRIEF_LINES }}>
68
+ style={open ? undefined : { WebkitLineClamp: BRIEF_LINES }}
69
+ >
144
70
  {text}
145
71
  </span>
146
72
  </Row>
@@ -156,17 +82,11 @@ export function TaskRow({
156
82
  }: {
157
83
  block: TaskBlock
158
84
  fileUrl?: (path: string) => string
159
- /** Take over the panel with this sub-agent's own frame. Absent draws no
160
- * affordance at all — the plain renderer has no surface to take over, and an
161
- * action that opens nothing is worse than no action. */
162
85
  onOpenSubagent?: (toolUseId: string) => void
163
86
  }) {
164
87
  const [open, setOpen] = useState(false)
165
88
  const reveal = useRevealOnOpen(open)
166
89
  const children = useMemo(() => taskChildItems(block), [block])
167
- // Only when the sub-agent's own stream carries no brief — a foreground Task
168
- // forwards one as a real user item, and two spellings of one instruction is
169
- // worse than none. See `taskBrief`.
170
90
  const brief = children.some((item) => item.kind === 'user') ? undefined : taskBrief(block.task)
171
91
  const busy = taskBusy(block.task, children)
172
92
  const failed = taskFailed(block.task)
@@ -175,64 +95,30 @@ export function TaskRow({
175
95
  const row = (
176
96
  <div ref={reveal} className={open ? 'term-open' : undefined}>
177
97
  <Pressable onPress={() => setOpen((v) => !v)} expanded={open}>
178
- {/* A marker, where a folded run of calls gets none: a run is an aside,
179
- but delegating a piece of the work is something the model *did*, and
180
- the row stands for the whole of it. The body is `taskSummary`
181
- verbatim — it is the string `height.ts` wraps to size this row, and
182
- a second spelling here would be a second height. */}
183
- {/* Green means sub-agent, and it means it here for the same reason it
184
- means it on the rail: every other colour is spoken for — blue is
185
- you, white is the answer, red is an alarm, magenta is your bookmark,
186
- yellow is the session waiting on you (see `terminal.css`). The
187
- *body* is green and the marker is not: a green glyph already means
188
- "wrote to the workspace" a few rows down, and one colour cannot mean
189
- two things in the same gutter. Failure still outranks it — an alarm
190
- is not a category. */}
191
- <Row
192
- glyph={busy ? pulse : '●'}
193
- glyphTone={failed ? 'red' : busy ? 'mark' : 'dim'}
194
- tone={failed ? 'red' : 'green'}>
98
+ <Row glyph={busy ? pulse : '●'} glyphTone={failed ? 'red' : busy ? 'mark' : 'dim'} tone={failed ? 'red' : 'green'}>
195
99
  {taskSummary(block.task, children)}
196
100
  </Row>
197
101
  </Pressable>
198
102
  {open ? (
199
- // `term-nested` and not the shell's cards-era `border-l-2 pl-3`: this
200
- // sits on the open block's wash, where that border token is invisible,
201
- // and its 14px would take every nested marker off the cell grid.
202
- <div className='term-nested'>
203
- {/* The brief leads the children for the same reason it leads the
204
- frame: the instruction, then the work. */}
103
+ <div className="term-nested">
205
104
  {brief ? <BriefRow text={brief} terminal /> : null}
206
105
  {block.children.map((leaf, index) => (
207
106
  <Fragment key={leaf.key}>
208
107
  {index > 0 && blockNeedsBlank(block.children[index - 1]!, leaf) ? <Blank /> : null}
209
- {'run' in leaf ? (
210
- <ToolRunRow items={leaf.run} />
211
- ) : (
212
- <TerminalItemView item={leaf.item} fileUrl={fileUrl} />
213
- )}
108
+ {'run' in leaf ? <ToolRunRow items={leaf.run} /> : <TerminalItemView item={leaf.item} fileUrl={fileUrl} />}
214
109
  </Fragment>
215
110
  ))}
216
111
  </div>
217
112
  ) : null}
218
113
  </div>
219
114
  )
220
- // Wrapped only when there is somewhere to go. `WithActions` is a no-op when
221
- // affordances are off, but wrapping unconditionally would still put an
222
- // "open" glyph on a renderer that cannot honour it.
223
115
  return onOpenSubagent === undefined ? (
224
116
  row
225
117
  ) : (
226
- <WithActions actions={<OpenSubagentAction onOpen={() => onOpenSubagent(block.task.id)} />}>
227
- {row}
228
- </WithActions>
118
+ <WithActions actions={<OpenSubagentAction onOpen={() => onOpenSubagent(block.task.id)} />}>{row}</WithActions>
229
119
  )
230
120
  }
231
121
 
232
- /** When the current run began — the clock the working line counts from. Held
233
- * here, not in the row, because the row comes and goes within a single turn (it
234
- * hides the moment text streams) and a clock restarting at every tool call
235
- * would be measuring the wrong thing. */
236
122
  function useRunStart(status: TranscriptState['status']): number | undefined {
237
123
  const running = status === 'running' || status === 'starting'
238
124
  const [startedAt, setStartedAt] = useState<number | undefined>(undefined)
@@ -242,25 +128,24 @@ function useRunStart(status: TranscriptState['status']): number | undefined {
242
128
  return running ? startedAt : undefined
243
129
  }
244
130
 
245
- /** Is the model between outputs? Only then does the working line show — while
246
- * text is streaming, the text itself is the evidence. */
247
131
  function working(state: TranscriptState): boolean {
248
- if (state.status !== 'running' && state.status !== 'starting') return false
132
+ if (state.status !== 'running' && state.status !== 'starting') {
133
+ return false
134
+ }
249
135
  const last = state.items.at(-1)
250
- if (!last) return true
251
- if (last.kind === 'assistant_text' && last.streaming) return false
252
- if (last.kind === 'thinking' && last.id === 'streaming-thinking') return false
136
+ if (!last) {
137
+ return true
138
+ }
139
+ if (last.kind === 'assistant_text' && last.streaming) {
140
+ return false
141
+ }
142
+ if (last.kind === 'thinking' && last.id === 'streaming-thinking') {
143
+ return false
144
+ }
253
145
  return true
254
146
  }
255
147
 
256
- export function TerminalTranscript({
257
- state,
258
- fileUrl,
259
- fontSize,
260
- lineHeight,
261
- affordances,
262
- className,
263
- }: TerminalTranscriptProps) {
148
+ export function TerminalTranscript({ state, fileUrl, fontSize, lineHeight, affordances, className }: TerminalTranscriptProps) {
264
149
  const runStartedAt = useRunStart(state.status)
265
150
  const blocks = useMemo(() => terminalBlocks(state.items), [state.items])
266
151
  return (
@@ -268,10 +153,9 @@ export function TerminalTranscript({
268
153
  fontSize={fontSize}
269
154
  lineHeight={lineHeight}
270
155
  affordances={affordances}
271
- // One cell of breathing room at each edge, and the value a full-bleed
272
- // band cancels so its wash reaches the scroller's edge.
273
- bleed='1ch'
274
- className={cn('term-transcript', className)}>
156
+ bleed="1ch"
157
+ className={cn('term-transcript', className)}
158
+ >
275
159
  {blocks.map((block, index) => (
276
160
  <Fragment key={block.key}>
277
161
  {index > 0 && blockNeedsBlank(blocks[index - 1]!, block) ? <Blank /> : null}
@@ -1,30 +1,9 @@
1
1
  import { createContext, useContext, useState, type ReactNode } from 'react'
2
+ import { copyText } from '../../lib/clipboard.ts'
2
3
  import { cn } from '../../lib/utils.ts'
3
4
 
4
- /**
5
- * The things a terminal cannot do, and this can.
6
- *
7
- * The theme's whole discipline is that it renders like a CLI — but it is not a
8
- * CLI, and refusing every affordance a pointer makes possible would be cosplay
9
- * rather than design. A terminal cannot highlight the row under your cursor and
10
- * cannot put a copy button on a block of output; a web view can do both for free
11
- * and they are genuinely useful.
12
- *
13
- * The rule that keeps this honest is that **each one costs no layout**. A hover
14
- * fill is a background. An action button is an overlay at the row's right edge,
15
- * one line tall, absolutely positioned so it displaces nothing. Turn them all
16
- * off and every glyph is on exactly the same cell it was on — which is what
17
- * makes `off` a real option rather than a degraded mode, and why it is the mode
18
- * a host projecting to a real terminal would choose.
19
- *
20
- * Off by nothing in particular: both default **on**, because the surface these
21
- * render on is a browser and pretending otherwise helps nobody. A host that
22
- * wants the pure article passes `affordances={false}`.
23
- */
24
5
  export type TerminalAffordances = {
25
- /** Fill the row under the pointer, on anything pressable. */
26
6
  hover?: boolean
27
- /** Reveal a row's actions (copy, and whatever a row adds) on hover or focus. */
28
7
  actions?: boolean
29
8
  }
30
9
 
@@ -36,88 +15,83 @@ export function useAffordances(): Required<TerminalAffordances> {
36
15
  return useContext(AffordanceContext)
37
16
  }
38
17
 
39
- /** Resolve the surface's prop `false` means none, `true`/absent means all. */
40
- export function resolveAffordances(
41
- value: TerminalAffordances | boolean | undefined,
42
- ): Required<TerminalAffordances> {
43
- if (value === false) return { hover: false, actions: false }
44
- if (value === true || value === undefined) return DEFAULTS
18
+ export function resolveAffordances(value: TerminalAffordances | boolean | undefined): Required<TerminalAffordances> {
19
+ if (value === false) {
20
+ return { hover: false, actions: false }
21
+ }
22
+ if (value === true || value === undefined) {
23
+ return DEFAULTS
24
+ }
45
25
  return { ...DEFAULTS, ...value }
46
26
  }
47
27
 
48
- export function AffordanceProvider({
49
- value,
50
- children,
51
- }: {
52
- value: Required<TerminalAffordances>
53
- children: ReactNode
54
- }) {
28
+ export function AffordanceProvider({ value, children }: { value: Required<TerminalAffordances>; children: ReactNode }) {
55
29
  return <AffordanceContext.Provider value={value}>{children}</AffordanceContext.Provider>
56
30
  }
57
31
 
58
- /**
59
- * A block that reveals its actions on hover.
60
- *
61
- * Wraps rather than decorates because the actions belong to the *block* — a
62
- * message is many rows and its copy button belongs at the top right of all of
63
- * them, not on whichever row the pointer happens to be over.
64
- */
65
- export function WithActions({
66
- actions,
67
- children,
68
- className,
69
- }: {
70
- actions: ReactNode
71
- children: ReactNode
72
- className?: string
73
- }) {
32
+ export function WithActions({ actions, children, className }: { actions: ReactNode; children: ReactNode; className?: string }) {
74
33
  const { actions: enabled } = useAffordances()
75
- if (!enabled) return <>{children}</>
34
+ if (!enabled) {
35
+ return <>{children}</>
36
+ }
76
37
  return (
77
38
  <div className={cn('term-hoverable', className)}>
78
39
  {children}
79
- <div className='term-actions'>{actions}</div>
40
+ <div className="term-actions">{actions}</div>
80
41
  </div>
81
42
  )
82
43
  }
83
44
 
84
- /**
85
- * Copy, as a character.
86
- *
87
- * `⧉` and `✓` rather than an SVG for the same reason the rest of the theme uses
88
- * glyphs: an icon drawn at some other size sits between the cells everything
89
- * else is on, and reads as a button borrowed from another application. The tick
90
- * replaces the glyph in place, so the confirmation costs no width either.
91
- */
92
- /**
93
- * Open a sub-agent's own surface, as a character.
94
- *
95
- * A row **action** and not the row's press, because the press already means
96
- * expand/collapse and that is the cheaper, more common intent — a reader
97
- * glancing at what an agent did wants the four rows inline, not a new screen.
98
- * Taking over the panel is the deliberate move, so it gets the deliberate
99
- * target. Same zero-layout contract as `CopyAction`: it lives in the hover
100
- * actions overlay, so a collapsed Task row is exactly as tall with it as
101
- * without, which is what keeps the height book honest.
102
- */
103
- export function OpenSubagentAction({
104
- onOpen,
105
- label = 'Open sub-agent',
106
- }: {
107
- onOpen: () => void
108
- label?: string
109
- }) {
45
+ // Bookmarks are a host concern (which items, where they persist); the transcript only needs
46
+ // membership and a toggle. A missing provider renders no action at all, so embeddings that
47
+ // never wire bookmarks pay nothing — the same contract AffordanceContext has.
48
+ export type BookmarkHandle = {
49
+ has: (itemId: string) => boolean
50
+ toggle: (itemId: string) => void
51
+ }
52
+
53
+ const BookmarkContext = createContext<BookmarkHandle | undefined>(undefined)
54
+
55
+ export function BookmarkProvider({ value, children }: { value: BookmarkHandle | undefined; children: ReactNode }) {
56
+ return <BookmarkContext.Provider value={value}>{children}</BookmarkContext.Provider>
57
+ }
58
+
59
+ export function BookmarkAction({ id }: { id: string }) {
60
+ const handle = useContext(BookmarkContext)
61
+ if (!handle) {
62
+ return null
63
+ }
64
+ const active = handle.has(id)
65
+ const label = active ? 'Remove bookmark' : 'Bookmark'
66
+ return (
67
+ <button
68
+ type="button"
69
+ className="term-action"
70
+ title={label}
71
+ aria-label={label}
72
+ aria-pressed={active}
73
+ onClick={(event) => {
74
+ event.stopPropagation()
75
+ handle.toggle(id)
76
+ }}
77
+ >
78
+ {active ? '★' : '☆'}
79
+ </button>
80
+ )
81
+ }
82
+
83
+ export function OpenSubagentAction({ onOpen, label = 'Open sub-agent' }: { onOpen: () => void; label?: string }) {
110
84
  return (
111
85
  <button
112
- type='button'
113
- className='term-action'
86
+ type="button"
87
+ className="term-action"
114
88
  title={label}
115
89
  aria-label={label}
116
90
  onClick={(event) => {
117
- // The row underneath expands; opening is not expanding.
118
91
  event.stopPropagation()
119
92
  onOpen()
120
- }}>
93
+ }}
94
+ >
121
95
 
122
96
  </button>
123
97
  )
@@ -127,25 +101,21 @@ export function CopyAction({ text, label = 'Copy' }: { text: string; label?: str
127
101
  const [copied, setCopied] = useState(false)
128
102
  return (
129
103
  <button
130
- type='button'
131
- className='term-action'
104
+ type="button"
105
+ className="term-action"
132
106
  title={label}
133
107
  aria-label={label}
134
108
  onClick={(event) => {
135
- // The row underneath is usually pressable (a tool call expands); copying
136
- // is not expanding.
137
109
  event.stopPropagation()
138
- void navigator.clipboard
139
- ?.writeText(text)
140
- .then(() => {
141
- setCopied(true)
142
- setTimeout(() => setCopied(false), 1200)
143
- })
144
- // No toast, no error row: a refused clipboard (an insecure origin, a
145
- // denied permission) is the host's business, and a failed copy that
146
- // says nothing is better than a transcript that grows an error.
147
- .catch(() => {})
148
- }}>
110
+ void copyText(text).then((ok) => {
111
+ if (!ok) {
112
+ return
113
+ }
114
+ setCopied(true)
115
+ setTimeout(() => setCopied(false), 1200)
116
+ })
117
+ }}
118
+ >
149
119
  {copied ? '✓' : '⧉'}
150
120
  </button>
151
121
  )