@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
@@ -1,6 +1,6 @@
1
- import { ArrowRight, Check, CircleAlert } from 'lucide-react'
2
- import { isAgentRecord, promotedShells, subagentLabel, visibleSubagents } from '@workerdeck/protocol'
3
- import type { SessionInfo, ShellInfo, SubagentDisplay, SubagentInfo } from '@workerdeck/protocol'
1
+ import { ArrowRight, Check, Circle, CircleAlert } from 'lucide-react'
2
+ import { displayedTasks, isAgentRecord, subagentLabel, visibleShells, visibleSubagents } from '@workerdeck/protocol'
3
+ import type { SessionInfo, SessionTask, ShellInfo, StepDisplay, SubagentDisplay, SubagentInfo } from '@workerdeck/protocol'
4
4
  import { Spinner } from '../ui/Spinner.tsx'
5
5
  import { cn } from '../../lib/utils.ts'
6
6
  import { AgentWriteIcon } from '../terminal/affordances.tsx'
@@ -16,34 +16,45 @@ import {
16
16
  shellTitle,
17
17
  } from '../terminal/shell-row.ts'
18
18
 
19
- export type StepKind = 'agent' | 'shell'
19
+ export type StepKind = 'agent' | 'shell' | 'task'
20
20
 
21
21
  export type Step = {
22
22
  key: string
23
23
  kind: StepKind
24
24
  label: string
25
25
  noun: string
26
- state: 'done' | 'running' | 'failed'
26
+ state: 'done' | 'running' | 'failed' | 'pending'
27
27
  detail?: string
28
28
  title: string
29
29
  onSelect: () => void
30
30
  onKill?: () => void
31
+ killLabel?: string
31
32
  agentWrite?: { granted: boolean; label: string; toggle: () => void }
32
33
  }
33
34
 
34
35
  export type ShellStepOptions = {
35
36
  now: number
37
+ show?: StepDisplay
36
38
  onSelect: (shellId: string) => void
37
39
  onKill?: (shellId: string) => void
38
40
  // Offered only where the session's agent holds the shell write tools.
39
41
  onAgentWrite?: (shellId: string, enabled: boolean) => void
40
42
  }
41
43
 
44
+ export type TaskStepOptions = {
45
+ show: StepDisplay
46
+ // Whether the session is still working: a checklist item left in progress after the turn ended is not running.
47
+ live: boolean
48
+ onSelect: (task: SessionTask) => void
49
+ onStop?: (toolUseId: string) => void
50
+ }
51
+
42
52
  export function sessionSteps(
43
53
  info: SessionInfo,
44
54
  onSelect: (toolUseId: string) => void,
45
55
  show: SubagentDisplay = 'all',
46
56
  shells?: ShellStepOptions,
57
+ tasks?: TaskStepOptions,
47
58
  ): Step[] {
48
59
  const agents: Step[] = visibleSubagents(info, show)
49
60
  .filter(isAgentRecord)
@@ -57,11 +68,30 @@ export function sessionSteps(
57
68
  title: `${subagentLabel(sub)} · ${sub.toolCount} tool${sub.toolCount === 1 ? '' : 's'}`,
58
69
  onSelect: () => onSelect(sub.toolUseId),
59
70
  }))
71
+ const taskSteps = tasks === undefined ? [] : displayedTasks(info, tasks.show).map((task) => taskStep(task, tasks))
60
72
  if (shells === undefined) {
61
- return agents
73
+ return [...agents, ...taskSteps]
62
74
  }
63
75
  const grants = info.shellAgentWrite !== undefined
64
- return [...agents, ...promotedShells(info, shells.now).map((shell) => shellStep(shell, shells, grants))]
76
+ const shellSteps = visibleShells(info, shells.show ?? 'active', shells.now).map((shell) => shellStep(shell, shells, grants))
77
+ return [...agents, ...taskSteps, ...shellSteps]
78
+ }
79
+
80
+ function taskStep(task: SessionTask, options: TaskStepOptions): Step {
81
+ const stalled = task.source === 'checklist' && task.state === 'running' && !options.live
82
+ const toolUseId = task.toolUseId
83
+ return {
84
+ key: task.key,
85
+ kind: 'task',
86
+ label: task.label,
87
+ noun: 'task',
88
+ state: stalled ? 'pending' : task.state,
89
+ detail: task.detail,
90
+ title: stalled ? `${task.label} · left in progress` : task.label,
91
+ onSelect: () => options.onSelect(task),
92
+ onKill: task.stoppable && toolUseId !== undefined && options.onStop ? () => options.onStop?.(toolUseId) : undefined,
93
+ killLabel: 'Stop this task',
94
+ }
65
95
  }
