@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
@@ -55,6 +55,46 @@ export function useToolResultImageSrc(ref: ToolResultImageRef): ToolResultImageS
55
55
  return state
56
56
  }
57
57
 
58
+ export type HostImageLoader = (path: string) => Promise<string | undefined>
59
+
60
+ const HostImageContext = createContext<HostImageLoader | undefined>(undefined)
61
+
62
+ export function HostImageProvider({ value, children }: { value: HostImageLoader | undefined; children: ReactNode }) {
63
+ return <HostImageContext.Provider value={value}>{children}</HostImageContext.Provider>
64
+ }
65
+
66
+ export function useHostImageSrc(path: string | undefined, override?: HostImageLoader): ToolResultImageState {
67
+ const provided = useContext(HostImageContext)
68
+ const load = override ?? provided
69
+ const [state, setState] = useState<ToolResultImageState>({ failed: false })
70
+ useEffect(() => {
71
+ if (path === undefined) {
72
+ return
73
+ }
74
+ if (!load) {
75
+ setState({ failed: true })
76
+ return
77
+ }
78
+ let live = true
79
+ setState({ failed: false })
80
+ load(path)
81
+ .then((src) => {
82
+ if (live) {
83
+ setState({ src, failed: src === undefined })
84
+ }
85
+ })
86
+ .catch(() => {
87
+ if (live) {
88
+ setState({ failed: true })
89
+ }
90
+ })
91
+ return () => {
92
+ live = false
93
+ }
94
+ }, [load, path])
95
+ return state
96
+ }
97
+
58
98
  const CACHE_BUDGET_BYTES = 64 * 1024 * 1024
59
99
 
60
100
  type Entry = { pending: Promise<string | undefined>; url?: string; bytes: number }
@@ -12,27 +12,9 @@ export function useTranscriptVariant(): TranscriptVariant {
12
12
  return useContext(VariantContext)
13
13
  }
14
14
 
15
- export type TranscriptDensity = 'comfortable' | 'compact'
16
-
17
- const DensityContext = createContext<TranscriptDensity>('comfortable')
18
-
19
- export function TranscriptDensityProvider({ value, children }: { value: TranscriptDensity; children: ReactNode }) {
20
- return <DensityContext.Provider value={value}>{children}</DensityContext.Provider>
21
- }
22
-
23
- export function useTranscriptDensity(): TranscriptDensity {
24
- return useContext(DensityContext)
25
- }
26
-
27
15
  export type TranscriptFont = 'sans' | 'mono'
28
16
 
