@workerdeck/ui 2.15.0 → 3.1.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 (75) hide show
  1. package/README.md +6 -5
  2. package/build/{SessionPanel-BVKqPs_3.d.mts → SessionPanel-BNNc8grK.d.mts} +18 -16
  3. package/build/index.d.mts +194 -49
  4. package/build/index.mjs +562 -260
  5. package/build/index.mjs.map +1 -1
  6. package/build/scoped.css +170 -21
  7. package/build/{use-path-links-BgWXmfAS.mjs → use-path-links-C6Fh5zdx.mjs} +3312 -2758
  8. package/build/use-path-links-C6Fh5zdx.mjs.map +1 -0
  9. package/build/workspace.d.mts +3 -3
  10. package/build/workspace.mjs +3 -3
  11. package/build/workspace.mjs.map +1 -1
  12. package/package.json +4 -4
  13. package/src/components/agent/ApprovalPrompts.tsx +87 -0
  14. package/src/components/agent/Composer.tsx +25 -47
  15. package/src/components/agent/ContextDialog.tsx +75 -67
  16. package/src/components/agent/FrameStrip.tsx +74 -0
  17. package/src/components/agent/HostFilesDialog.tsx +3 -2
  18. package/src/components/agent/McpDialog.tsx +112 -78
  19. package/src/components/agent/PermissionPrompt.tsx +22 -17
  20. package/src/components/agent/QuestionPrompt.tsx +6 -41
  21. package/src/components/agent/Response.tsx +8 -1
  22. package/src/components/agent/Scrubber.tsx +6 -75
  23. package/src/components/agent/SessionBrowser.tsx +131 -214
  24. package/src/components/agent/SessionFilters.tsx +266 -0
  25. package/src/components/agent/SessionInfoDialog.tsx +47 -63
  26. package/src/components/agent/SessionItem.tsx +29 -6
  27. package/src/components/agent/SessionPanel.tsx +249 -498
  28. package/src/components/agent/SessionSteps.tsx +98 -14
  29. package/src/components/agent/SessionWorkspace.tsx +3 -3
  30. package/src/components/agent/ShellStrip.tsx +43 -36
  31. package/src/components/agent/ShellTerminal.tsx +1 -1
  32. package/src/components/agent/SubagentStrip.tsx +13 -30
  33. package/src/components/agent/ToolCallCard.tsx +18 -41
  34. package/src/components/agent/Transcript.tsx +111 -93
  35. package/src/components/agent/TranscriptItemView.tsx +80 -67
  36. package/src/components/agent/TranscriptRows.tsx +4 -2
  37. package/src/components/agent/composer-commands.ts +80 -1
  38. package/src/components/agent/image-viewer.tsx +326 -0
  39. package/src/components/agent/markdown-image.tsx +139 -0
  40. package/src/components/agent/scrubber-marks.ts +33 -11
  41. package/src/components/agent/scrubber-peek.tsx +122 -0
  42. package/src/components/agent/session-panel-providers.tsx +41 -0
  43. package/src/components/agent/shell-actions.tsx +38 -1
  44. package/src/components/agent/tool-result-image.tsx +40 -0
  45. package/src/components/agent/transcript-variant.tsx +3 -21
  46. package/src/components/agent/use-catch-up.ts +20 -0
  47. package/src/components/agent/use-frame.ts +88 -0
  48. package/src/components/agent/use-host-image.ts +81 -0
  49. package/src/components/agent/use-shell-frame.ts +12 -70
  50. package/src/components/agent/use-subagent-frame.ts +19 -75
  51. package/src/components/prompt-area/prompt-area-engine.ts +21 -20
  52. package/src/components/prompt-area/prompt-area-list-ops.ts +12 -15
  53. package/src/components/prompt-area/use-prompt-area-keydown.ts +4 -2
  54. package/src/components/terminal/PermissionPrompt.tsx +31 -28
  55. package/src/components/terminal/QuestionPrompt.tsx +10 -46
  56. package/src/components/terminal/TerminalTranscript.tsx +23 -89
  57. package/src/components/terminal/affordances.tsx +100 -74
  58. package/src/components/terminal/height.ts +48 -32
  59. package/src/components/terminal/image-box.ts +31 -0
  60. package/src/components/terminal/items.tsx +70 -30
  61. package/src/components/terminal/markdown.tsx +4 -1
  62. package/src/components/terminal/scrubber.tsx +25 -272
  63. package/src/components/terminal/shell-row.ts +17 -3
  64. package/src/components/ui/Popover.tsx +29 -0
  65. package/src/components/ui/Select.tsx +25 -0
  66. package/src/index.ts +65 -12
  67. package/src/lib/file-link.ts +3 -16
  68. package/src/lib/permission-prompt.ts +57 -0
  69. package/src/lib/question-answers.ts +65 -0
  70. package/src/lib/shell-request.ts +115 -0
  71. package/src/lib/tool-icon.ts +13 -1
  72. package/src/styles/terminal.css +129 -22
  73. package/src/styles/theme.css +4 -0
  74. package/build/use-path-links-BgWXmfAS.mjs.map +0 -1
  75. package/src/components/prompt-area/use-markdown-mode.ts +0 -103