66
96
 
67
97
  function shellStep(shell: ShellInfo, options: ShellStepOptions, grants: boolean): Step {
@@ -77,6 +107,7 @@ function shellStep(shell: ShellInfo, options: ShellStepOptions, grants: boolean)
77
107
  title: `${shellTitle(shell)} · ${status}`,
78
108
  onSelect: () => options.onSelect(shell.id),
79
109
  onKill: running && options.onKill ? () => options.onKill?.(shell.id) : undefined,
110
+ killLabel: 'Kill this shell',
80
111
  agentWrite:
81
112
  grants && options.onAgentWrite && shellGrantable(shell)
82
113
  ? {
@@ -103,7 +134,16 @@ function stepState(status: SubagentInfo['status']): Step['state'] {
103
134
  }
104
135
 
105
136
  export function StepRow({ step, active = false, onSelect }: { step: Step; active?: boolean; onSelect: () => void }) {
106
- const body = step.state === 'failed' ? 'text-danger' : step.kind === 'shell' ? 'text-[var(--wd-shell-accent)]' : 'text-success'
137
+ const body =
138
+ step.state === 'failed'
139
+ ? 'text-danger'
140
+ : step.kind === 'shell'
141
+ ? 'text-[var(--wd-shell-accent)]'
142
+ : step.kind === 'task'
143
+ ? step.state === 'pending'
144
+ ? 'text-fg-4'
145
+ : 'text-fg-2'
146
+ : 'text-success'
107
147
  return (
108
148
  <div className={cn('flex w-full items-center rounded-[4px] pr-1.5', active ? 'bg-row-selected' : 'hover:bg-row-active', body)}>
109
149
  <button
@@ -119,7 +159,7 @@ export function StepRow({ step, active = false, onSelect }: { step: Step; active
119
159
  <StepIcon step={step} />
120
160
  <span className="min-w-0 flex-1 truncate">{step.label}</span>
121
161
  {step.detail ? <span className="shrink-0 tabular-nums text-fg-4">{step.detail}</span> : null}
122
- {step.kind === 'shell' ? null : <ArrowRight className="size-3.5 shrink-0 text-fg-4" />}
162
+ {step.kind === 'agent' ? <ArrowRight className="size-3.5 shrink-0 text-fg-4" /> : null}
123
163
  </button>
124
164
  {step.agentWrite ? (
125
165
  <button
@@ -142,8 +182,8 @@ export function StepRow({ step, active = false, onSelect }: { step: Step; active
142
182
  {step.onKill ? (
143
183
  <button
144
184
  type="button"
145
- aria-label={`Kill ${step.label}`}
146
- title="Kill this shell"
185
+ aria-label={`${step.kind === 'task' ? 'Stop' : 'Kill'} ${step.label}`}
186
+ title={step.killLabel}
147
187
  onClick={(e) => {
148
188
  e.stopPropagation()
149
189
  step.onKill?.()
@@ -168,6 +208,9 @@ function StepIcon({ step }: { step: Step }) {
168
208
  case 'failed': {
169
209
  return <CircleAlert className="size-[11px] shrink-0" />
170
210
  }
211
+ case 'pending': {
212
+ return <Circle className="size-[11px] shrink-0" />
213
+ }
171
214
  default: {
172
215
  return <Check className="size-[11px] shrink-0" />
173
216
  }
@@ -1,11 +1,9 @@
1
- import { ArrowLeft, X } from 'lucide-react'
1
+ import { X } from 'lucide-react'
2
2
  import type { ShellInfo } from '@workerdeck/protocol'
3
3
  import { cn } from '../../lib/utils.ts'
4
- import { Button } from '../ui/Button.tsx'
5
- import { Ink, Row } from '../terminal/row.tsx'
6
- import { TerminalSurface } from '../terminal/surface.tsx'
7
4
  import { shellAgentWriteLabel, shellGrantable, shellInfoFailed, shellInfoStatusText, shellTitle } from '../terminal/shell-row.ts'
8
- import { AgentWriteAction, AgentWriteIcon, KillShellAction, WithActions } from '../terminal/affordances.tsx'
5
+ import { AgentWriteAction, AgentWriteIcon, KillShellAction } from '../terminal/affordances.tsx'
6
+ import { FrameStrip } from './FrameStrip.tsx'
9
7
 
10
8
  export interface ShellStripProps {
11
9
  shell: ShellInfo | undefined
@@ -29,43 +27,13 @@ export function ShellStrip({ shell, label, cols, onBack, onKill, onAgentWrite, t
29
27
  const granted = shell?.agentWrite === true
30
28
  const grant = shell && onAgentWrite && shellGrantable(shell) ? () => onAgentWrite(!granted) : undefined
31
29
 
32
- if (terminal) {
33
- return (
34
- <TerminalSurface fontSize={fontSize} lineHeight={lineHeight} className="shrink-0">
35
- <WithActions
36
- placement="inline"
37
- actions={
38
- <>
39
- {grant && shell ? <AgentWriteAction granted={granted} label={shellAgentWriteLabel(shell)} onToggle={grant} /> : null}
40
- {running && onKill ? <KillShellAction onKill={onKill} /> : null}
41
- </>
42
- }
43
- >
44
- <Row glyph="←" glyphTone="dim" indent={1} tone={failed ? 'red' : 'magenta'}>
45
- <button type="button" onClick={onBack} aria-label="Back to the session" className="cursor-pointer text-left">
46
- {name}
47
- </button>
48
- {status ? <Ink tone={failed ? 'red' : running ? 'magenta' : 'dim'}> · {status}</Ink> : null}
49
- {detail ? <Ink tone="faint"> · {detail}</Ink> : null}
50
- </Row>
51
- </WithActions>
52
- </TerminalSurface>
53
- )
54
- }
55
-
56
- return (
57
- <div className="flex shrink-0 items-center gap-2 border-b border-border px-3 py-1.5">
58
- <Button variant="ghost" size="sm" onClick={onBack} className="h-6 gap-1 px-1.5">
59
- <ArrowLeft className="size-3.5" />
60
- Back
61
- </Button>
62
- <span className={cn('min-w-0 flex-1 truncate font-mono text-body-sm', failed ? 'text-danger' : 'text-fg-2')}>{name}</span>
63
- {status ? (
64
- <span className={cn('shrink-0 text-label', failed ? 'text-danger' : running ? 'text-[var(--wd-shell-accent)]' : 'text-fg-3')}>
65
- {status}
66
- </span>
67
- ) : null}
68
- {detail ? <span className="shrink-0 text-label text-fg-4">{detail}</span> : null}
30
+ const actions = terminal ? (
31
+ <>
32
+ {grant && shell ? <AgentWriteAction granted={granted} label={shellAgentWriteLabel(shell)} onToggle={grant} /> : null}
33
+ {running && onKill ? <KillShellAction onKill={onKill} /> : null}
34
+ </>
35
+ ) : (
36
+ <>
69
37
  {grant && shell ? (
70
38
  <button
71
39
  type="button"
@@ -89,6 +57,22 @@ export function ShellStrip({ shell, label, cols, onBack, onKill, onAgentWrite, t
89
57
  <X className="size-3.5" />
90
58
  </button>
91
59
  ) : null}
92
- </div>
60
+ </>
61
+ )
62
+
63
+ return (
64
+ <FrameStrip
65
+ kind="shell"
66
+ name={name}
67
+ status={status}
68
+ detail={detail}
69
+ failed={failed}
70
+ live={running}
71
+ onBack={onBack}
72
+ actions={actions}
73
+ terminal={terminal}
74
+ fontSize={fontSize}
75
+ lineHeight={lineHeight}
76
+ />
93
77
  )
94
78
  }
@@ -1,14 +1,10 @@
1
- import { ArrowLeft } from 'lucide-react'
2
1
  import type { TranscriptItem } from '@workerdeck/react'
3
- import { cn } from '../../lib/utils.ts'
4
2
  import { formatDuration } from '../../lib/format.ts'
5
- import { Button } from '../ui/Button.tsx'
6
- import { Ink, Row } from '../terminal/row.tsx'
7
- import { TerminalSurface } from '../terminal/surface.tsx'
8
3
  import { taskBusy, taskFailed, taskIdentity } from '../terminal/tool-run.ts'
9
4
  import type { ToolCallItem } from '../terminal/blocks.ts'
10
5
  import { usePulse } from './pulse.tsx'
11
6
  import { useTicker } from '../terminal/items.tsx'
7
+ import { FrameStrip } from './FrameStrip.tsx'
12
8
 
13
9
  export function SubagentStrip({
14
10
  task,
@@ -39,31 +35,18 @@ export function SubagentStrip({
39
35
  const status = !task ? undefined : failed ? 'failed' : busy ? `${pulse} working…` : 'done'
40
36
  const detail = [tools > 0 ? `${tools} tool${tools === 1 ? '' : 's'}` : undefined, elapsed].filter(Boolean).join(' · ')
41
37
 
42
- if (terminal) {
43
- return (
44
- <TerminalSurface fontSize={fontSize} lineHeight={lineHeight} className="shrink-0">
45
- <button type="button" onClick={onBack} aria-label="Back to the session" className="block w-full cursor-pointer text-left">
46
- <Row glyph="←" glyphTone="dim" indent={1} tone={failed ? 'red' : 'green'}>
47
- {name}
48
- {status ? <Ink tone={failed ? 'red' : busy ? 'mark' : 'dim'}> · {status}</Ink> : null}
49
- {detail ? <Ink tone="faint"> · {detail}</Ink> : null}
50
- </Row>
51
- </button>
52
- </TerminalSurface>
53
- )
54
- }
55
-
56
38
  return (
57
- <div className="flex shrink-0 items-center gap-2 border-b border-border px-3 py-1.5">
58
- <Button variant="ghost" size="sm" onClick={onBack} className="h-6 gap-1 px-1.5">
59
- <ArrowLeft className="size-3.5" />
60
- Back
61
- </Button>
62
- <span className={cn('min-w-0 flex-1 truncate text-body-sm', failed ? 'text-danger' : 'text-fg-2')}>{name}</span>
63
- {status ? (
64
- <span className={cn('shrink-0 text-label', failed ? 'text-danger' : busy ? 'text-accent' : 'text-fg-3')}>{status}</span>
65
- ) : null}
66
- {detail ? <span className="shrink-0 text-label text-fg-4">{detail}</span> : null}
67
- </div>
39
+ <FrameStrip
40
+ kind="subagent"
41
+ name={name}
42
+ status={status}
43
+ detail={detail}
44
+ failed={failed}
45
+ live={busy}
46
+ onBack={onBack}
47
+ terminal={terminal}
48
+ fontSize={fontSize}
49
+ lineHeight={lineHeight}
50
+ />
68
51
  )
69
52
  }
@@ -243,3 +243,16 @@ export function Transcript({
243
243
  </TranscriptVariantProvider>
244
244
  )
245
245
  }
246
+
247
+ export interface TerminalTranscriptProps {
248
+ state: TranscriptState
249
+ fileUrl?: (path: string) => string
250
+ fontSize?: number
251
+ lineHeight?: number
252
+ affordances?: TerminalAffordances | boolean
253
+ className?: string
254
+ }
255
+
256
+ export function TerminalTranscript(props: TerminalTranscriptProps) {
257
+ return <Transcript {...props} variant="terminal" />
258
+ }
@@ -1,5 +1,5 @@
1
1
  import type { MessageAttachment } from '@workerdeck/protocol'
2
- import { useEffect, useState } from 'react'
2
+ import { useState } from 'react'
3
3
  import type { ShellItem, TranscriptItem } from '@workerdeck/react'
4
4
  import { cn } from '../../lib/utils.ts'
5
5
  import { compactionText, formatCost, formatDuration, formatRelativeTime } from '../../lib/format.ts'
@@ -13,17 +13,9 @@ import { Row } from '../terminal/row.tsx'
13
13
  import { TerminalItemView } from '../terminal/TerminalTranscript.tsx'
14
14
  import { ViewableImage } from './image-viewer.tsx'
15
15
  import { peerLabel } from '../terminal/items.tsx'
16
- import { useShellActions } from './shell-actions.tsx'
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'
16
+ import { ShellItemActions, useShellActions, useVerifyRunning } from './shell-actions.tsx'
17
+ import { BookmarkAction, CopyAction, WithActions } from '../terminal/affordances.tsx'
18
+ import { shellBodyLines, shellFailed, shellFooterText, shellLabel, shellStatusText } from '../terminal/shell-row.ts'
27
19
 
28
20
  function TurnResultRow({ item }: { item: Extract<TranscriptItem, { kind: 'turn_result' }> }) {
29
21
  return (
@@ -166,37 +158,15 @@ function ToolCard({
166
158
  function ShellCard({ item }: { item: ShellItem }) {
167
159
  const [open, setOpen] = useState(false)
168
160
  const actions = useShellActions()
169
- const running = item.shell.status === 'running'
170
161
  const failed = shellFailed(item)
171
162
  const lines = shellBodyLines(item, open)
172
163
  const footer = shellFooterText(item, open, lines.length)
173
164
 
174
165
  const shellId = item.shell.id
175
- const verify = actions.verify
176
- useEffect(() => {
177
- if (running) {
178
- void verify(shellId)
179
- }
180
- }, [running, shellId, verify])
166
+ useVerifyRunning(item)
181
167
 
182
168
  return (
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
- >
169
+ <WithActions actions={<ShellItemActions item={item} />}>
200
170
  <div data-slot="shell" className="overflow-hidden rounded-md border border-border bg-surface">
201
171
  <div className="flex items-center gap-2 px-3 py-1.5">
202
172
  <span className="font-mono text-label text-[var(--wd-shell-accent)]">$</span>
@@ -1,4 +1,4 @@
1
- import type { SkillInfo, SlashCommandInfo } from '@workerdeck/protocol'
1
+ import type { EngineCapabilities, PermissionMode, SkillInfo, SlashCommandInfo } from '@workerdeck/protocol'
2
2
 
3
3
  export type ClientCommand = {
4
4
  name: string
@@ -170,3 +170,82 @@ export function composerCommandRows(rows: readonly ComposerRow[]): ComposerComma
170
170
  },
171
171
  )
172
172
  }
173
+
174
+ export type ClientCommandPanel = 'info' | 'context' | 'usage' | 'mcp' | 'skills'
175
+
176
+ export type ClientCommandSources = {
177
+ capabilities: Pick<EngineCapabilities, 'permissionModes' | 'clearContext' | 'mcpStatus' | 'contextUsage' | 'rateLimits' | 'skillsList'>
178
+ hasModels: boolean
179
+ setModel: (model: string) => void
180
+ setPermissionMode: (mode: PermissionMode) => void
181
+ clearContext: () => void
182
+ openPanel: (panel: ClientCommandPanel) => void
183
+ }
184
+
185
+ export function buildClientCommands({
186
+ capabilities,
187
+ hasModels,
188
+ setModel,
189
+ setPermissionMode,
190
+ clearContext,
191
+ openPanel,
192
+ }: ClientCommandSources): ClientCommand[] {
193
+ const modes = capabilities.permissionModes
194
+ const built: ClientCommand[] = []
195
+ if (hasModels) {
196
+ built.push({
197
+ name: 'model',
198
+ description: 'Switch the model for this session',
199
+ argumentHint: '<model>',
200
+ requiresArgs: true,
201
+ run: (args) => {
202
+ const wanted = args.split(/\s+/)[0]
203
+ if (!wanted) {
204
+ return false
205
+ }
206
+ setModel(wanted)
207
+ return true
208
+ },
209
+ })
210
+ }
211
+ if (modes.length > 1) {
212
+ built.push({
213
+ name: 'permissions',
214
+ description: `Set the permission mode (${modes.join(', ')})`,
215
+ argumentHint: '<mode>',
216
+ requiresArgs: true,
217
+ run: (args) => {
218
+ const wanted = args.split(/\s+/)[0] as PermissionMode
219
+ if (!modes.includes(wanted)) {
220
+ return false
221
+ }
222
+ setPermissionMode(wanted)
223
+ return true
224
+ },
225
+ })
226
+ }
227
+ if (capabilities.clearContext) {
228
+ built.push({
229
+ name: 'clear',
230
+ description: 'Clear the conversation - the session keeps running and the old one stays resumable',
231
+ run: () => {
232
+ clearContext()
233
+ return true
234
+ },
235
+ })
236
+ }
237
+ if (capabilities.mcpStatus) {
238
+ built.push({ name: 'mcp', description: 'MCP servers and their status', run: () => (openPanel('mcp'), true) })
239
+ }
240
+ if (capabilities.contextUsage) {
241
+ built.push({ name: 'context', description: 'Context window usage', run: () => (openPanel('context'), true) })
242
+ }
243
+ built.push({ name: 'status', description: 'Session details', run: () => (openPanel('info'), true) })
244
+ if (capabilities.rateLimits) {
245
+ built.push({ name: 'usage', description: 'Rate limits and spend', run: () => (openPanel('usage'), true) })
246
+ }
247
+ if (capabilities.skillsList) {
248
+ built.push({ name: 'skills', description: 'Browse the skills this session can use', run: () => (openPanel('skills'), true) })
249
+ }
250
+ return built
251
+ }
@@ -11,6 +11,10 @@ const LOCAL_PREFIX = 'https://local-image.invalid/'
11
11
 
12
12
  const WEB_SOURCE = /^(?:https?:|data:image\/|blob:)/i
13
13
 
14
+ const REMOTE_SOURCE = /^https?:/i
15
+
16
+ export const REMOTE_IMAGE_NOTICE = 'remote image, not loaded'
17
+
14
18
  type HastNode = { type: string; tagName?: string; properties?: Record<string, unknown>; children?: HastNode[] }
15
19
 
16
20
  function shieldLocalImages() {
@@ -47,9 +51,53 @@ export function localImagePath(src: string | undefined): string | undefined {
47
51
  }
48
52
  }
49
53
 
54
+ // A remote image loads the moment it renders, so a transcript an agent was talked into writing could beacon out
55
+ // whatever it can read. It is shown as a link instead: opening it is the user's decision, and a navigation, not a load.
56
+ export function remoteImageHost(src: string | undefined): string | undefined {
57
+ if (!src || !REMOTE_SOURCE.test(src.trim()) || localImagePath(src) !== undefined) {
58
+ return undefined
59
+ }
60
+ try {
61
+ return new URL(src.trim()).host
62
+ } catch {
63
+ return undefined
64
+ }
65
+ }
66
+
50
67
  export function MarkdownImage({ src, alt, terminal }: { src?: unknown; alt?: unknown; terminal?: boolean }) {
51
- const links = useFileLinks()
52
68
  const raw = typeof src === 'string' ? src : undefined
69
+ const remoteHost = remoteImageHost(raw)
70
+ if (remoteHost !== undefined) {
71
+ return <RemoteImageLink href={raw!.trim()} host={remoteHost} alt={alt} terminal={terminal} />
72
+ }
73
+ return <LoadedMarkdownImage raw={raw} alt={alt} terminal={terminal} />
74
+ }
75
+
76
+ function RemoteImageLink({ href, host, alt, terminal }: { href: string; host: string; alt?: unknown; terminal?: boolean }) {
77
+ const label = typeof alt === 'string' && alt ? `${alt} · ` : ''
78
+ const text = `${label}${host} (${REMOTE_IMAGE_NOTICE})`
79
+ if (terminal) {
80
+ return (
81
+ <a href={href} target="_blank" rel="noopener noreferrer" data-tone="faint" title={href}>
82
+ {text}
83
+ </a>
84
+ )
85
+ }
86
+ return (
87
+ <a
88
+ href={href}
89
+ target="_blank"
90
+ rel="noopener noreferrer"
91
+ title={href}
92
+ className="my-1 inline-block rounded-md border border-border bg-surface-hover px-2 py-1 text-label text-fg-4"
93
+ >
94
+ {text}
95
+ </a>
96
+ )
97
+ }
98
+
99
+ function LoadedMarkdownImage({ raw, alt, terminal }: { raw?: string; alt?: unknown; terminal?: boolean }) {
100
+ const links = useFileLinks()
53
101
  const local = localImagePath(raw)
54
102
  const path = local === undefined ? undefined : parseFileLink(local, links?.cwd)?.path
55
103
  const host = useHostImageSrc(path)
@@ -8,9 +8,12 @@ export type MarkKind = 'user' | 'subagent' | 'shell' | 'turn' | 'turnFailed' | '
8
8
  export type Mark = {
9
9
  kind: MarkKind
10
10
  itemIndex: number
11
+ rowIndex?: number
11
12
  turnIndex?: number
12
13
  }
13
14
 
15
+ export type Placement = (mark: Mark) => { y: number; h: number }
16
+
14
17
  export type Cluster = {
15
18
  lane: Lane
16
19
  kind: MarkKind
@@ -106,11 +109,30 @@ export interface BuildMarksOptions {
106
109
  frameParentId?: string
107
110
  bookmarks?: readonly number[]
108
111
  recapItemIndex?: number
112
+ rowIndexFor?: (itemIndex: number) => number
113
+ }
114
+
115
+ export function toolCallFailed(item: TranscriptItem): boolean {
116
+ return item.kind === 'tool_call' && (item.status === 'failed' || item.result?.isError === true)
117
+ }
118
+
119
+ export function rowOutcomes(
120
+ items: readonly TranscriptItem[],
121
+ frameParentId: string | undefined,
122
+ rowIndexFor: (itemIndex: number) => number,
123
+ ): Set<number> {
124
+ const outcome = new Map<number, number>()
125
+ items.forEach((item, index) => {
126
+ if (item.kind === 'tool_call' && parentOf(item) === frameParentId) {
127
+ outcome.set(rowIndexFor(index), index)
128
+ }
129
+ })
130
+ return new Set(outcome.values())
109
131
  }
110
132
 
111
133
  export function buildMarks(
112
134
  items: readonly TranscriptItem[],
113
- { frameParentId, bookmarks = [], recapItemIndex }: BuildMarksOptions = {},
135
+ { frameParentId, bookmarks = [], recapItemIndex, rowIndexFor = (index) => index }: BuildMarksOptions = {},
114
136
  ): Mark[] {
115
137
  const marks: Mark[] = []
116
138
  const subagentParents = new Set<string>()
@@ -120,6 +142,7 @@ export function buildMarks(
120
142
  subagentParents.add(parent)
121
143
  }
122
144
  }
145
+ const outcomes = rowOutcomes(items, frameParentId, rowIndexFor)
123
146
  let segment: Segment = {}
124
147
  const closeSegment = () => {
125
148
  const anchor = segment.response ?? segment.turn
@@ -147,11 +170,7 @@ export function buildMarks(
147
170
  marks.push({ kind: 'error', itemIndex: index })
148
171
  } else if (item.kind === 'shell') {
149
172
  marks.push({ kind: 'shell', itemIndex: index })
150
- } else if (
151
- item.kind === 'tool_call' &&
152
- parentOf(item) === frameParentId &&
153
- (item.status === 'failed' || item.result?.isError === true)
154
- ) {
173
+ } else if (toolCallFailed(item) && outcomes.has(index)) {
155
174
  marks.push({ kind: 'toolFailed', itemIndex: index })
156
175
  } else if (item.kind === 'assistant_text' && parentOf(item) === frameParentId) {
157
176
  if (frameParentId !== undefined) {
@@ -173,22 +192,25 @@ export function buildMarks(
173
192
  return marks
174
193
  }
175
194
 
176
- export function clusterMarks(marks: readonly Mark[], railH: number, itemCount: number): Cluster[] {
195
+ export function proportionalPlacement(railH: number, itemCount: number): Placement {
177
196
  const count = Math.max(1, itemCount)
178
197
  const h = Math.max(MIN_MARK, Math.round(railH / count))
179
- const lanes = new Map<Lane, { mark: Mark; y: number }[]>()
198
+ return (mark) => ({ y: Math.min(Math.max(0, railH - h), Math.round((mark.itemIndex / count) * railH)), h })
199
+ }
200
+
201
+ export function clusterMarks(marks: readonly Mark[], place: Placement): Cluster[] {
202
+ const lanes = new Map<Lane, { mark: Mark; y: number; h: number }[]>()
180
203
  for (const mark of marks) {
181
- const y = Math.min(Math.max(0, railH - h), Math.round((mark.itemIndex / count) * railH))
182
204
  const lane = LANE[mark.kind]
183
205
  const list = lanes.get(lane) ?? []
184
- list.push({ mark, y })
206
+ list.push({ mark, ...place(mark) })
185
207
  lanes.set(lane, list)
186
208
  }
187
209
  const clusters: Cluster[] = []
188
210
  for (const [lane, list] of lanes) {
189
211
  list.sort((a, b) => a.y - b.y)
190
212
  let current: Cluster | null = null
191
- for (const { mark, y } of list) {
213
+ for (const { mark, y, h } of list) {
192
214
  if (current && y <= current.y + current.h + 1) {
193
215
  current.h = Math.max(current.h, y + h - current.y)
194
216
  if (LOUDNESS[mark.kind] > LOUDNESS[current.kind]) {