@workerdeck/ui 2.15.0 → 3.0.1

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 (45) hide show
  1. package/README.md +6 -5
  2. package/build/{SessionPanel-BVKqPs_3.d.mts → SessionPanel-C1bU4yTM.d.mts} +11 -11
  3. package/build/index.d.mts +46 -9
  4. package/build/index.mjs +70 -43
  5. package/build/index.mjs.map +1 -1
  6. package/build/scoped.css +156 -16
  7. package/build/{use-path-links-BgWXmfAS.mjs → use-path-links-BEWxt0ZM.mjs} +1566 -852
  8. package/build/use-path-links-BEWxt0ZM.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/PermissionPrompt.tsx +16 -2
  14. package/src/components/agent/Response.tsx +8 -1
  15. package/src/components/agent/SessionBrowser.tsx +6 -0
  16. package/src/components/agent/SessionItem.tsx +3 -0
  17. package/src/components/agent/SessionPanel.tsx +28 -33
  18. package/src/components/agent/SessionSteps.tsx +46 -5
  19. package/src/components/agent/SessionWorkspace.tsx +3 -3
  20. package/src/components/agent/ShellStrip.tsx +27 -4
  21. package/src/components/agent/ShellTerminal.tsx +1 -1
  22. package/src/components/agent/ToolCallCard.tsx +18 -41
  23. package/src/components/agent/Transcript.tsx +98 -93
  24. package/src/components/agent/TranscriptItemView.tsx +102 -59
  25. package/src/components/agent/TranscriptRows.tsx +4 -2
  26. package/src/components/agent/image-viewer.tsx +326 -0
  27. package/src/components/agent/markdown-image.tsx +91 -0
  28. package/src/components/agent/shell-actions.tsx +2 -0
  29. package/src/components/agent/tool-result-image.tsx +40 -0
  30. package/src/components/agent/transcript-variant.tsx +3 -21
  31. package/src/components/prompt-area/use-prompt-area-keydown.ts +4 -2
  32. package/src/components/terminal/PermissionPrompt.tsx +20 -4
  33. package/src/components/terminal/TerminalTranscript.tsx +38 -21
  34. package/src/components/terminal/affordances.tsx +100 -74
  35. package/src/components/terminal/height.ts +48 -32
  36. package/src/components/terminal/image-box.ts +31 -0
  37. package/src/components/terminal/items.tsx +92 -14
  38. package/src/components/terminal/markdown.tsx +4 -1
  39. package/src/components/terminal/shell-row.ts +17 -3
  40. package/src/index.ts +19 -4
  41. package/src/lib/shell-request.ts +115 -0
  42. package/src/lib/tool-icon.ts +13 -1
  43. package/src/styles/terminal.css +129 -22
  44. package/src/styles/theme.css +4 -0
  45. package/build/use-path-links-BgWXmfAS.mjs.map +0 -1
@@ -11,9 +11,19 @@ import { Response } from './Response.tsx'
11
11
  import { ToolCallCard } from './ToolCallCard.tsx'
12
12
  import { Row } from '../terminal/row.tsx'
13
13
  import { TerminalItemView } from '../terminal/TerminalTranscript.tsx'
14
+ import { ViewableImage } from './image-viewer.tsx'
14
15
  import { peerLabel } from '../terminal/items.tsx'
15
16
  import { useShellActions } from './shell-actions.tsx'
16
- import { shellBodyLines, shellFailed, shellFooterText, shellLabel, shellStatusText } from '../terminal/shell-row.ts'
17
+ import { AgentWriteAction, BookmarkAction, CopyAction, KillShellAction, OpenShellAction, WithActions } from '../terminal/affordances.tsx'
18
+ import {
19
+ shellAgentWriteLabel,
20
+ shellBodyLines,
21
+ shellFailed,
22
+ shellFooterText,
23
+ shellGrantable,
24
+ shellLabel,
25
+ shellStatusText,
26
+ } from '../terminal/shell-row.ts'
17
27
 
