@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
@@ -2,7 +2,7 @@ import type { FilePatch, PatchHunk } from '@workerdeck/protocol'
2
2
  import type { TranscriptItem } from '@workerdeck/react'
3
3
  import { compactionText, formatBytes, formatCost, formatDuration, toolInputPreview } from '../../lib/format.ts'
4
4
  import { taskChildItems, type TerminalBlock, type ToolCallItem } from './blocks.ts'
5
- import { IMAGE_BOX_LINES } from './image-box.ts'
5
+ import { IMAGE_BOX_LINES, hostImagePathOf } from './image-box.ts'
6
6
  import { collapsedResult } from './result-preview.ts'
7
7
  import { shellBodyLines, shellFooterText, shellHeaderText } from './shell-row.ts'
8
8
  import { todoLine, todoPreview } from './todos.ts'
@@ -60,6 +60,10 @@ export function measureCh(surface: HTMLElement): number {
60
60
  return width
61
61
  }
62
62
 
63
+ const MARKDOWN_IMAGE = /!\[[^\]]*\]\([^)]*\)/
64
+
65
+ const MARKDOWN_IMAGE_LINE = /^\s*!\[[^\]]*\]\([^)\s]+\)\s*$/
66
+
63
67
  const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' })
64
68
 
65
69
  const WIDE_RANGES: [number, number][] = [
@@ -276,7 +280,8 @@ function stripInline(s: string): string {
276
280
  }
277
281
 
278
282
  type MdBlock =
279
- | { t: 'p'; lines: string[] }
283
+ | { t: 'p'; lines: string[]; images: boolean }
284
+ | { t: 'image' }
280
285
  | { t: 'h'; text: string }
281
286
  | { t: 'fence'; code: string }
282
287
  | { t: 'list'; items: { lines: string[]; gutter: number; indent: number }[] }
@@ -302,11 +307,37 @@ function hardLines(source: string[]): string[] {
302
307
  return out
303
308
  }
304
309
 
310
+ function isBlank(s: string | undefined): boolean {
311
+ return s !== undefined && s.trim() === ''
312
+ }
313
+
314
+ function listMarker(s: string): { depth: number; ordered: boolean; text: string } | undefined {
315
+ const m = /^(\s*)([-*+]|\d+\.)\s+(.*)$/.exec(s)
316
+ if (!m) {
317
+ return undefined
318
+ }
319
+ return { depth: Math.floor(m[1]!.length / 2), ordered: /\d/.test(m[2]!), text: m[3]! }
320
+ }
321
+
322
+ function numberHunk(hunk: PatchHunk): { number: number; text: string }[] {
323
+ let newLine = hunk.newStart
324
+ let oldLine = hunk.oldStart
325
+ return hunk.lines.map((line) => {
326
+ const kind = line.startsWith('+') ? 'add' : line.startsWith('-') ? 'remove' : 'context'
327
+ const text = line.slice(1)
328
+ const number = kind === 'add' ? newLine++ : kind === 'remove' ? oldLine++ : newLine
329
+ if (kind === 'context') {
330
+ oldLine++
331
+ newLine++
332
+ }
333
+ return { number, text }
334
+ })
335
+ }
336
+
305
337
  function parseBlocks(md: string): MdBlock[] {
306
338
  const lines = md.split('\n')
307
339
  const blocks: MdBlock[] = []
308
340
  let i = 0
309
- const isBlank = (s: string | undefined) => s !== undefined && s.trim() === ''
310
341
  while (i < lines.length) {
311
342
  const line = lines[i]!
312
343
  if (line.trim() === '') {
@@ -359,15 +390,6 @@ function parseBlocks(md: string): MdBlock[] {
359
390
  blocks.push({ t: 'quote', paras })
360
391
  continue
361
392
  }
362
- const listMarker = (s: string) => {
363
- const m = /^(\s*)([-*+]|\d+\.)\s+(.*)$/.exec(s)
364
- if (!m) {
365
- return undefined
366
- }
367
- const depth = Math.floor(m[1]!.length / 2)
368
- const ordered = /\d/.test(m[2]!)
369
- return { depth, ordered, text: m[3]! }
370
- }
371
393
  if (listMarker(line)) {
372
394
  const items: { lines: string[]; gutter: number; indent: number }[] = []
373
395
  const gutters: number[] = []
@@ -431,7 +453,11 @@ function parseBlocks(md: string): MdBlock[] {
431
453
  para.push(lines[i]!)
432
454
  i += 1
433
455
  }
434
- blocks.push({ t: 'p', lines: hardLines(para) })
456
+ if (para.length === 1 && MARKDOWN_IMAGE_LINE.test(para[0]!)) {
457
+ blocks.push({ t: 'image' })
458
+ } else {
459
+ blocks.push({ t: 'p', lines: hardLines(para), images: para.some((row) => MARKDOWN_IMAGE.test(row)) })
460
+ }
435
461
  }
436
462
  return blocks
437
463
  }
@@ -449,10 +475,14 @@ export function markdownHeight(md: string, m: CellMetrics, extraPx = 0): Acc {
449
475
  case 'p': {
450
476
  for (const line of block.lines) {
451
477
  const r = textLines(line, cols)
452
- acc = add(acc, { px: r.lines * m.line, exact: r.exact })
478
+ acc = add(acc, { px: r.lines * m.line, exact: r.exact && !block.images })
453
479
  }
454
480
  break
455
481
  }
482
+ case 'image': {
483
+ acc = add(acc, { px: IMAGE_BOX_LINES * m.line, exact: true })
484
+ break
485
+ }
456
486
  case 'h': {
457
487
  const r = textLines(block.text, cols)
458
488
  acc = add(acc, { px: r.lines * m.line, exact: r.exact })
@@ -512,21 +542,7 @@ export function markdownHeight(md: string, m: CellMetrics, extraPx = 0): Acc {
512
542
  }
513
543
 
514
544
  function diffHeight(patch: FilePatch, m: CellMetrics, extraPx = 0): Acc {
515
- const walk = (hunk: PatchHunk) => {
516
- let newLine = hunk.newStart
517
- let oldLine = hunk.oldStart
518
- return hunk.lines.map((line) => {
519
- const kind = line.startsWith('+') ? 'add' : line.startsWith('-') ? 'remove' : 'context'
520
- const text = line.slice(1)
521
- const number = kind === 'add' ? newLine++ : kind === 'remove' ? oldLine++ : newLine
522
- if (kind === 'context') {
523
- oldLine++
524
- newLine++
525
- }
526
- return { number, text }
527
- })
528
- }
529
- const hunks = patch.hunks.map(walk)
545
+ const hunks = patch.hunks.map(numberHunk)
530
546
  const numbered = patch.hunks.some((hunk) => hunk.newStart > 0)
531
547
  const width = numbered ? String(Math.max(...hunks.flat().map((row) => row.number), 1)).length : 0
532
548
  const columns = numbered ? width + 3 : 2
@@ -551,9 +567,9 @@ function toolRowHeight(item: ToolCallItem, m: CellMetrics, extraPx: number): Acc
551
567
  const backend = item.backend && item.backend !== 'server' ? ` · ${item.backend}` : ''
552
568
  let acc = rowH(`${item.name}(${preview})${backend}`, m, { gutterCells: 2, extraPx })
553
569
 
554
- const images = item.result?.images
555
- if (images?.length) {
556
- acc = add(acc, { px: images.length * IMAGE_BOX_LINES * m.line, exact: true })
570
+ const boxes = (item.result?.images?.length ?? 0) + (hostImagePathOf(item) === undefined ? 0 : 1)
571
+ if (boxes > 0) {
572
+ acc = add(acc, { px: boxes * IMAGE_BOX_LINES * m.line, exact: true })
557
573
  }
558
574
 
559
575
  if (item.patch) {
@@ -1,9 +1,40 @@
1
+ import type { TranscriptItem } from '@workerdeck/react'
1
2
  import { formatBytes } from '../../lib/format.ts'
2
3
 
4
+ type ToolCallItem = Extract<TranscriptItem, { kind: 'tool_call' }>
5
+
3
6
  export const IMAGE_BOX_LINES = 12
4
7
 
8
+ const HOST_IMAGE_TOOLS = new Set(['CodexImageGeneration', 'CodexImageView'])
9
+
10
+ const MEDIA_EXTENSIONS: Record<string, string> = {
11
+ 'image/png': 'png',
12
+ 'image/jpeg': 'jpg',
13
+ 'image/gif': 'gif',
14
+ 'image/webp': 'webp',
15
+ 'image/svg+xml': 'svg',
16
+ }
17
+
5
18
  export function imagePlaceholder(image: { bytes: number }): string {
6
19
  return `image · ${formatBytes(image.bytes)}`
7
20
  }
8
21
 
9
22
  export const IMAGE_UNAVAILABLE = 'image unavailable'
23
+
24
+ export function hostImagePathOf(item: ToolCallItem): string | undefined {
25
+ if (!HOST_IMAGE_TOOLS.has(item.name)) {
26
+ return undefined
27
+ }
28
+ const input = item.input as { savedPath?: unknown; path?: unknown } | null
29
+ const path = input?.savedPath ?? input?.path
30
+ return typeof path === 'string' ? path : undefined
31
+ }
32
+
33
+ export function baseName(path: string): string {
34
+ return path.split(/[\\/]/).pop() || path
35
+ }
36
+
37
+ export function resultImageName(toolName: string, image: { partIndex: number; mediaType: string }): string {
38
+ const extension = MEDIA_EXTENSIONS[image.mediaType] ?? image.mediaType.split('/').pop() ?? 'img'
39
+ return `${toolName.toLowerCase()}-${image.partIndex + 1}.${extension}`
40
+ }
@@ -1,23 +1,24 @@
1
- import { useEffect, useState, type ReactNode } from 'react'
1
+ import { Fragment, useEffect, useState, type ReactNode } from 'react'
2
2
  import type { MessageOrigin } from '@workerdeck/protocol'
3
3
  import type { ShellItem, TranscriptItem } from '@workerdeck/react'
4
4
  import { compactionText, formatBytes, formatCost, formatDuration, toolInputPreview } from '../../lib/format.ts'
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 { BookmarkAction, CopyAction, KillShellAction, OpenShellAction, WithActions } from './affordances.tsx'
8
+ import { ActionPlacementProvider, BookmarkAction, CopyAction, WithActions } from './affordances.tsx'
9
9
  import { TerminalDiff } from './diff.tsx'
10
10
  import { TerminalMarkdown } from './markdown.tsx'
11
11
  import { usePeerNames } from './peer-names.tsx'
12
12
  import { Pressable, useRevealOnOpen, type PressModifiers } from './press.tsx'
13
- import { IMAGE_BOX_LINES, IMAGE_UNAVAILABLE, imagePlaceholder } from './image-box.ts'
13
+ import { IMAGE_BOX_LINES, IMAGE_UNAVAILABLE, baseName, hostImagePathOf, imagePlaceholder, resultImageName } from './image-box.ts'
14
14
  import { collapsedResult } from './result-preview.ts'
15
15
  import { useToolResultFetcher } from '../agent/tool-result-fetch.tsx'
16
16
  import { useToolTitle } from '../agent/tool-titles.tsx'
17
- import { useToolResultImageSrc } from '../agent/tool-result-image.tsx'
17
+ import { useHostImageSrc, useToolResultImageSrc, type ToolResultImageState } from '../agent/tool-result-image.tsx'
18
+ import { ViewableImage, useOpenImage } from '../agent/image-viewer.tsx'
18
19
  import { isPeerSend, peerName, peerOneLine, peerSendTarget, peerSendText, planRun, runFailed, runSummary } from './tool-run.ts'
19
20
  import { todoLine, todoPreview, type TodoPreview, type TodoStatus } from './todos.ts'
20
- import { useShellActions } from '../agent/shell-actions.tsx'
21
+ import { ShellItemActions, useShellActions, useVerifyRunning } from '../agent/shell-actions.tsx'
21
22
  import { SHELL_GLYPH, shellBodyLines, shellFailed, shellFooterText, shellLabel, shellStatusText } from './shell-row.ts'
22
23
  import { type ToolCallItem } from './blocks.ts'
23
24
  import { Band, Blank, Ink, Row, type Tone } from './row.tsx'
@@ -101,13 +102,24 @@ export function PeerSendRow({ item }: { item: ToolCallItem }) {
101
102
  )
102
103
  }
103
104
 
104
- export function UserRow({ item }: { item: Extract<TranscriptItem, { kind: 'user' }> }) {
105
+ export function UserRow({
106
+ item,
107
+ attachmentUrl,
108
+ }: {
109
+ item: Extract<TranscriptItem, { kind: 'user' }>
110
+ attachmentUrl?: (attachmentId: string) => string
111
+ }) {
105
112
  return (
106
113
  <WithActions actions={<BookmarkAction id={item.id} />}>
107
114
  <div className="term-user" data-peer={item.origin ? '' : undefined}>
108
115
  {item.attachments?.length ? (
109
116
  <Row glyph={PROMPT_GLYPH} glyphTone="dim" tone="dim">
110
- {item.attachments.map((attachment) => attachment.name).join(', ')}
117
+ {item.attachments.map((attachment, index) => (
118
+ <Fragment key={attachment.id}>
119
+ {index > 0 ? ', ' : null}
120
+ <AttachmentName attachment={attachment} href={attachmentUrl?.(attachment.id)} />
121
+ </Fragment>
122
+ ))}
111
123
  </Row>
112
124
  ) : null}
113
125
  {item.text
@@ -195,6 +207,7 @@ export function ToolRow({ item }: { item: ToolCallItem }) {
195
207
  const copyable = typeof command === 'string' ? command : text
196
208
  const todos = todoPreview(item.name, item.input)
197
209
  const title = useToolTitle(item.name)
210
+ const hostPath = hostImagePathOf(item)
198
211
 
199
212
  return (
200
213
  <div ref={reveal} className={open ? 'term-open' : undefined}>
@@ -216,8 +229,9 @@ export function ToolRow({ item }: { item: ToolCallItem }) {
216
229
  {item.backend && item.backend !== 'server' ? <Ink tone="faint"> · {item.backend}</Ink> : null}
217
230
  </Row>
218
231
  </Pressable>
232
+ {hostPath !== undefined ? <TerminalHostImage path={hostPath} /> : null}
219
233
  {item.result?.images?.map((image) => (
220
- <TerminalImage key={image.partIndex} toolUseId={item.id} image={image} />
234
+ <TerminalImage key={image.partIndex} toolUseId={item.id} name={resultImageName(item.name, image)} image={image} />
221
235
  ))}
222
236
  {item.patch && !open ? (
223
237
  <TerminalDiff patch={item.patch} />
@@ -298,10 +312,39 @@ function TerminalTodos({ preview }: { preview: TodoPreview }) {
298
312
  )
299
313
  }
300
314
 
315
+ function AttachmentName({ attachment, href }: { attachment: { name: string; mediaType: string }; href?: string }) {
316
+ const open = useOpenImage()
317
+ if (!open || !href || !attachment.mediaType.startsWith('image/')) {
318
+ return <>{attachment.name}</>
319
+ }
320
+ return (
321
+ <button
322
+ type="button"
323
+ className="term-press term-link"
324
+ onClick={(event) => {
325
+ event.stopPropagation()
326
+ open({ src: href, name: attachment.name })
327
+ }}
328
+ >
329
+ {attachment.name}
330
+ </button>
331
+ )
332
+ }
333
+
301
334
  type ToolResultImage = NonNullable<NonNullable<ToolCallItem['result']>['images']>[number]
302
335
 
303
- function TerminalImage({ toolUseId, image }: { toolUseId: string; image: ToolResultImage }) {
304
- const { src, failed } = useToolResultImageSrc({ toolUseId, ...image })
336
+ function TerminalImage({ toolUseId, name, image }: { toolUseId: string; name: string; image: ToolResultImage }) {
337
+ const state = useToolResultImageSrc({ toolUseId, ...image })
338
+ return <TerminalImageBox state={state} name={name} placeholder={imagePlaceholder(image)} />
339
+ }
340
+
341
+ function TerminalHostImage({ path }: { path: string }) {
342
+ const state = useHostImageSrc(path)
343
+ return <TerminalImageBox state={state} name={baseName(path)} placeholder={baseName(path)} />
344
+ }
345
+
346
+ function TerminalImageBox({ state, name, placeholder }: { state: ToolResultImageState; name: string; placeholder: string }) {
347
+ const { src, failed } = state
305
348
  return (
306
349
  <Row indent={1} columns={3}>
307
350
  <div
@@ -310,9 +353,11 @@ function TerminalImage({ toolUseId, image }: { toolUseId: string; image: ToolRes
310
353
  style={{ height: `calc(var(--term-line) * ${IMAGE_BOX_LINES})` }}
311
354
  >
312
355
  {src ? (
313
- <img src={src} alt={imagePlaceholder(image)} />
356
+ <ViewableImage image={{ src, name }}>
357
+ <img src={src} alt={name} />
358
+ </ViewableImage>
314
359
  ) : (
315
- <Ink tone="faint">{failed ? IMAGE_UNAVAILABLE : imagePlaceholder(image)}</Ink>
360
+ <Ink tone="faint">{failed ? IMAGE_UNAVAILABLE : placeholder}</Ink>
316
361
  )}
317
362
  </div>
318
363
  </Row>
@@ -338,8 +383,17 @@ export function ToolRunRow({ items }: { items: ToolCallItem[] }) {
338
383
  </Pressable>
339
384
  {open ? (
340
385
  <div>
341
- {items.map((item) => (
342
- <ToolRow key={item.id} item={item} />
386
+ {items.map((item, index) => (
387
+ <Fragment key={item.id}>
388
+ {index > 0 ? <Blank /> : null}
389
+ {index < items.length - 1 ? (
390
+ <ActionPlacementProvider value="below">
391
+ <ToolRow item={item} />
392
+ </ActionPlacementProvider>
393
+ ) : (
394
+ <ToolRow item={item} />
395
+ )}
396
+ </Fragment>
343
397
  ))}
344
398
  </div>
345
399
  ) : null}
@@ -400,12 +454,7 @@ export function ShellRow({ item }: { item: ShellItem }) {
400
454
  const footer = shellFooterText(item, open, lines.length)
401
455
 
402
456
  const shellId = item.shell.id
403
- const verify = actions.verify
404
- useEffect(() => {
405
- if (running) {
406
- void verify(shellId)
407
- }
408
- }, [running, shellId, verify])
457
+ useVerifyRunning(item)
409
458
 
410
459
  const press = (modifiers: PressModifiers) => {
411
460
  if ((modifiers.meta || modifiers.ctrl) && actions.open) {
@@ -423,16 +472,7 @@ export function ShellRow({ item }: { item: ShellItem }) {
423
472
 
424
473
  return (
425
474
  <div ref={reveal} className={open ? 'term-open' : undefined}>
426
- <WithActions
427
- actions={
428
- <>
429
- {actions.open ? <OpenShellAction onOpen={() => actions.open?.(shellId)} /> : null}
430
- {running ? <KillShellAction onKill={() => void actions.kill(shellId)} /> : null}
431
- <BookmarkAction id={item.id} />
432
- <CopyAction text={item.shell.command} label="Copy command" />
433
- </>
434
- }
435
- >
475
+ <WithActions actions={<ShellItemActions item={item} />}>
436
476
  <Pressable onPress={press} expanded={open}>
437
477
  <Row glyph={SHELL_GLYPH} glyphTone={tone} tone="fg">
438
478
  <Ink bold tone="bright">
@@ -5,6 +5,7 @@ import { parseFileLink } from '../../lib/file-link.ts'
5
5
  import { CopyAction, WithActions } from './affordances.tsx'
6
6
  import { useFileLinks } from './file-link.tsx'
7
7
  import { Band } from './row.tsx'
8
+ import { MARKDOWN_REHYPE_PLUGINS, MarkdownImage } from '../agent/markdown-image.tsx'
8
9
 
9
10
  function codeText(node: ReactNode): string {
10
11
  if (node === null || node === undefined || typeof node === 'boolean') {
@@ -29,7 +30,7 @@ function fenceLanguage(node: ReactNode): string | undefined {
29
30
 
30
31
  function CodeBand({ code, language }: { code: string; language?: string }) {
31
32
  return (
32
- <WithActions className="term-block" actions={<CopyAction text={code} label="Copy code" />}>
33
+ <WithActions className="term-block" placement="inline" actions={<CopyAction text={code} label="Copy code" />}>
33
34
  <Band className="term-code" data-language={language}>
34
35
  <pre className="term-pre">{code}</pre>
35
36
  </Band>
@@ -116,6 +117,7 @@ const TERMINAL_COMPONENTS: Components = {
116
117
  ),
117
118
  em: ({ children }) => <em className="term-em">{children}</em>,
118
119
  a: ({ children, href }) => <Link href={href}>{children}</Link>,
120
+ img: ({ src, alt }) => <MarkdownImage src={src} alt={alt} terminal />,
119
121
 
120
122
  table: ({ children }) => (
121
123
  <div className="term-block term-table-wrap">
@@ -148,6 +150,7 @@ export const TerminalMarkdown = memo(
148
150
  parseIncompleteMarkdown={streaming}
149
151
  controls={false}
150
152
  components={TERMINAL_COMPONENTS}
153
+ rehypePlugins={MARKDOWN_REHYPE_PLUGINS}
151
154
  className={cn('term-md', className)}
152
155
  >
153
156
  {children}