@@ -304,28 +304,25 @@ export function normalizeListPrefixText(text: string, markdownEnabled: boolean):
304
304
  * preserved, while an unterminated fence does not suppress later bullets.
305
305
  */
306
306
  export function normalizeListPrefixes(segments: Segment[], markdownEnabled: boolean): Segment[] {
307
- const globalLines: string[] = []
308
- segments.forEach((seg) => {
309
- if (seg.type === 'text') {
310
- globalLines.push(...seg.text.split('\n'))
311
- }
312
- })
313
- const protectedLines = fenceProtectedLineIndices(globalLines)
307
+ const protectedLines = fenceProtectedLineIndices(segmentsToPlainText(segments).split('\n'))
314
308
 
315
- let globalIndex = 0
309
+ let line = 0
310
+ // A chip is inline, so text right after one continues its line rather than starting a new one.
311
+ let atLineStart = true
316
312
  let changed = false
317
313
  const result = segments.map((seg) => {
318
314
  if (seg.type !== 'text') {
315
+ atLineStart = false
319
316
  return seg
320
317
  }
321
- const newText = seg.text
322
- .split('\n')
323
- .map((line) => {
324
- const out = protectedLines.has(globalIndex) ? line : swapListPrefixLine(line, markdownEnabled)
325
- globalIndex++
326
- return out
327
- })
318
+ const pieces = seg.text.split('\n')
319
+ const newText = pieces
320
+ .map((piece, k) => ((k > 0 || atLineStart) && !protectedLines.has(line + k) ? swapListPrefixLine(piece, markdownEnabled) : piece))
328
321
  .join('\n')
322
+ line += pieces.length - 1
323
+ if (pieces.length > 1 || seg.text !== '') {
324
+ atLineStart = pieces[pieces.length - 1] === ''
325
+ }
329
326
  if (newText === seg.text) {
330
327
  return seg
331
328
  }
@@ -383,10 +383,12 @@ export function usePromptAreaKeydown({
383
383
  const launcher = triggers.find((t) => t.mode === 'launch' && t.char === e.key)
384
384
  const editor = editorRef.current
385
385
  if (launcher?.onActivate && editor) {
386
- const cursorPos = getCursorOffset(editor)
387
- if (cursorPos !== null) {
386
+ const caret = getCursorOffset(editor)
387
+ if (caret !== null) {
388
388
  const segments = readSegmentsFromDOM()
389
389
  const plainText = segmentsToPlainText(segments)
390
+ // An emptied editor keeps the browser's filler <br>: the reader drops it, the caret count does not.
391
+ const cursorPos = Math.min(caret, plainText.length)
390
392
  if (isValidTriggerPosition(plainText, cursorPos, launcher.position)) {
391
393
  e.preventDefault()
392
394
  launcher.onActivate({
@@ -1,7 +1,8 @@
1
1
  import { useState } from 'react'
2
2
  import type { PermissionRequest } from '@workerdeck/protocol'
3
3
  import { toolInputPreview } from '../../lib/format.ts'
4
- import { planFromRequest } from '../../lib/plan-request.ts'
4
+ import { permissionPromptModel } from '../../lib/permission-prompt.ts'
5
+ import { shellRequestLines } from '../../lib/shell-request.ts'
5
6
  import { TerminalDiff, previewPatch } from './diff.tsx'
6
7
  import { TerminalMarkdown } from './markdown.tsx'
7
8
  import { Choices, Hint, PromptInput, PromptTitle, Rule } from './prompt.tsx'
@@ -28,10 +29,10 @@ export function TerminalPermissionPrompt({ request, onApprove, onDeny, className
28
29
  setDenying(false)
29
30
  }
30
31
 
31
- const plan = planFromRequest(request)
32
- const patch = plan ? undefined : previewPatch(request.input)
33
- const summary = plan ? '' : toolInputPreview(request.input)
34
- const heading = plan ? 'Plan ready for review' : (request.displayName ?? request.title ?? 'Permission needed')
32
+ const model = permissionPromptModel(request)
33
+ const { plan, shell, heading } = model
34
+ const patch = plan || shell ? undefined : previewPatch(request.input)
35
+ const summary = plan || shell ? '' : toolInputPreview(request.input)
35
36
  const subject = patch?.path ?? (summary || undefined)
36
37
 
37
38
  const reasonInput = denying ? (
@@ -40,25 +41,16 @@ export function TerminalPermissionPrompt({ request, onApprove, onDeny, className
40
41
  onChange={setReason}
41
42
  onSubmit={() => deny(false)}
42
43
  onCancel={() => setDenying(false)}
43
- placeholder={
44
- plan
45
- ? 'What should change? (optional) - the agent keeps planning and reads this'
46
- : 'Reason (optional) - the agent reads this and can try something else'
47
- }
44
+ placeholder={model.denyPlaceholder}
48
45
  />
49
46
  ) : undefined
50
47
 
51
- const options = plan
52
- ? [
53
- { key: 'allow', label: 'Approve plan' },
54
- { key: 'deny', label: 'Keep planning - tell it what to change', detail: reasonInput },
55
- { key: 'stop', label: 'No, and stop the turn', danger: true },
56
- ]
57
- : [
58
- { key: 'allow', label: 'Yes' },
59
- { key: 'deny', label: 'No, and tell the agent what to do differently', detail: reasonInput },
60
- { key: 'stop', label: 'No, and stop the turn', danger: true },
61
- ]
48
+ const options = model.choices.map((choice) => ({
49
+ key: choice.key,
50
+ label: choice.option,
51
+ danger: choice.danger,
52
+ detail: choice.key === 'deny' ? reasonInput : undefined,
53
+ }))
62
54
 
63
55
  return (
64
56
  <div
@@ -82,20 +74,30 @@ export function TerminalPermissionPrompt({ request, onApprove, onDeny, className
82
74
  <Blank />
83
75
  <Rule dashed />
84
76
  </>
77
+ ) : shell ? (
78
+ <>
79
+ {shellRequestLines(shell).map((line, index) => (
80
+ <Row key={index} bold>
81
+ {line}
82
+ </Row>
83
+ ))}
84
+ <Blank />
85
+ <Rule dashed />
86
+ </>
85
87
  ) : patch ? (
86
88
  <>
87
89
  <TerminalDiff patch={patch} />
88
90
  <Blank />
89
91
  <Rule dashed />
90
92
  </>
91
- ) : request.description ? (
93
+ ) : model.description ? (
92
94
  <>
93
- <Row tone="dim">{request.description}</Row>
95
+ <Row tone="dim">{model.description}</Row>
94
96
  <Blank />
95
97
  </>
96
98
  ) : null}
97
- {request.decisionReason ? <Row tone="faint">{request.decisionReason}</Row> : null}
98
- <Row>{plan ? 'Ready to implement this plan?' : (request.title ?? `Do you want to proceed?`)}</Row>
99
+ {model.decisionReason ? <Row tone="faint">{model.decisionReason}</Row> : null}
100
+ <Row>{model.question}</Row>
99
101
  <Choices
100
102
  label={heading}
101
103
  options={options}
@@ -103,11 +105,12 @@ export function TerminalPermissionPrompt({ request, onApprove, onDeny, className
103
105
  onFocus={setFocused}
104
106
  active={!denying}
105
107
  onChoose={(index) => {
106
- if (index === 0) {
108
+ const key = model.choices[index]?.key
109
+ if (key === 'allow') {
107
110
  onApprove(request.id)
108
- } else if (index === 1) {
111
+ } else if (key === 'deny') {
109
112
  setDenying(true)
110
- } else {
113
+ } else if (key === 'stop') {
111
114
  deny(true)
112
115
  }
113
116
  }}
@@ -1,21 +1,10 @@
1
1
  import { useState } from 'react'
2
2
  import type { PermissionRequest, UserQuestion } from '@workerdeck/protocol'
3
3
  import { parseUserQuestions } from '../agent/QuestionPrompt.tsx'
4
+ import { answerFor, useQuestionAnswers, type Selection } from '../../lib/question-answers.ts'
4
5
  import { Box, Choices, Hint, PromptInput, Rule, TabStrip, type Choice } from './prompt.tsx'
5
6
  import { Blank, Ink, Row } from './row.tsx'
6
7
 
7
- type Selection = { labels: string[]; other: string; otherActive: boolean }
8
-
9
- const EMPTY: Selection = { labels: [], other: '', otherActive: false }
10
-
11
- function answerFor(selection: Selection): string {
12
- const parts = [...selection.labels]
13
- if (selection.otherActive && selection.other.trim()) {
14
- parts.push(selection.other.trim())
15
- }
16
- return parts.join(', ')
17
- }
18
-
19
8
  export interface TerminalQuestionPromptProps {
20
9
  request: PermissionRequest
21
10
  onAnswer: (requestId: string, updatedInput: Record<string, unknown>) => void
@@ -25,43 +14,15 @@ export interface TerminalQuestionPromptProps {
25
14
 
26
15
  export function TerminalQuestionPrompt({ request, onAnswer, onDismiss, className }: TerminalQuestionPromptProps) {
27
16
  const questions = parseUserQuestions(request.input)
28
- const [selections, setSelections] = useState<Selection[]>(() => questions.map(() => EMPTY))
17
+ const { selectionFor, answered, complete, update, toggle, toggleOther, answers } = useQuestionAnswers(questions)
29
18
  const [cursors, setCursors] = useState<number[]>(() => questions.map(() => 0))
30
19
  const [tab, setTab] = useState(0)
31
20
  const [reviewCursor, setReviewCursor] = useState(0)
32
21
 
33
22
  const review = tab >= questions.length
34
- const selection = selections[tab] ?? EMPTY
35
- const answered = (index: number) => answerFor(selections[index] ?? EMPTY) !== ''
36
- const complete = questions.every((_, index) => answered(index))
37
-
38
- const update = (index: number, patch: Partial<Selection>) =>
39
- setSelections((prev) => prev.map((s, i) => (i === index ? { ...s, ...patch } : s)))
40
-
41
- const toggle = (index: number, label: string, multiSelect: boolean) => {
42
- setSelections((prev) =>
43
- prev.map((current, i) => {
44
- if (i !== index) {
45
- return current
46
- }
47
- if (!multiSelect) {
48
- return { ...current, labels: [label], otherActive: false }
49
- }
50
- return {
51
- ...current,
52
- labels: current.labels.includes(label) ? current.labels.filter((l) => l !== label) : [...current.labels, label],
53
- }
54
- }),
55
- )
56
- }
23
+ const selection = selectionFor(tab)
57
24
 
58
- const submit = () => {
59
- const answers: Record<string, string> = {}
60
- questions.forEach((question, index) => {
61
- answers[question.question] = answerFor(selections[index] ?? EMPTY)
62
- })
63
- onAnswer(request.id, { ...request.input, answers })
64
- }
25
+ const submit = () => onAnswer(request.id, { ...request.input, answers: answers() })
65
26
 
66
27
  const dismiss = () => onDismiss(request.id, 'Question dismissed by user')
67
28
 
@@ -100,7 +61,7 @@ export function TerminalQuestionPrompt({ request, onAnswer, onDismiss, className
100
61
  {review ? (
101
62
  <ReviewStep
102
63
  questions={questions}
103
- answers={questions.map((_, index) => answerFor(selections[index] ?? EMPTY))}
64
+ answers={questions.map((_, index) => answerFor(selectionFor(index)))}
104
65
  complete={complete}
105
66
  cursor={reviewCursor}
106
67
  onCursor={setReviewCursor}
@@ -115,8 +76,9 @@ export function TerminalQuestionPrompt({ request, onAnswer, onDismiss, className
115
76
  selection={selection}
116
77
  cursor={cursors[tab] ?? 0}
117
78
  onCursor={(next) => setCursors((prev) => prev.map((c, i) => (i === tab ? next : c)))}
118
- onToggle={(label) => toggle(tab, label, questions[tab]!.multiSelect === true)}
79
+ onToggle={(label) => toggle(tab, label)}
119
80
  onOther={(patch) => update(tab, patch)}
81
+ onToggleOther={() => toggleOther(tab)}
120
82
  onAdvance={() => move(1)}
121
83
  onDismiss={dismiss}
122
84
  />
@@ -138,6 +100,7 @@ function QuestionStep({
138
100
  onCursor,
139
101
  onToggle,
140
102
  onOther,
103
+ onToggleOther,
141
104
  onAdvance,
142
105
  onDismiss,
143
106
  }: {
@@ -147,6 +110,7 @@ function QuestionStep({
147
110
  onCursor: (index: number) => void
148
111
  onToggle: (label: string) => void
149
112
  onOther: (patch: Partial<Selection>) => void
113
+ onToggleOther: () => void
150
114
  onAdvance: () => void
151
115
  onDismiss: () => void
152
116
  }) {
@@ -214,7 +178,7 @@ function QuestionStep({
214
178
  return
215
179
  }
216
180
  if (index === question.options.length) {
217
- onOther({ otherActive: !selection.otherActive })
181
+ onToggleOther()
218
182
  return
219
183
  }
220
184
  if (multiSelect && index === question.options.length + 1) {
@@ -1,8 +1,7 @@
1
- import { Fragment, useEffect, useMemo, useState } from 'react'
2
- import type { TranscriptItem, TranscriptState } from '@workerdeck/react'
1
+ import { Fragment, useMemo, useState } from 'react'
2
+ import type { TranscriptItem } from '@workerdeck/react'
3
3
  import { cn } from '../../lib/utils.ts'
4
- import type { TerminalAffordances } from './affordances.tsx'
5
- import { OpenSubagentAction, WithActions } from './affordances.tsx'
4
+ import { ActionPlacementProvider, OpenSubagentAction, WithActions } from './affordances.tsx'
6
5
  import {
7
6
  AssistantRow,
8
7
  CompactionRow,
@@ -15,30 +14,26 @@ import {
15
14
  ToolRow,
16
15
  TurnResultRow,
17
16
  UserRow,
18
- WorkingRow,
19
17
  } from './items.tsx'
20
- import { blockNeedsBlank, taskChildItems, terminalBlocks, type TaskBlock } from './blocks.ts'
18
+ import { blockNeedsBlank, taskChildItems, type TaskBlock } from './blocks.ts'
21
19
  import { usePulse } from '../agent/pulse.tsx'
22
- import { PeerNamesProvider } from './peer-names.tsx'
23
20
  import { Pressable, useRevealOnOpen } from './press.tsx'
24
21
  import { isPeerSend, taskBrief, taskBusy, taskFailed, taskSummary } from './tool-run.ts'
25
22
  import { BRIEF_LINES } from './height.ts'
26
23
  import { Blank, Row } from './row.tsx'
27
- import { TerminalSurface } from './surface.tsx'
28
24
 
29
- export interface TerminalTranscriptProps {
30
- state: TranscriptState
25
+ export function TerminalItemView({
26
+ item,
27
+ fileUrl,
28
+ attachmentUrl,
29
+ }: {
30
+ item: TranscriptItem
31
31
  fileUrl?: (path: string) => string
32
- fontSize?: number
33
- lineHeight?: number
34
- affordances?: TerminalAffordances | boolean
35
- className?: string
36
- }
37
-
38
- export function TerminalItemView({ item, fileUrl }: { item: TranscriptItem; fileUrl?: (path: string) => string }) {
32
+ attachmentUrl?: (attachmentId: string) => string
33
+ }) {
39
34
  switch (item.kind) {
40
35
  case 'user': {
41
- return <UserRow item={item} />
36
+ return <UserRow item={item} attachmentUrl={attachmentUrl} />
42
37
  }
43
38
  case 'assistant_text': {
44
39
  return <AssistantRow item={item} />
@@ -122,12 +117,16 @@ export function TaskRow({
122
117
  {open ? (
123
118
  <div className="term-nested">
124
119
  {brief ? <BriefRow text={brief} terminal /> : null}
125
- {block.children.map((leaf, index) => (
126
- <Fragment key={leaf.key}>
127
- {index > 0 && blockNeedsBlank(block.children[index - 1]!, leaf) ? <Blank /> : null}
128
- {'run' in leaf ? <RunRow items={leaf.run} /> : <TerminalItemView item={leaf.item} fileUrl={fileUrl} />}
129
- </Fragment>
130
- ))}
120
+ {block.children.map((leaf, index) => {
121
+ const next = block.children[index + 1]
122
+ const view = 'run' in leaf ? <RunRow items={leaf.run} /> : <TerminalItemView item={leaf.item} fileUrl={fileUrl} />
123
+ return (
124
+ <Fragment key={leaf.key}>
125
+ {index > 0 && blockNeedsBlank(block.children[index - 1]!, leaf) ? <Blank /> : null}
126
+ {next && !blockNeedsBlank(leaf, next) ? <ActionPlacementProvider value="inline">{view}</ActionPlacementProvider> : view}
127
+ </Fragment>
128
+ )
129
+ })}
131
130
  </div>
132
131
  ) : null}
133
132
  </div>
@@ -138,68 +137,3 @@ export function TaskRow({
138
137
  <WithActions actions={<OpenSubagentAction onOpen={() => onOpenSubagent(block.task.id)} />}>{row}</WithActions>
139
138
  )
140
139
  }
141
-
142
- function useRunStart(status: TranscriptState['status']): number | undefined {
143
- const running = status === 'running' || status === 'starting'
144
- const [startedAt, setStartedAt] = useState<number | undefined>(undefined)
145
- useEffect(() => {
146
- setStartedAt((previous) => (running ? (previous ?? Date.now()) : undefined))
147
- }, [running])
148
- return running ? startedAt : undefined
149
- }
150
-
151
- function working(state: TranscriptState): boolean {
152
- if (state.status !== 'running' && state.status !== 'starting') {
153
- return false
154
- }
155
- const last = state.items.at(-1)
156
- if (!last) {
157
- return true
158
- }
159
- if (last.kind === 'assistant_text' && last.streaming) {
160
- return false
161
- }
162
- if (last.kind === 'thinking' && last.id === 'streaming-thinking') {
163
- return false
164
- }
165
- return true
166
- }
167
-
168
- export function TerminalTranscript({ state, fileUrl, fontSize, lineHeight, affordances, className }: TerminalTranscriptProps) {
169
- const runStartedAt = useRunStart(state.status)
170
- const blocks = useMemo(() => terminalBlocks(state.items), [state.items])
171
- return (
172
- <TerminalSurface
173
- fontSize={fontSize}
174
- lineHeight={lineHeight}
175
- affordances={affordances}
176
- bleed="1ch"
177
- className={cn('term-transcript', className)}
178
- >
179
- <PeerNamesProvider items={state.items}>
180
- {blocks.map((block, index) => (
181
- <Fragment key={block.key}>
182
- {index > 0 && blockNeedsBlank(blocks[index - 1]!, block) ? <Blank /> : null}
183
- {'run' in block ? (
184
- <RunRow items={block.run} />
185
- ) : 'item' in block ? (
186
- <TerminalItemView item={block.item} fileUrl={fileUrl} />
187
- ) : (
188
- <TaskRow block={block} fileUrl={fileUrl} />
189
- )}
190
- </Fragment>
191
- ))}
192
- {working(state) ? (
193
- <>
194
- {state.items.length > 0 ? <Blank /> : null}
195
- <WorkingRow
196
- label={state.status === 'starting' ? 'Starting…' : 'Working…'}
197
- startedAt={runStartedAt}
198
- tokens={state.contextUsage?.totalTokens}
199
- />
200
- </>
201
- ) : null}
202
- </PeerNamesProvider>
203
- </TerminalSurface>
204
- )
205
- }
@@ -1,14 +1,15 @@
1
+ import { Bot, BotOff, Check, Copy, Maximize2, SquareTerminal, Star, X, type LucideIcon } from 'lucide-react'
1
2
  import { createContext, useContext, useState, type ReactNode } from 'react'
2
3
  import { copyText } from '../../lib/clipboard.ts'
3
- import { SHELL_KILL_GLYPH } from './shell-row.ts'
4
4
  import { cn } from '../../lib/utils.ts'
5
5
 
6
6
  export type TerminalAffordances = {
7
7
  hover?: boolean
8
8
  actions?: boolean
9
+ labels?: boolean
9
10
  }
10
11
 
11
- const DEFAULTS: Required<TerminalAffordances> = { hover: true, actions: true }
12
+ const DEFAULTS: Required<TerminalAffordances> = { hover: true, actions: true, labels: false }
12
13
 
13
14
  const AffordanceContext = createContext<Required<TerminalAffordances>>(DEFAULTS)
14
15
 
@@ -18,7 +19,7 @@ export function useAffordances(): Required<TerminalAffordances> {
18
19
 
19
20
  export function resolveAffordances(value: TerminalAffordances | boolean | undefined): Required<TerminalAffordances> {
20
21
  if (value === false) {
21
- return { hover: false, actions: false }
22
+ return { hover: false, actions: false, labels: false }
22
23
  }
23
24
  if (value === true || value === undefined) {
24
25
  return DEFAULTS
@@ -30,22 +31,79 @@ export function AffordanceProvider({ value, children }: { value: Required<Termin
30
31
  return <AffordanceContext.Provider value={value}>{children}</AffordanceContext.Provider>
31
32
  }
32
33
 
33
- export function WithActions({ actions, children, className }: { actions: ReactNode; children: ReactNode; className?: string }) {
34
+ export type ActionPlacement = 'below' | 'inline'
35
+
36
+ const PlacementContext = createContext<ActionPlacement>('below')
37
+
38
+ export function ActionPlacementProvider({ value, children }: { value: ActionPlacement; children: ReactNode }) {
39
+ return <PlacementContext.Provider value={value}>{children}</PlacementContext.Provider>
40
+ }
41
+
42
+ export function WithActions({
43
+ actions,
44
+ children,
45
+ className,
46
+ placement,
47
+ }: {
48
+ actions: ReactNode
49
+ children: ReactNode
50
+ className?: string
51
+ placement?: ActionPlacement
52
+ }) {
34
53
  const { actions: enabled } = useAffordances()
54
+ const inherited = useContext(PlacementContext)
35
55
  if (!enabled) {
36
56
  return <>{children}</>
37
57
  }
38
58
  return (
39
- <div className={cn('term-hoverable', className)}>
40
- {children}
59
+ <div className={cn('term-hoverable', className)} data-placement={placement ?? inherited}>
60
+ <PlacementContext.Provider value="below">{children}</PlacementContext.Provider>
41
61
  <div className="term-actions">{actions}</div>
42
62
  </div>
43
63
  )
44
64
  }
45
65
 
66
+ type Tone = 'magenta' | 'yellow' | 'red' | 'danger'
67
+
68
+ function ActionButton({
69
+ icon: Icon,
70
+ word,
71
+ label,
72
+ tone,
73
+ pressed,
74
+ filled,
75
+ onPress,
76
+ }: {
77
+ icon: LucideIcon
78
+ word: string
79
+ label: string
80
+ tone?: Tone
81
+ pressed?: boolean
82
+ filled?: boolean
83
+ onPress: () => void
84
+ }) {
85
+ const { labels } = useAffordances()
86
+ return (
87
+ <button
88
+ type="button"
89
+ className="term-action"
90
+ title={label}
91
+ aria-label={label}
92
+ aria-pressed={pressed}
93
+ data-tone={tone}
94
+ onClick={(event) => {
95
+ event.stopPropagation()
96
+ onPress()
97
+ }}
98
+ >
99
+ <Icon aria-hidden className="term-action-icon" fill={filled ? 'currentColor' : 'none'} />
100
+ {labels ? <span className="term-action-word">{word}</span> : null}
101
+ </button>
102
+ )
103
+ }
104
+
46
105
  // Bookmarks are a host concern (which items, where they persist); the transcript only needs
47
- // membership and a toggle. A missing provider renders no action at all, so embeddings that
48
- // never wire bookmarks pay nothing - the same contract AffordanceContext has.
106
+ // membership and a toggle. A missing provider renders no action at all.
49
107
  export type BookmarkHandle = {
50
108
  has: (itemId: string) => boolean
51
109
  toggle: (itemId: string) => void
@@ -63,87 +121,52 @@ export function BookmarkAction({ id }: { id: string }) {
63
121
  return null
64
122
  }
65
123
  const active = handle.has(id)
66
- const label = active ? 'Remove bookmark' : 'Bookmark'
67
124
  return (
68
- <button
69
- type="button"
70
- className="term-action"
71
- title={label}
72
- aria-label={label}
73
- aria-pressed={active}
74
- onClick={(event) => {
75
- event.stopPropagation()
76
- handle.toggle(id)
77
- }}
78
- >
79
- {active ? '★' : '☆'}
80
- </button>
125
+ <ActionButton
126
+ icon={Star}
127
+ filled
128
+ word={active ? 'bookmarked' : 'bookmark'}
129
+ label={active ? 'Remove bookmark' : 'Bookmark'}
130
+ tone={active ? 'yellow' : undefined}
131
+ pressed={active}
132
+ onPress={() => handle.toggle(id)}
133
+ />
81
134
  )
82
135
  }
83
136
 
84
137
  export function OpenSubagentAction({ onOpen, label = 'Open sub-agent' }: { onOpen: () => void; label?: string }) {
85
- return (
86
- <button
87
- type="button"
88
- className="term-action"
89
- title={label}
90
- aria-label={label}
91
- onClick={(event) => {
92
- event.stopPropagation()
93
- onOpen()
94
- }}
95
- >
96
- ⤢
97
- </button>
98
- )
138
+ return <ActionButton icon={Maximize2} word="open" label={label} onPress={onOpen} />
99
139
  }
100
140
 
101
141
  export function OpenShellAction({ onOpen, label = 'Open terminal' }: { onOpen: () => void; label?: string }) {
102
- return (
103
- <button
104
- type="button"
105
- className="term-action"
106
- title={label}
107
- aria-label={label}
108
- data-tone="magenta"
109
- onClick={(event) => {
110
- event.stopPropagation()
111
- onOpen()
112
- }}
113
- >
114
- ⤢
115
- </button>
116
- )
142
+ return <ActionButton icon={SquareTerminal} word="enter shell" label={label} tone="magenta" onPress={onOpen} />
117
143
  }
118
144
 
119
145
  export function KillShellAction({ onKill, label = 'Kill this shell' }: { onKill: () => void; label?: string }) {
146
+ return <ActionButton icon={X} word="kill" label={label} tone="danger" onPress={onKill} />
147
+ }
148
+
149
+ export function AgentWriteAction({ granted, label, onToggle }: { granted: boolean; label: string; onToggle: () => void }) {
120
150
  return (
121
- <button
122
- type="button"
123
- className="term-action"
124
- title={label}
125
- aria-label={label}
126
- data-tone="red"
127
- onClick={(event) => {
128
- event.stopPropagation()
129
- onKill()
130
- }}
131
- >
132
- {SHELL_KILL_GLYPH}
133
- </button>
151
+ <ActionButton
152
+ icon={granted ? Bot : BotOff}
153
+ word={granted ? 'agent can type' : 'agent read-only'}
154
+ label={label}
155
+ tone={granted ? 'yellow' : undefined}
156
+ pressed={granted}
157
+ onPress={onToggle}
158
+ />
134
159
  )
135
160
  }
136
161
 
137
162
  export function CopyAction({ text, label = 'Copy' }: { text: string; label?: string }) {
138
163
  const [copied, setCopied] = useState(false)
139
164
  return (
140
- <button
141
- type="button"
142
- className="term-action"
143
- title={label}
144
- aria-label={label}
145
- onClick={(event) => {
146
- event.stopPropagation()
165
+ <ActionButton
166
+ icon={copied ? Check : Copy}
167
+ word={copied ? 'copied' : 'copy'}
168
+ label={label}
169
+ onPress={() => {
147
170
  void copyText(text).then((ok) => {
148
171
  if (!ok) {
149
172
  return
@@ -152,8 +175,11 @@ export function CopyAction({ text, label = 'Copy' }: { text: string; label?: str
152
175
  setTimeout(() => setCopied(false), 1200)
153
176
  })
154
177
  }}
155
- >
156
- {copied ? '✓' : '⧉'}
157
- </button>
178
+ />
158
179
  )
159
180
  }
181
+
182
+ export function AgentWriteIcon({ granted, className }: { granted: boolean; className?: string }) {
183
+ const Icon = granted ? Bot : BotOff
184
+ return <Icon aria-hidden className={className} />
185
+ }