@workerdeck/ui 3.0.1 → 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 (55) hide show
  1. package/build/{SessionPanel-C1bU4yTM.d.mts → SessionPanel-BNNc8grK.d.mts} +9 -7
  2. package/build/index.d.mts +153 -45
  3. package/build/index.mjs +501 -226
  4. package/build/index.mjs.map +1 -1
  5. package/build/scoped.css +15 -6
  6. package/build/{use-path-links-BEWxt0ZM.mjs → use-path-links-C6Fh5zdx.mjs} +1926 -2086
  7. package/build/use-path-links-C6Fh5zdx.mjs.map +1 -0
  8. package/build/workspace.d.mts +1 -1
  9. package/build/workspace.mjs +1 -1
  10. package/package.json +4 -4
  11. package/src/components/agent/ApprovalPrompts.tsx +87 -0
  12. package/src/components/agent/Composer.tsx +25 -47
  13. package/src/components/agent/ContextDialog.tsx +75 -67
  14. package/src/components/agent/FrameStrip.tsx +74 -0
  15. package/src/components/agent/HostFilesDialog.tsx +3 -2
  16. package/src/components/agent/McpDialog.tsx +112 -78
  17. package/src/components/agent/PermissionPrompt.tsx +13 -22
  18. package/src/components/agent/QuestionPrompt.tsx +6 -41
  19. package/src/components/agent/Scrubber.tsx +6 -75
  20. package/src/components/agent/SessionBrowser.tsx +125 -214
  21. package/src/components/agent/SessionFilters.tsx +266 -0
  22. package/src/components/agent/SessionInfoDialog.tsx +47 -63
  23. package/src/components/agent/SessionItem.tsx +27 -7
  24. package/src/components/agent/SessionPanel.tsx +244 -488
  25. package/src/components/agent/SessionSteps.tsx +54 -11
  26. package/src/components/agent/ShellStrip.tsx +27 -43
  27. package/src/components/agent/SubagentStrip.tsx +13 -30
  28. package/src/components/agent/Transcript.tsx +13 -0
  29. package/src/components/agent/TranscriptItemView.tsx +6 -36
  30. package/src/components/agent/composer-commands.ts +80 -1
  31. package/src/components/agent/markdown-image.tsx +49 -1
  32. package/src/components/agent/scrubber-marks.ts +33 -11
  33. package/src/components/agent/scrubber-peek.tsx +122 -0
  34. package/src/components/agent/session-panel-providers.tsx +41 -0
  35. package/src/components/agent/shell-actions.tsx +36 -1
  36. package/src/components/agent/use-catch-up.ts +20 -0
  37. package/src/components/agent/use-frame.ts +88 -0
  38. package/src/components/agent/use-host-image.ts +81 -0
  39. package/src/components/agent/use-shell-frame.ts +12 -70
  40. package/src/components/agent/use-subagent-frame.ts +19 -75
  41. package/src/components/prompt-area/prompt-area-engine.ts +21 -20
  42. package/src/components/prompt-area/prompt-area-list-ops.ts +12 -15
  43. package/src/components/terminal/PermissionPrompt.tsx +19 -32
  44. package/src/components/terminal/QuestionPrompt.tsx +10 -46
  45. package/src/components/terminal/TerminalTranscript.tsx +3 -86
  46. package/src/components/terminal/items.tsx +5 -43
  47. package/src/components/terminal/scrubber.tsx +25 -272
  48. package/src/components/ui/Popover.tsx +29 -0
  49. package/src/components/ui/Select.tsx +25 -0
  50. package/src/index.ts +46 -8
  51. package/src/lib/file-link.ts +3 -16
  52. package/src/lib/permission-prompt.ts +57 -0
  53. package/src/lib/question-answers.ts +65 -0
  54. package/build/use-path-links-BEWxt0ZM.mjs.map +0 -1
  55. package/src/components/prompt-area/use-markdown-mode.ts +0 -103