29
- export const ROW_GAP: Record<TranscriptVariant, Record<TranscriptDensity, { className?: string; px: number }>> = {
30
- terminal: {
31
- comfortable: { className: 'term-row-gap', px: 18 },
32
- compact: { className: 'term-row-gap', px: 18 },
33
- },
34
- cards: {
35
- comfortable: { className: 'pt-4', px: 16 },
36
- compact: { className: 'pt-2', px: 8 },
37
- },
17
+ export const ROW_GAP: Record<TranscriptVariant, { className?: string; px: number }> = {
18
+ terminal: { className: 'term-row-gap', px: 18 },
19
+ cards: { className: 'pt-5', px: 20 },
38
20
  }
@@ -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({
@@ -2,6 +2,7 @@ import { useState } from 'react'
2
2
  import type { PermissionRequest } from '@workerdeck/protocol'
3
3
  import { toolInputPreview } from '../../lib/format.ts'
4
4
  import { planFromRequest } from '../../lib/plan-request.ts'
5
+ import { shellRequestLines, shellRequestPayload, shellRequestTitle } 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'
@@ -29,9 +30,14 @@ export function TerminalPermissionPrompt({ request, onApprove, onDeny, className
29
30
  }
30
31
 
31
32
  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')
33
+ const shell = plan ? undefined : shellRequestPayload(request)
34
+ const patch = plan || shell ? undefined : previewPatch(request.input)
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')
35
41
  const subject = patch?.path ?? (summary || undefined)
36
42
 
37
43
  const reasonInput = denying ? (
@@ -82,6 +88,16 @@ export function TerminalPermissionPrompt({ request, onApprove, onDeny, className
82
88
  <Blank />
83
89
  <Rule dashed />
84
90
  </>
91
+ ) : shell ? (
92
+ <>
93
+ {shellRequestLines(shell).map((line, index) => (
94
+ <Row key={index} bold>
95
+ {line}
96
+ </Row>
97
+ ))}
98
+ <Blank />
99
+ <Rule dashed />
100
+ </>
85
101
  ) : patch ? (
86
102
  <>
87
103
  <TerminalDiff patch={patch} />
@@ -95,7 +111,7 @@ export function TerminalPermissionPrompt({ request, onApprove, onDeny, className
95
111
  </>
96
112
  ) : null}
97
113
  {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>
114
+ <Row>{plan ? 'Ready to implement this plan?' : shell ? 'Do you want to let it?' : (request.title ?? `Do you want to proceed?`)}</Row>
99
115
  <Choices
100
116
  label={heading}
101
117
  options={options}
@@ -2,7 +2,7 @@ import { Fragment, useEffect, useMemo, useState } from 'react'
2
2
  import type { TranscriptItem, TranscriptState } from '@workerdeck/react'
3
3
  import { cn } from '../../lib/utils.ts'
4
4
  import type { TerminalAffordances } from './affordances.tsx'
5
- import { OpenSubagentAction, WithActions } from './affordances.tsx'
5
+ import { ActionPlacementProvider, OpenSubagentAction, WithActions } from './affordances.tsx'
6
6
  import {
7
7
  AssistantRow,
8
8
  CompactionRow,
@@ -35,10 +35,18 @@ export interface TerminalTranscriptProps {
35
35
  className?: string
36
36
  }
37
37
 
38
- export function TerminalItemView({ item, fileUrl }: { item: TranscriptItem; fileUrl?: (path: string) => string }) {
38
+ export function TerminalItemView({
39
+ item,
40
+ fileUrl,
41
+ attachmentUrl,
42
+ }: {
43
+ item: TranscriptItem
44
+ fileUrl?: (path: string) => string
45
+ attachmentUrl?: (attachmentId: string) => string
46
+ }) {
39
47
  switch (item.kind) {
40
48
  case 'user': {
41
- return <UserRow item={item} />
49
+ return <UserRow item={item} attachmentUrl={attachmentUrl} />
42
50
  }
43
51
  case 'assistant_text': {
44
52
  return <AssistantRow item={item} />
@@ -122,12 +130,16 @@ export function TaskRow({
122
130
  {open ? (
123
131
  <div className="term-nested">
124
132
  {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
- ))}
133
+ {block.children.map((leaf, index) => {
134
+ const next = block.children[index + 1]
135
+ const view = 'run' in leaf ? <RunRow items={leaf.run} /> : <TerminalItemView item={leaf.item} fileUrl={fileUrl} />
136
+ return (
137
+ <Fragment key={leaf.key}>
138
+ {index > 0 && blockNeedsBlank(block.children[index - 1]!, leaf) ? <Blank /> : null}
139
+ {next && !blockNeedsBlank(leaf, next) ? <ActionPlacementProvider value="inline">{view}</ActionPlacementProvider> : view}
140
+ </Fragment>
141
+ )
142
+ })}
131
143
  </div>
132
144
  ) : null}
133
145
  </div>
@@ -177,18 +189,23 @@ export function TerminalTranscript({ state, fileUrl, fontSize, lineHeight, affor
177
189
  className={cn('term-transcript', className)}
178
190
  >
179
191
  <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
+ {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
+ })}
192
209
  {working(state) ? (
193
210
  <>
194
211
  {state.items.length > 0 ? <Blank /> : null}
@@ -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
+ }
@@ -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
+ }