18
28
  function TurnResultRow({ item }: { item: Extract<TranscriptItem, { kind: 'turn_result' }> }) {
19
29
  return (
@@ -67,36 +77,47 @@ export function TranscriptItemView({
67
77
  terminal?: boolean
68
78
  }) {
69
79
  if (terminal) {
70
- return <TerminalItemView item={item} fileUrl={fileUrl} />
80
+ return <TerminalItemView item={item} fileUrl={fileUrl} attachmentUrl={attachmentUrl} />
71
81
  }
72
82
  switch (item.kind) {
73
83
  case 'user': {
74
84
  return (
75
- <Message from="user">
76
- {item.origin ? <div className="wd-peer-origin text-xs text-muted-foreground">{peerLabel(item.origin)}</div> : null}
77
- {item.attachments?.length ? <SentAttachments attachments={item.attachments} attachmentUrl={attachmentUrl} /> : null}
78
- {item.text ? (
79
- <MessageContent>
80
- <PromptTokenText text={item.text} />
81
- </MessageContent>
82
- ) : null}
83
- </Message>
85
+ <WithActions actions={<BookmarkAction id={item.id} />}>
86
+ <Message from="user">
87
+ {item.origin ? <div className="wd-peer-origin text-xs text-muted-foreground">{peerLabel(item.origin)}</div> : null}
88
+ {item.attachments?.length ? <SentAttachments attachments={item.attachments} attachmentUrl={attachmentUrl} /> : null}
89
+ {item.text ? (
90
+ <MessageContent>
91
+ <PromptTokenText text={item.text} />
92
+ </MessageContent>
93
+ ) : null}
94
+ </Message>
95
+ </WithActions>
84
96
  )
85
97
  }
86
98
  case 'assistant_text': {
87
99
  return (
88
- <Message from="assistant">
89
- <MessageContent>
90
- <Response streaming={item.streaming}>{item.text}</Response>
91
- </MessageContent>
92
- </Message>
100
+ <WithActions
101
+ actions={
102
+ <>
103
+ <BookmarkAction id={item.id} />
104
+ {item.streaming ? null : <CopyAction text={item.text} label="Copy message" />}
105
+ </>
106
+ }
107
+ >
108
+ <Message from="assistant">
109
+ <MessageContent>
110
+ <Response streaming={item.streaming}>{item.text}</Response>
111
+ </MessageContent>
112
+ </Message>
113
+ </WithActions>
93
114
  )
94
115
  }
95
116
  case 'thinking': {
96
117
  return <Reasoning isStreaming={item.id === 'streaming-thinking'}>{item.text}</Reasoning>
97
118
  }
98
119
  case 'tool_call': {
99
- return <ToolCallCard item={item} hostImage={hostImage} />
120
+ return <ToolCard item={item} hostImage={hostImage} />
100
121
  }
101
122
  case 'turn_result': {
102
123
  return <TurnResultRow item={item} />
@@ -119,6 +140,29 @@ export function TranscriptItemView({
119
140
  }
120
141
  }
121
142
 
143
+ function ToolCard({
144
+ item,
145
+ hostImage,
146
+ }: {
147
+ item: Extract<TranscriptItem, { kind: 'tool_call' }>
148
+ hostImage?: (path: string) => Promise<string | undefined>
149
+ }) {
150
+ const command = (item.input as { command?: unknown } | null)?.command
151
+ const copyable = typeof command === 'string' ? command : (item.result?.text ?? '')
152
+ return (
153
+ <WithActions
154
+ actions={
155
+ <>
156
+ <BookmarkAction id={item.id} />
157
+ {copyable ? <CopyAction text={copyable} label="Copy" /> : null}
158
+ </>
159
+ }
160
+ >
161
+ <ToolCallCard item={item} hostImage={hostImage} />
162
+ </WithActions>
163
+ )
164
+ }
165
+
122
166
  function ShellCard({ item }: { item: ShellItem }) {
123
167
  const [open, setOpen] = useState(false)
124
168
  const actions = useShellActions()
@@ -136,53 +180,50 @@ function ShellCard({ item }: { item: ShellItem }) {
136
180
  }, [running, shellId, verify])
137
181
 
138
182
  return (
139
- <div data-slot="shell" className="overflow-hidden rounded-md border border-border bg-surface">
140
- <div className="flex items-center gap-2 px-3 py-1.5">
141
- <span className="font-mono text-label text-[var(--wd-shell-accent)]">$</span>
142
- <button
143
- type="button"
144
- aria-expanded={open}
145
- className="min-w-0 flex-1 truncate text-left font-mono text-label text-fg-1"
146
- onClick={() => {
147
- const next = !open
148
- setOpen(next)
149
- if (next && item.truncated && item.expanded === undefined && !item.missing) {
150
- void actions.loadOutput(shellId)
151
- }
152
- }}
153
- >
154
- {shellLabel(item)}
155
- </button>
156
- <span className={cn('shrink-0 text-label', failed ? 'text-danger' : 'text-fg-4')}>{shellStatusText(item)}</span>
157
- {actions.open ? (
158
- <button
159
- type="button"
160
- aria-label="Open terminal"
161
- title="Open terminal"
162
- className="shrink-0 text-label text-fg-3 hover:text-fg-1"
163
- onClick={() => actions.open?.(shellId)}
164
- >
165
- ⤢
166
- </button>
167
- ) : null}
168
- {running ? (
183
+ <WithActions
184
+ actions={
185
+ <>
186
+ {actions.open ? <OpenShellAction onOpen={() => actions.open?.(shellId)} /> : null}
187
+ {actions.agentWrite && shellGrantable(item.shell) ? (
188
+ <AgentWriteAction
189
+ granted={item.shell.agentWrite === true}
190
+ label={shellAgentWriteLabel(item.shell)}
191
+ onToggle={() => void actions.agentWrite?.(shellId, item.shell.agentWrite !== true)}
192
+ />
193
+ ) : null}
194
+ {running ? <KillShellAction onKill={() => void actions.kill(shellId)} /> : null}
195
+ <BookmarkAction id={item.id} />
196
+ <CopyAction text={item.shell.command} label="Copy command" />
197
+ </>
198
+ }
199
+ >
200
+ <div data-slot="shell" className="overflow-hidden rounded-md border border-border bg-surface">
201
+ <div className="flex items-center gap-2 px-3 py-1.5">
202
+ <span className="font-mono text-label text-[var(--wd-shell-accent)]">$</span>
169
203
  <button
170
204
  type="button"
171
- aria-label="Kill this shell"
172
- className="shrink-0 text-label text-fg-3"
173
- onClick={() => void actions.kill(shellId)}
205
+ aria-expanded={open}
206
+ className="min-w-0 flex-1 truncate text-left font-mono text-label text-fg-1"
207
+ onClick={() => {
208
+ const next = !open
209
+ setOpen(next)
210
+ if (next && item.truncated && item.expanded === undefined && !item.missing) {
211
+ void actions.loadOutput(shellId)
212
+ }
213
+ }}
174
214
  >
175
- ✕
215
+ {shellLabel(item)}
176
216
  </button>
217
+ <span className={cn('shrink-0 text-label', failed ? 'text-danger' : 'text-fg-4')}>{shellStatusText(item)}</span>
218
+ </div>
219
+ {lines.length > 0 ? (
220
+ <pre className="overflow-x-auto border-t border-border px-3 py-2 font-mono text-label whitespace-pre-wrap text-fg-2">
221
+ {lines.join('\n')}
222
+ </pre>
177
223
  ) : null}
224
+ {footer ? <div className="px-3 pb-2 text-label text-fg-4">{footer}</div> : null}
178
225
  </div>
179
- {lines.length > 0 ? (
180
- <pre className="overflow-x-auto border-t border-border px-3 py-2 font-mono text-label whitespace-pre-wrap text-fg-2">
181
- {lines.join('\n')}
182
- </pre>
183
- ) : null}
184
- {footer ? <div className="px-3 pb-2 text-label text-fg-4">{footer}</div> : null}
185
- </div>
226
+ </WithActions>
186
227
  )
187
228
  }
188
229
 
@@ -233,7 +274,9 @@ function SentAttachments({
233
274
  {attachments.map((attachment) => {
234
275
  const href = attachmentUrl?.(attachment.id)
235
276
  return attachment.mediaType.startsWith('image/') && href ? (
236
- <img key={attachment.id} src={href} alt={attachment.name} className="size-20 rounded-md border border-border object-cover" />
277
+ <ViewableImage key={attachment.id} image={{ src: href, name: attachment.name }} className="rounded-md">
278
+ <img src={href} alt={attachment.name} className="size-20 rounded-md border border-border object-cover" />
279
+ </ViewableImage>
237
280
  ) : (
238
281
  <span key={attachment.id} className="rounded-full border border-border bg-surface px-2.5 py-1 text-body-xs text-fg-3">
239
282
  {attachment.name}
@@ -5,7 +5,7 @@ import { useStickToBottomContext } from 'use-stick-to-bottom'
5
5
  import type { PermissionRequest } from '@workerdeck/protocol'
6
6
  import type { TranscriptItem } from '@workerdeck/react'
7
7
  import { cn } from '../../lib/utils.ts'
8
- import { resolveAffordances, type TerminalAffordances } from '../terminal/affordances.tsx'
8
+ import { ActionPlacementProvider, resolveAffordances, type TerminalAffordances } from '../terminal/affordances.tsx'
9
9
  import { RunRow } from '../terminal/items.tsx'
10
10
  import { parentOf } from '../terminal/blocks.ts'
11
11
  import { briefPx, estimateBlockPx } from '../terminal/height.ts'
@@ -324,7 +324,8 @@ export function TranscriptRows({
324
324
  {virtualizer.getVirtualItems().map((virtualRow) => {
325
325
  const row = rows[virtualRow.index]
326
326
  const gapClass = virtualRow.index > 0 && (!terminal || gapBefore(rows, virtualRow.index)) ? gap.className : undefined
327
- const content =
327
+ const flushBelow = terminal && virtualRow.index < rows.length - 1 && !gapBefore(rows, virtualRow.index + 1)
328
+ const view =
328
329
  'run' in row ? (
329
330
  <div className={cn(read(boundary, row.index) && 'opacity-45')}>
330
331
  <RunRow items={row.run} />
@@ -348,6 +349,7 @@ export function TranscriptRows({
348
349
  <TaskRow block={row} fileUrl={fileUrl} onOpenSubagent={onOpenSubagent} />
349
350
  </div>
350
351
  )
352
+ const content = <ActionPlacementProvider value={flushBelow ? 'inline' : 'below'}>{view}</ActionPlacementProvider>
351
353
  if (stickyPrompt && 'item' in row && row.item.kind === 'user' && parentOf(row.item) === undefined) {
352
354
  const next = promptRows.find((index) => index > virtualRow.index)
353
355
  const laneEnd = next === undefined ? virtualizer.getTotalSize() : (measurements[next]?.start ?? virtualRow.start)
@@ -0,0 +1,326 @@
1
+ import {
2
+ createContext,
3
+ useCallback,
4
+ useContext,
5
+ useEffect,
6
+ useLayoutEffect,
7
+ useRef,
8
+ useState,
9
+ type KeyboardEvent as ReactKeyboardEvent,
10
+ type PointerEvent as ReactPointerEvent,
11
+ type ReactNode,
12
+ } from 'react'
13
+ import { Download, Maximize, Minus, Plus, X } from 'lucide-react'
14
+ import { Button } from '../ui/Button.tsx'
15
+ import { cn } from '../../lib/utils.ts'
16
+
17
+ export type ViewedImage = { src: string; name: string }
18
+
19
+ export type OpenImage = (image: ViewedImage) => void
20
+
21
+ type Zoom = number | 'fit'
22
+
23
+ type Size = { width: number; height: number }
24
+
25
+ type Anchor = { x: number; y: number; viewX: number; viewY: number }
26
+
27
+ type Focus = { clientX: number; clientY: number }
28
+
29
+ const ZOOM_STEPS = [0.1, 0.25, 0.33, 0.5, 0.67, 0.75, 1, 1.25, 1.5, 2, 3, 4, 6, 8]
30
+
31
+ const STAGE_PADDING = 16
32
+
33
+ const DRAG_SLOP = 4
34
+
35
+ const WHEEL_ZOOM_RATE = 0.01
36
+
37
+ const WHEEL_DELTA_CAP = 25
38
+
39
+ const ViewerContext = createContext<OpenImage | undefined>(undefined)
40
+
41
+ export function useOpenImage(): OpenImage | undefined {
42
+ return useContext(ViewerContext)
43
+ }
44
+
45
+ export function ImageViewerProvider({ children }: { children: ReactNode }) {
46
+ const [image, setImage] = useState<ViewedImage>()
47
+ const close = useCallback(() => setImage(undefined), [])
48
+ return (
49
+ <ViewerContext.Provider value={setImage}>
50
+ {children}
51
+ {image ? <ImageViewer key={image.src} image={image} onClose={close} /> : null}
52
+ </ViewerContext.Provider>
53
+ )
54
+ }
55
+
56
+ export function ViewableImage({ image, className, children }: { image: ViewedImage; className?: string; children: ReactNode }) {
57
+ const open = useOpenImage()
58
+ if (!open) {
59
+ return <>{children}</>
60
+ }
61
+ return (
62
+ <button
63
+ type="button"
64
+ aria-label={`View ${image.name}`}
65
+ title={image.name}
66
+ data-slot="viewable-image"
67
+ className={cn('cursor-zoom-in', className)}
68
+ onClick={(event) => {
69
+ event.stopPropagation()
70
+ open(image)
71
+ }}
72
+ >
73
+ {children}
74
+ </button>
75
+ )
76
+ }
77
+
78
+ export function ImageViewer({ image, onClose, className }: { image: ViewedImage; onClose: () => void; className?: string }) {
79
+ const rootRef = useRef<HTMLDivElement>(null)
80
+ const stageRef = useRef<HTMLDivElement>(null)
81
+ const [natural, setNatural] = useState<Size>()
82
+ const [stage, setStage] = useState<Size>()
83
+ const [zoom, setZoom] = useState<Zoom>('fit')
84
+ const [failed, setFailed] = useState(false)
85
+ const anchor = useRef<Anchor | undefined>(undefined)
86
+ const wheelScale = useRef<number | undefined>(undefined)
87
+ const drag = useRef<{ x: number; y: number; left: number; top: number } | undefined>(undefined)
88
+ const dragged = useRef(false)
89
+
90
+ const fitScale =
91
+ natural && stage
92
+ ? Math.min(1, (stage.width - STAGE_PADDING * 2) / natural.width, (stage.height - STAGE_PADDING * 2) / natural.height)
93
+ : 1
94
+ const scale = zoom === 'fit' ? Math.max(fitScale, 0.01) : zoom
95
+ const scrollable =
96
+ natural !== undefined && stage !== undefined && (natural.width * scale > stage.width || natural.height * scale > stage.height)
97
+
98
+ useEffect(() => {
99
+ const previous = document.activeElement as HTMLElement | null
100
+ rootRef.current?.focus()
101
+ return () => previous?.focus?.()
102
+ }, [])
103
+
104
+ useEffect(() => {
105
+ const element = stageRef.current
106
+ if (!element) {
107
+ return
108
+ }
109
+ const observer = new ResizeObserver(() => setStage({ width: element.clientWidth, height: element.clientHeight }))
110
+ observer.observe(element)
111
+ return () => observer.disconnect()
112
+ }, [])
113
+
114
+ useLayoutEffect(() => {
115
+ const element = stageRef.current
116
+ const point = anchor.current
117
+ if (!element || !point) {
118
+ return
119
+ }
120
+ anchor.current = undefined
121
+ wheelScale.current = undefined
122
+ element.scrollLeft = point.x * element.scrollWidth - point.viewX
123
+ element.scrollTop = point.y * element.scrollHeight - point.viewY
124
+ }, [scale])
125
+
126
+ const zoomTo = useCallback((next: Zoom, focus?: Focus) => {
127
+ const element = stageRef.current
128
+ if (element && element.scrollWidth > 0 && element.scrollHeight > 0) {
129
+ const rect = element.getBoundingClientRect()
130
+ const viewX = focus ? focus.clientX - rect.left : element.clientWidth / 2
131
+ const viewY = focus ? focus.clientY - rect.top : element.clientHeight / 2
132
+ anchor.current = {
133
+ x: (element.scrollLeft + viewX) / element.scrollWidth,
134
+ y: (element.scrollTop + viewY) / element.scrollHeight,
135
+ viewX,
136
+ viewY,
137
+ }
138
+ }
139
+ setZoom(next)
140
+ }, [])
141
+
142
+ const step = (direction: 1 | -1) => {
143
+ const next =
144
+ direction > 0 ? ZOOM_STEPS.find((value) => value > scale + 0.001) : ZOOM_STEPS.filter((value) => value < scale - 0.001).at(-1)
145
+ zoomTo(next ?? (direction > 0 ? ZOOM_STEPS[ZOOM_STEPS.length - 1] : ZOOM_STEPS[0]))
146
+ }
147
+
148
+ const onKeyDown = (event: ReactKeyboardEvent) => {
149
+ event.stopPropagation()
150
+ if (event.key === 'Escape') {
151
+ event.preventDefault()
152
+ onClose()
153
+ } else if (event.key === '+' || event.key === '=') {
154
+ event.preventDefault()
155
+ step(1)
156
+ } else if (event.key === '-' || event.key === '_') {
157
+ event.preventDefault()
158
+ step(-1)
159
+ } else if (event.key === '0') {
160
+ event.preventDefault()
161
+ zoomTo('fit')
162
+ } else if (event.key === '1') {
163
+ event.preventDefault()
164
+ zoomTo(1)
165
+ }
166
+ }
167
+
168
+ const scaleRef = useRef(scale)
169
+ scaleRef.current = scale
170
+
171
+ useEffect(() => {
172
+ const element = stageRef.current
173
+ if (!element) {
174
+ return
175
+ }
176
+ const onWheel = (event: WheelEvent) => {
177
+ if (!event.ctrlKey && !event.metaKey) {
178
+ return
179
+ }
180
+ event.preventDefault()
181
+ const delta = Math.max(-WHEEL_DELTA_CAP, Math.min(WHEEL_DELTA_CAP, event.deltaY))
182
+ const base = wheelScale.current ?? scaleRef.current
183
+ const next = Math.min(ZOOM_STEPS[ZOOM_STEPS.length - 1]!, Math.max(ZOOM_STEPS[0]!, base * Math.exp(-delta * WHEEL_ZOOM_RATE)))
184
+ wheelScale.current = next
185
+ zoomTo(next, event)
186
+ }
187
+ element.addEventListener('wheel', onWheel, { passive: false })
188
+ return () => element.removeEventListener('wheel', onWheel)
189
+ }, [zoomTo])
190
+
191
+ const onPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
192
+ const element = stageRef.current
193
+ if (!scrollable || !element || event.button !== 0) {
194
+ return
195
+ }
196
+ dragged.current = false
197
+ drag.current = { x: event.clientX, y: event.clientY, left: element.scrollLeft, top: element.scrollTop }
198
+ element.setPointerCapture(event.pointerId)
199
+ }
200
+
201
+ const onPointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
202
+ const element = stageRef.current
203
+ const start = drag.current
204
+ if (!element || !start) {
205
+ return
206
+ }
207
+ if (Math.abs(event.clientX - start.x) + Math.abs(event.clientY - start.y) > DRAG_SLOP) {
208
+ dragged.current = true
209
+ }
210
+ element.scrollLeft = start.left - (event.clientX - start.x)
211
+ element.scrollTop = start.top - (event.clientY - start.y)
212
+ }
213
+
214
+ const endDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
215
+ drag.current = undefined
216
+ stageRef.current?.releasePointerCapture?.(event.pointerId)
217
+ }
218
+
219
+ return (
220
+ <div
221
+ ref={rootRef}
222
+ role="dialog"
223
+ aria-modal="true"
224
+ aria-label={image.name}
225
+ tabIndex={-1}
226
+ data-slot="image-viewer"
227
+ onKeyDown={onKeyDown}
228
+ onClick={(event) => event.stopPropagation()}
229
+ className={cn('absolute inset-0 z-50 flex flex-col bg-bg outline-none', className)}
230
+ >
231
+ <div className="flex shrink-0 items-center gap-1 border-b border-border px-2 py-1.5">
232
+ <div className="min-w-0 flex-1 px-1">
233
+ <div className="truncate font-mono text-body-sm text-fg-1" title={image.name}>
234
+ {image.name}
235
+ </div>
236
+ {natural ? (
237
+ <div className="text-label text-fg-4">
238
+ {natural.width} × {natural.height}
239
+ </div>
240
+ ) : null}
241
+ </div>
242
+ <Button variant="ghost" size="icon-sm" aria-label="Zoom out" title="Zoom out (-)" onClick={() => step(-1)} disabled={failed}>
243
+ <Minus />
244
+ </Button>
245
+ <span className="w-12 text-center font-mono text-label text-fg-3 tabular-nums">{Math.round(scale * 100)}%</span>
246
+ <Button variant="ghost" size="icon-sm" aria-label="Zoom in" title="Zoom in (+)" onClick={() => step(1)} disabled={failed}>
247
+ <Plus />
248
+ </Button>
249
+ <Button
250
+ variant={zoom === 'fit' ? 'secondary' : 'ghost'}
251
+ size="icon-sm"
252
+ aria-label="Fit to view"
253
+ aria-pressed={zoom === 'fit'}
254
+ title="Fit to view (0)"
255
+ onClick={() => zoomTo('fit')}
256
+ disabled={failed}
257
+ >
258
+ <Maximize />
259
+ </Button>
260
+ <Button
261
+ variant={zoom === 1 ? 'secondary' : 'ghost'}
262
+ size="sm"
263
+ aria-label="Actual size"
264
+ aria-pressed={zoom === 1}
265
+ title="Actual size (1)"
266
+ onClick={() => zoomTo(1)}
267
+ disabled={failed}
268
+ className="font-mono"
269
+ >
270
+ 1:1
271
+ </Button>
272
+ <a
273
+ href={image.src}
274
+ download={image.name}
275
+ aria-label="Download"
276
+ title="Download"
277
+ className="inline-flex size-7 items-center justify-center rounded-md text-fg-2 transition-colors hover:bg-surface-hover hover:text-foreground"
278
+ >
279
+ <Download className="size-4" />
280
+ </a>
281
+ <Button variant="ghost" size="icon-sm" aria-label="Close" title="Close (Esc)" onClick={onClose}>
282
+ <X />
283
+ </Button>
284
+ </div>
285
+ <div
286
+ ref={stageRef}
287
+ data-slot="image-viewer-stage"
288
+ onPointerDown={onPointerDown}
289
+ onPointerMove={onPointerMove}
290
+ onPointerUp={endDrag}
291
+ onPointerCancel={endDrag}
292
+ onClick={(event) => {
293
+ if (dragged.current) {
294
+ dragged.current = false
295
+ return
296
+ }
297
+ if (event.target === event.currentTarget || event.target === event.currentTarget.firstElementChild) {
298
+ onClose()
299
+ }
300
+ }}
301
+ className={cn('min-h-0 flex-1 overflow-auto bg-surface-hover/40', scrollable && 'cursor-grab active:cursor-grabbing')}
302
+ >
303
+ <div className="flex min-h-full w-max min-w-full items-center justify-center" style={{ padding: STAGE_PADDING }}>
304
+ {failed ? (
305
+ <span className="text-body-sm text-fg-4">Image could not be displayed</span>
306
+ ) : (
307
+ <img
308
+ src={image.src}
309
+ alt={image.name}
310
+ draggable={false}
311
+ onLoad={(event) => setNatural({ width: event.currentTarget.naturalWidth, height: event.currentTarget.naturalHeight })}
312
+ onError={() => setFailed(true)}
313
+ onDoubleClick={() => zoomTo(zoom === 'fit' ? 1 : 'fit')}
314
+ className="block max-w-none select-none"
315
+ style={
316
+ natural
317
+ ? { width: natural.width * scale, height: natural.height * scale, imageRendering: scale > 2 ? 'pixelated' : undefined }
318
+ : { maxWidth: '100%', maxHeight: '100%', visibility: 'hidden' }
319
+ }
320
+ />
321
+ )}
322
+ </div>
323
+ </div>
324
+ </div>
325
+ )
326
+ }
@@ -0,0 +1,91 @@
1
+ import { defaultRehypePlugins, type StreamdownProps } from 'streamdown'
2
+ import { parseFileLink } from '../../lib/file-link.ts'
3
+ import { useFileLinks } from '../terminal/file-link.tsx'
4
+ import { IMAGE_BOX_LINES, IMAGE_UNAVAILABLE, baseName } from '../terminal/image-box.ts'
5
+ import { ViewableImage } from './image-viewer.tsx'
6
+ import { useHostImageSrc } from './tool-result-image.tsx'
7
+
8
+ // Streamdown's sanitize drops `file:` sources and its harden step blocks or re-roots relative ones, so
9
+ // a host path is carried through both as an address on a reserved, never-resolving host.
10
+ const LOCAL_PREFIX = 'https://local-image.invalid/'
11
+
12
+ const WEB_SOURCE = /^(?:https?:|data:image\/|blob:)/i
13
+
14
+ type HastNode = { type: string; tagName?: string; properties?: Record<string, unknown>; children?: HastNode[] }
15
+
16
+ function shieldLocalImages() {
17
+ return (tree: HastNode) => {
18
+ walk(tree)
19
+ }
20
+ }
21
+
22
+ function walk(node: HastNode): void {
23
+ if (node.type === 'element' && node.tagName === 'img' && node.properties) {
24
+ const src = node.properties.src
25
+ if (typeof src === 'string' && src.trim() && !WEB_SOURCE.test(src.trim())) {
26
+ node.properties.src = LOCAL_PREFIX + encodeURIComponent(src.trim())
27
+ }
28
+ }
29
+ for (const child of node.children ?? []) {
30
+ walk(child)
31
+ }
32
+ }
33
+
34
+ export const MARKDOWN_REHYPE_PLUGINS: NonNullable<StreamdownProps['rehypePlugins']> = [
35
+ shieldLocalImages,
36
+ ...Object.values(defaultRehypePlugins),
37
+ ]
38
+
39
+ export function localImagePath(src: string | undefined): string | undefined {
40
+ if (!src?.startsWith(LOCAL_PREFIX)) {
41
+ return undefined
42
+ }
43
+ try {
44
+ return decodeURIComponent(src.slice(LOCAL_PREFIX.length))
45
+ } catch {
46
+ return undefined
47
+ }
48
+ }
49
+
50
+ export function MarkdownImage({ src, alt, terminal }: { src?: unknown; alt?: unknown; terminal?: boolean }) {
51
+ const links = useFileLinks()
52
+ const raw = typeof src === 'string' ? src : undefined
53
+ const local = localImagePath(raw)
54
+ const path = local === undefined ? undefined : parseFileLink(local, links?.cwd)?.path
55
+ const host = useHostImageSrc(path)
56
+ const resolved = local === undefined ? raw : host.src
57
+ const label = typeof alt === 'string' && alt ? alt : undefined
58
+ const name = path ? baseName(path) : (label ?? 'image')
59
+ const failed = local !== undefined && (path === undefined || host.failed)
60
+
61
+ if (terminal) {
62
+ return (
63
+ <span
64
+ className="term-image"
65
+ data-state={resolved ? 'loaded' : failed ? 'failed' : 'pending'}
66
+ style={{ height: `calc(var(--term-line) * ${IMAGE_BOX_LINES})` }}
67
+ >
68
+ {resolved ? (
69
+ <ViewableImage image={{ src: resolved, name }}>
70
+ <img src={resolved} alt={label ?? name} />
71
+ </ViewableImage>
72
+ ) : (
73
+ <span data-tone="faint">{failed ? `${IMAGE_UNAVAILABLE} · ${local ?? name}` : name}</span>
74
+ )}
75
+ </span>
76
+ )
77
+ }
78
+
79
+ if (!resolved) {
80
+ return (
81
+ <span className="my-1 inline-block rounded-md border border-border bg-surface-hover px-2 py-1 text-label text-fg-4">
82
+ {failed ? `${IMAGE_UNAVAILABLE} · ${local ?? name}` : name}
83
+ </span>
84
+ )
85
+ }
86
+ return (
87
+ <ViewableImage image={{ src: resolved, name }} className="my-1 block max-w-full">
88
+ <img src={resolved} alt={label ?? name} className="max-h-96 w-auto max-w-full rounded-md border border-border" />
89
+ </ViewableImage>
90
+ )
91
+ }
@@ -6,6 +6,8 @@ export type ShellActions = {
6
6
  kill: (shellId: string) => Promise<boolean>
7
7
  // Drill in to the shell's terminal. Absent when the host has no frame to open one in.
8
8
  open?: (shellId: string) => void
9
+ // Grant or revoke the agent's hand on a shell the user started. Absent where the agent holds no write tools.
10
+ agentWrite?: (shellId: string, enabled: boolean) => Promise<boolean>
9
11
  }
10
12
 
11
13
  const NOOP: ShellActions = {