@@ -210,8 +210,10 @@ export function resolveChip(
210
210
 
211
211
  const newSegments: Segment[] = []
212
212
  let offset = 0
213
+ let insertedChip: ChipSegment | undefined
213
214
 
214
- for (const seg of segments) {
215
+ // Merged first: the trigger range is typed text, so it then lies in exactly one text segment.
216
+ for (const seg of mergeAdjacentTextSegments(segments)) {
215
217
  if (seg.type === 'chip') {
216
218
  const chipText = chipPlainText(seg)
217
219
  const chipStart = offset
@@ -252,6 +254,7 @@ export function resolveChip(
252
254
  ...(chip.autoResolved ? { autoResolved: true } : {}),
253
255
  }
254
256
  newSegments.push(newChip)
257
+ insertedChip = newChip
255
258
 
256
259
  // Add the trailing text (a space by default) after the chip, then any remaining text
257
260
  if (afterText) {
@@ -266,23 +269,10 @@ export function resolveChip(
266
269
  }
267
270
 
268
271
  const merged = mergeAdjacentTextSegments(newSegments)
269
-
270
- // Cursor should be placed after the chip + trailing space.
271
- // Find the *last* matching chip so duplicates resolve correctly.
272
- let lastChipEndOffset = -1
273
- let runningOffset = 0
274
- for (const seg of merged) {
275
- if (seg.type === 'text') {
276
- runningOffset += seg.text.length
277
- } else {
278
- runningOffset += chipPlainText(seg).length
279
- if (seg.value === chip.value && seg.displayText === chip.displayText && seg.trigger === activeTrigger.config.char) {
280
- lastChipEndOffset = runningOffset
281
- }
282
- }
283
- }
284
- // The caret lands after the chip and its trailing text
285
- const cursorOffset = lastChipEndOffset === -1 ? runningOffset : lastChipEndOffset + trailingText.length
272
+ // Everything before the trigger is untouched, so the new chip starts exactly where the trigger did.
273
+ const cursorOffset = insertedChip
274
+ ? triggerStart + chipPlainText(insertedChip).length + trailingText.length
275
+ : segmentsToPlainText(merged).length
286
276
 
287
277
  return { segments: merged, cursorOffset }
288
278
  }
@@ -309,7 +299,7 @@ export function resolveText(
309
299
  let offset = 0
310
300
  let cursorOffset = triggerStart + text.length
311
301
 
312
- for (const seg of segments) {
302
+ for (const seg of mergeAdjacentTextSegments(segments)) {
313
303
  if (seg.type === 'chip') {
314
304
  const chipEnd = offset + chipPlainText(seg).length
315
305
  // A trigger range can never overlap a chip - chips are atomic - so a chip
@@ -510,8 +500,18 @@ export function replaceTextRange(segments: Segment[], start: number, end: number
510
500
  inserted = true
511
501
  }
512
502
 
513
- if (chipEnd <= start || chipStart >= end) {
503
+ if (start === end) {
514
504
  newSegments.push(seg)
505
+ // A collapsed insertion can never split an atomic chip, so one inside it lands just after.
506
+ if (!inserted && chipStart < start && start < chipEnd) {
507
+ newSegments.push({ type: 'text', text: replacement })
508
+ inserted = true
509
+ }
510
+ } else if (chipEnd <= start || chipStart >= end) {
511
+ newSegments.push(seg)
512
+ } else if (!inserted) {
513
+ newSegments.push({ type: 'text', text: replacement })
514
+ inserted = true
515
515
  }
516
516
  offset = chipEnd
517
517
  } else {
@@ -709,6 +709,7 @@ export function segmentsEqual(a: Segment[], b: Segment[]): boolean {
709
709
  sa.trigger !== sb.trigger ||
710
710
  sa.value !== sb.value ||
711
711
  sa.displayText !== sb.displayText ||
712
+ sa.sigil !== sb.sigil ||
712
713
  sa.autoResolved !== sb.autoResolved
713
714
  ) {
714
715
  return false
@@ -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
  }
@@ -1,8 +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'
5
- import { shellRequestLines, shellRequestPayload, shellRequestTitle } from '../../lib/shell-request.ts'
4
+ import { permissionPromptModel } from '../../lib/permission-prompt.ts'
5
+ import { shellRequestLines } from '../../lib/shell-request.ts'
6
6
  import { TerminalDiff, previewPatch } from './diff.tsx'
7
7
  import { TerminalMarkdown } from './markdown.tsx'
8
8
  import { Choices, Hint, PromptInput, PromptTitle, Rule } from './prompt.tsx'
@@ -29,15 +29,10 @@ export function TerminalPermissionPrompt({ request, onApprove, onDeny, className
29
29
  setDenying(false)
30
30
  }
31
31
 
32
- const plan = planFromRequest(request)
33
- const shell = plan ? undefined : shellRequestPayload(request)
32
+ const model = permissionPromptModel(request)
33
+ const { plan, shell, heading } = model
34
34
  const patch = plan || shell ? undefined : previewPatch(request.input)
35
35
  const summary = plan || shell ? '' : toolInputPreview(request.input)
36
- const heading = plan
37
- ? 'Plan ready for review'
38
- : shell
39
- ? shellRequestTitle(shell)
40
- : (request.displayName ?? request.title ?? 'Permission needed')
41
36
  const subject = patch?.path ?? (summary || undefined)
42
37
 
43
38
  const reasonInput = denying ? (
@@ -46,25 +41,16 @@ export function TerminalPermissionPrompt({ request, onApprove, onDeny, className
46
41
  onChange={setReason}
47
42
  onSubmit={() => deny(false)}
48
43
  onCancel={() => setDenying(false)}
49
- placeholder={
50
- plan
51
- ? 'What should change? (optional) - the agent keeps planning and reads this'
52
- : 'Reason (optional) - the agent reads this and can try something else'
53
- }
44
+ placeholder={model.denyPlaceholder}
54
45
  />
55
46
  ) : undefined
56
47
 
57
- const options = plan
58
- ? [
59
- { key: 'allow', label: 'Approve plan' },
60
- { key: 'deny', label: 'Keep planning - tell it what to change', detail: reasonInput },
61
- { key: 'stop', label: 'No, and stop the turn', danger: true },
62
- ]
63
- : [
64
- { key: 'allow', label: 'Yes' },
65
- { key: 'deny', label: 'No, and tell the agent what to do differently', detail: reasonInput },
66
- { key: 'stop', label: 'No, and stop the turn', danger: true },
67
- ]
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
+ }))
68
54
 
69
55
  return (
70
56
  <div
@@ -104,14 +90,14 @@ export function TerminalPermissionPrompt({ request, onApprove, onDeny, className
104
90
  <Blank />
105
91
  <Rule dashed />
106
92
  </>
107
- ) : request.description ? (
93
+ ) : model.description ? (
108
94
  <>
109
- <Row tone="dim">{request.description}</Row>
95
+ <Row tone="dim">{model.description}</Row>
110
96
  <Blank />
111
97
  </>
112
98
  ) : null}
113
- {request.decisionReason ? <Row tone="faint">{request.decisionReason}</Row> : null}
114
- <Row>{plan ? 'Ready to implement this plan?' : shell ? 'Do you want to let it?' : (request.title ?? `Do you want to proceed?`)}</Row>
99
+ {model.decisionReason ? <Row tone="faint">{model.decisionReason}</Row> : null}
100
+ <Row>{model.question}</Row>
115
101
  <Choices
116
102
  label={heading}
117
103
  options={options}
@@ -119,11 +105,12 @@ export function TerminalPermissionPrompt({ request, onApprove, onDeny, className
119
105
  onFocus={setFocused}
120
106
  active={!denying}
121
107
  onChoose={(index) => {
122
- if (index === 0) {
108
+ const key = model.choices[index]?.key
109
+ if (key === 'allow') {
123
110
  onApprove(request.id)
124
- } else if (index === 1) {
111
+ } else if (key === 'deny') {
125
112
  setDenying(true)
126
- } else {
113
+ } else if (key === 'stop') {
127
114
  deny(true)
128
115
  }
129
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,7 +1,6 @@
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
4
  import { ActionPlacementProvider, OpenSubagentAction, WithActions } from './affordances.tsx'
6
5
  import {
7
6
  AssistantRow,
@@ -15,25 +14,13 @@ 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
-
29
- export interface TerminalTranscriptProps {
30
- state: TranscriptState
31
- fileUrl?: (path: string) => string
32
- fontSize?: number
33
- lineHeight?: number
34
- affordances?: TerminalAffordances | boolean
35
- className?: string
36
- }
37
24
 
38
25
  export function TerminalItemView({
39
26
  item,
@@ -150,73 +137,3 @@ export function TaskRow({
150
137
  <WithActions actions={<OpenSubagentAction onOpen={() => onOpenSubagent(block.task.id)} />}>{row}</WithActions>
151
138
  )
152
139
  }
153
-
154
- function useRunStart(status: TranscriptState['status']): number | undefined {
155
- const running = status === 'running' || status === 'starting'
156
- const [startedAt, setStartedAt] = useState<number | undefined>(undefined)
157
- useEffect(() => {
158
- setStartedAt((previous) => (running ? (previous ?? Date.now()) : undefined))
159
- }, [running])
160
- return running ? startedAt : undefined
161
- }
162
-
163
- function working(state: TranscriptState): boolean {
164
- if (state.status !== 'running' && state.status !== 'starting') {
165
- return false
166
- }
167
- const last = state.items.at(-1)
168
- if (!last) {
169
- return true
170
- }
171
- if (last.kind === 'assistant_text' && last.streaming) {
172
- return false
173
- }
174
- if (last.kind === 'thinking' && last.id === 'streaming-thinking') {
175
- return false
176
- }
177
- return true
178
- }
179
-
180
- export function TerminalTranscript({ state, fileUrl, fontSize, lineHeight, affordances, className }: TerminalTranscriptProps) {
181
- const runStartedAt = useRunStart(state.status)
182
- const blocks = useMemo(() => terminalBlocks(state.items), [state.items])
183
- return (
184
- <TerminalSurface
185
- fontSize={fontSize}
186
- lineHeight={lineHeight}
187
- affordances={affordances}
188
- bleed="1ch"
189
- className={cn('term-transcript', className)}
190
- >
191
- <PeerNamesProvider items={state.items}>
192
- {blocks.map((block, index) => {
193
- const next = blocks[index + 1]
194
- return (
195
- <Fragment key={block.key}>
196
- {index > 0 && blockNeedsBlank(blocks[index - 1]!, block) ? <Blank /> : null}
197
- <ActionPlacementProvider value={next && !blockNeedsBlank(block, next) ? 'inline' : 'below'}>
198
- {'run' in block ? (
199
- <RunRow items={block.run} />
200
- ) : 'item' in block ? (
201
- <TerminalItemView item={block.item} fileUrl={fileUrl} />
202
- ) : (
203
- <TaskRow block={block} fileUrl={fileUrl} />
204
- )}
205
- </ActionPlacementProvider>
206
- </Fragment>
207
- )
208
- })}
209
- {working(state) ? (
210
- <>
211
- {state.items.length > 0 ? <Blank /> : null}
212
- <WorkingRow
213
- label={state.status === 'starting' ? 'Starting…' : 'Working…'}
214
- startedAt={runStartedAt}
215
- tokens={state.contextUsage?.totalTokens}
216
- />
217
- </>
218
- ) : null}
219
- </PeerNamesProvider>
220
- </TerminalSurface>
221
- )
222
- }
@@ -5,15 +5,7 @@ import { compactionText, formatBytes, formatCost, formatDuration, toolInputPrevi
5
5
  import { isMutatingTool } from '../../lib/tool-icon.ts'
6
6
  import { usePulse } from '../agent/pulse.tsx'
7
7
  import { PromptTokenText } from '../agent/PromptTokenText.tsx'
8
- import {
9
- ActionPlacementProvider,
10
- AgentWriteAction,
11
- BookmarkAction,
12
- CopyAction,
13
- KillShellAction,
14
- OpenShellAction,
15
- WithActions,
16
- } from './affordances.tsx'
8
+ import { ActionPlacementProvider, BookmarkAction, CopyAction, WithActions } from './affordances.tsx'
17
9
  import { TerminalDiff } from './diff.tsx'
18
10
  import { TerminalMarkdown } from './markdown.tsx'
19
11
  import { usePeerNames } from './peer-names.tsx'
@@ -26,17 +18,8 @@ import { useHostImageSrc, useToolResultImageSrc, type ToolResultImageState } fro
26
18
  import { ViewableImage, useOpenImage } from '../agent/image-viewer.tsx'
27
19
  import { isPeerSend, peerName, peerOneLine, peerSendTarget, peerSendText, planRun, runFailed, runSummary } from './tool-run.ts'
28
20
  import { todoLine, todoPreview, type TodoPreview, type TodoStatus } from './todos.ts'
29
- import { useShellActions } from '../agent/shell-actions.tsx'
30
- import {
31
- SHELL_GLYPH,
32
- shellAgentWriteLabel,
33
- shellBodyLines,
34
- shellFailed,
35
- shellFooterText,
36
- shellGrantable,
37
- shellLabel,
38
- shellStatusText,
39
- } from './shell-row.ts'
21
+ import { ShellItemActions, useShellActions, useVerifyRunning } from '../agent/shell-actions.tsx'
22
+ import { SHELL_GLYPH, shellBodyLines, shellFailed, shellFooterText, shellLabel, shellStatusText } from './shell-row.ts'
40
23
  import { type ToolCallItem } from './blocks.ts'
41
24
  import { Band, Blank, Ink, Row, type Tone } from './row.tsx'
42
25
 
@@ -471,12 +454,7 @@ export function ShellRow({ item }: { item: ShellItem }) {
471
454
  const footer = shellFooterText(item, open, lines.length)
472
455
 
473
456
  const shellId = item.shell.id
474
- const verify = actions.verify
475
- useEffect(() => {
476
- if (running) {
477
- void verify(shellId)
478
- }
479
- }, [running, shellId, verify])
457
+ useVerifyRunning(item)
480
458
 
481
459
  const press = (modifiers: PressModifiers) => {
482
460
  if ((modifiers.meta || modifiers.ctrl) && actions.open) {
@@ -494,23 +472,7 @@ export function ShellRow({ item }: { item: ShellItem }) {
494
472
 
495
473
  return (
496
474
  <div ref={reveal} className={open ? 'term-open' : undefined}>
497
- <WithActions
498
- actions={
499
- <>
500
- {actions.open ? <OpenShellAction onOpen={() => actions.open?.(shellId)} /> : null}
501
- {actions.agentWrite && shellGrantable(item.shell) ? (
502
- <AgentWriteAction
503
- granted={item.shell.agentWrite === true}
504
- label={shellAgentWriteLabel(item.shell)}
505
- onToggle={() => void actions.agentWrite?.(shellId, item.shell.agentWrite !== true)}
506
- />
507
- ) : null}
508
- {running ? <KillShellAction onKill={() => void actions.kill(shellId)} /> : null}
509
- <BookmarkAction id={item.id} />
510
- <CopyAction text={item.shell.command} label="Copy command" />
511
- </>
512
- }
513
- >
475
+ <WithActions actions={<ShellItemActions item={item} />}>
514
476
  <Pressable onPress={press} expanded={open}>
515
477
  <Row glyph={SHELL_GLYPH} glyphTone={tone} tone="fg">
516
478
  <Ink bold tone="bright">