@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
@@ -4,7 +4,7 @@ import { cn } from '../../lib/utils.ts'
4
4
  import { Conversation, ConversationContent, ConversationScrollButton } from './Conversation.tsx'
5
5
  import { Loader } from './Loader.tsx'
6
6
  import { SessionEmptyState } from './SessionEmptyState.tsx'
7
- import { type TerminalAffordances } from '../terminal/affordances.tsx'
7
+ import { AffordanceProvider, resolveAffordances, type TerminalAffordances } from '../terminal/affordances.tsx'
8
8
  import { WorkingRow } from '../terminal/items.tsx'
9
9
  import { subagentItems, terminalBlocks, type ToolCallItem } from '../terminal/blocks.ts'
10
10
  import { taskBrief, taskBusy } from '../terminal/tool-run.ts'
@@ -12,7 +12,8 @@ import { type TranscriptRow } from './transcript-rows.ts'
12
12
  import { TranscriptRows } from './TranscriptRows.tsx'
13
13
  import { SessionNamesProvider, SkillNamesProvider } from './PromptTokenText.tsx'
14
14
  import { TerminalSurface } from '../terminal/surface.tsx'
15
- import { ROW_GAP, TranscriptVariantProvider, type TranscriptDensity, type TranscriptVariant } from './transcript-variant.tsx'
15
+ import { ROW_GAP, TranscriptVariantProvider, type TranscriptVariant } from './transcript-variant.tsx'
16
+ import { HostImageProvider } from './tool-result-image.tsx'
16
17
 
17
18
  function useRunStart(status: TranscriptState['status']): number | undefined {
18
19
  const running = status === 'running' || status === 'starting'
@@ -54,7 +55,11 @@ function TerminalShell({
54
55
  children: ReactNode
55
56
  }) {
56
57
  if (!active) {
57
- return <>{children}</>
58
+ return (
59
+ <div data-cards="" className="contents">
60
+ <AffordanceProvider value={resolveAffordances(affordances)}>{children}</AffordanceProvider>
61
+ </div>
62
+ )
58
63
  }
59
64
  return (
60
65
  <TerminalSurface fontSize={fontSize} lineHeight={lineHeight} affordances={affordances} bleed="1ch" className="term-transcript">
@@ -72,7 +77,6 @@ export interface TranscriptProps {
72
77
  canBrowseFiles?: boolean
73
78
  hostImage?: (path: string) => Promise<string | undefined>
74
79
  variant?: TranscriptVariant
75
- density?: TranscriptDensity
76
80
  fontSize?: number
77
81
  lineHeight?: number
78
82
  affordances?: TerminalAffordances | boolean
@@ -99,7 +103,6 @@ export function Transcript({
99
103
  canBrowseFiles,
100
104
  hostImage,
101
105
  variant = 'cards',
102
- density = 'comfortable',
103
106
  fontSize,
104
107
  lineHeight,
105
108
  affordances,
@@ -124,7 +127,7 @@ export function Transcript({
124
127
  frame ? state.items.find((item): item is ToolCallItem => item.kind === 'tool_call' && item.id === frame.parentToolUseId) : undefined,
125
128
  [state.items, frame],
126
129
  )
127
- const gap = ROW_GAP[variant][density]
130
+ const gap = ROW_GAP[variant]
128
131
  const runStartedAt = useRunStart(state.status)
129
132
  const boundary = !frame && catchUp && catchUp.from > 0 && catchUp.from < state.items.length ? catchUp.from : undefined
130
133
  const recap = useMemo(() => (boundary === undefined ? undefined : recapLine(summarizeSince(state, boundary))), [state, boundary])
@@ -143,98 +146,113 @@ export function Transcript({
143
146
  const skillNames = useMemo(() => state.skills?.map((skill) => skill.name), [state.skills])
144
147
  return (
145
148
  <TranscriptVariantProvider value={variant}>
146
- <SkillNamesProvider names={skillNames}>
147
- <SessionNamesProvider names={sessionNames}>
148
- <Conversation className={cn(replaying && 'invisible', className)}>
149
- <ConversationContent className={cn(terminal && 'gap-0 p-0')}>
150
- <TerminalShell active={terminal} fontSize={fontSize} lineHeight={lineHeight} affordances={affordances}>
151
- {frame ? (
152
- frameTask === undefined && !replaying ? (
153
- <div className={cn(terminal ? 'term-row text-fg-4' : 'p-4 text-body-sm text-fg-4')}>
154
- This sub-agent's work is not in this transcript.
155
- </div>
156
- ) : null
157
- ) : items.length === 0 && state.status !== 'starting' ? (
158
- emptyState !== undefined ? (
159
- emptyState
160
- ) : (
161
- <SessionEmptyState
162
- cwd={state.cwd}
163
- hasCommands={!!state.commands?.length}
164
- hasSkills={!!state.skills?.some((s) => s.enabled)}
165
- canBrowseFiles={canBrowseFiles}
166
- />
167
- )
168
- ) : null}
169
- {frame && frameTask === undefined && !replaying ? null : (
170
- <TranscriptRows
171
- rows={rows}
172
- boundary={boundary}
173
- since={catchUp?.since}
174
- terminal={terminal}
175
- replaying={replaying}
176
- stickyPrompt={!frame && stickyPrompt}
177
- gap={gap}
178
- fontSize={fontSize}
179
- lineHeight={lineHeight}
180
- items={items}
181
- pendingApprovals={state.pendingApprovals}
182
- scrubber={scrubber}
183
- bookmarks={frame ? undefined : bookmarks}
184
- affordances={affordances}
185
- fileUrl={fileUrl}
186
- attachmentUrl={attachmentUrl}
187
- hostImage={hostImage}
188
- jumpToRecapRef={jumpToRecapRef}
189
- repinRef={repinRef}
190
- reveal={frame ? undefined : reveal}
191
- frameParentId={frame?.parentToolUseId}
192
- onOpenSubagent={frame ? undefined : onOpenSubagent}
193
- />
194
- )}
195
- {(frame ? frameTask !== undefined && taskBusy(frameTask, items) : showLoader(state)) ? (
196
- terminal ? (
197
- <>
198
- {state.items.length > 0 ? <div className="term-blank" aria-hidden /> : null}
199
- <WorkingRow
200
- label={state.status === 'starting' ? 'Starting…' : 'Working…'}
201
- startedAt={runStartedAt}
202
- tokens={state.contextUsage?.totalTokens}
149
+ <HostImageProvider value={hostImage}>
150
+ <SkillNamesProvider names={skillNames}>
151
+ <SessionNamesProvider names={sessionNames}>
152
+ <Conversation className={cn(replaying && 'invisible', className)}>
153
+ <ConversationContent className={cn(terminal && 'gap-0 p-0')}>
154
+ <TerminalShell active={terminal} fontSize={fontSize} lineHeight={lineHeight} affordances={affordances}>
155
+ {frame ? (
156
+ frameTask === undefined && !replaying ? (
157
+ <div className={cn(terminal ? 'term-row text-fg-4' : 'p-4 text-body-sm text-fg-4')}>
158
+ This sub-agent's work is not in this transcript.
159
+ </div>
160
+ ) : null
161
+ ) : items.length === 0 && state.status !== 'starting' ? (
162
+ emptyState !== undefined ? (
163
+ emptyState
164
+ ) : (
165
+ <SessionEmptyState
166
+ cwd={state.cwd}
167
+ hasCommands={!!state.commands?.length}
168
+ hasSkills={!!state.skills?.some((s) => s.enabled)}
169
+ canBrowseFiles={canBrowseFiles}
203
170
  />
204
- </>
205
- ) : (
206
- <Loader label={state.status === 'starting' ? 'Starting session…' : undefined} />
207
- )
208
- ) : null}
209
- </TerminalShell>
210
- </ConversationContent>
211
- <ConversationScrollButton />
212
- {replaying ? (
213
- <div
214
- data-slot="transcript-hold"
215
- aria-hidden
216
- className="wd-hold-appear visible pointer-events-none absolute inset-0 overflow-hidden"
217
- >
218
- <div className={cn('mx-auto w-full max-w-[var(--wd-transcript-max-width)]', !terminal && 'px-4 py-4')}>
219
- {terminal ? (
220
- <TerminalSurface
171
+ )
172
+ ) : null}
173
+ {frame && frameTask === undefined && !replaying ? null : (
174
+ <TranscriptRows
175
+ rows={rows}
176
+ boundary={boundary}
177
+ since={catchUp?.since}
178
+ terminal={terminal}
179
+ replaying={replaying}
180
+ stickyPrompt={!frame && stickyPrompt}
181
+ gap={gap}
221
182
  fontSize={fontSize}
222
183
  lineHeight={lineHeight}
223
- affordances={false}
224
- bleed="1ch"
225
- className="term-transcript"
226
- >
227
- <WorkingRow label="Loading…" />
228
- </TerminalSurface>
229
- ) : (
230
- <Loader label="Loading session…" />
184
+ items={items}
185
+ pendingApprovals={state.pendingApprovals}
186
+ scrubber={scrubber}
187
+ bookmarks={frame ? undefined : bookmarks}
188
+ affordances={affordances}
189
+ fileUrl={fileUrl}
190
+ attachmentUrl={attachmentUrl}
191
+ hostImage={hostImage}
192
+ jumpToRecapRef={jumpToRecapRef}
193
+ repinRef={repinRef}
194
+ reveal={frame ? undefined : reveal}
195
+ frameParentId={frame?.parentToolUseId}
196
+ onOpenSubagent={frame ? undefined : onOpenSubagent}
197
+ />
231
198
  )}
199
+ {(frame ? frameTask !== undefined && taskBusy(frameTask, items) : showLoader(state)) ? (
200
+ terminal ? (
201
+ <>
202
+ {state.items.length > 0 ? <div className="term-blank" aria-hidden /> : null}
203
+ <WorkingRow
204
+ label={state.status === 'starting' ? 'Starting…' : 'Working…'}
205
+ startedAt={runStartedAt}
206
+ tokens={state.contextUsage?.totalTokens}
207
+ />
208
+ </>
209
+ ) : (
210
+ <Loader label={state.status === 'starting' ? 'Starting session…' : undefined} />
211
+ )
212
+ ) : null}
213
+ </TerminalShell>
214
+ </ConversationContent>
215
+ <ConversationScrollButton />
216
+ {replaying ? (
217
+ <div
218
+ data-slot="transcript-hold"
219
+ aria-hidden
220
+ className="wd-hold-appear visible pointer-events-none absolute inset-0 overflow-hidden"
221
+ >
222
+ <div className={cn('mx-auto w-full max-w-[var(--wd-transcript-max-width)]', !terminal && 'px-4 py-4')}>
223
+ {terminal ? (
224
+ <TerminalSurface
225
+ fontSize={fontSize}
226
+ lineHeight={lineHeight}
227
+ affordances={false}
228
+ bleed="1ch"
229
+ className="term-transcript"
230
+ >
231
+ <WorkingRow label="Loading…" />
232
+ </TerminalSurface>
233
+ ) : (
234
+ <Loader label="Loading session…" />
235
+ )}
236
+ </div>
232
237
  </div>
233
- </div>
234
- ) : null}
235
- </Conversation>
236
- </SessionNamesProvider>
237
- </SkillNamesProvider>
238
+ ) : null}
239
+ </Conversation>
240
+ </SessionNamesProvider>
241
+ </SkillNamesProvider>
242
+ </HostImageProvider>
238
243
  </TranscriptVariantProvider>
239
244
  )
240
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'
@@ -11,8 +11,10 @@ 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
- import { useShellActions } from './shell-actions.tsx'
16
+ import { ShellItemActions, useShellActions, useVerifyRunning } from './shell-actions.tsx'
17
+ import { BookmarkAction, CopyAction, WithActions } from '../terminal/affordances.tsx'
16
18
  import { shellBodyLines, shellFailed, shellFooterText, shellLabel, shellStatusText } from '../terminal/shell-row.ts'
17
19
 
18
20
  function TurnResultRow({ item }: { item: Extract<TranscriptItem, { kind: 'turn_result' }> }) {
@@ -67,36 +69,47 @@ export function TranscriptItemView({
67
69
  terminal?: boolean
68
70
  }) {
69
71
  if (terminal) {
70
- return <TerminalItemView item={item} fileUrl={fileUrl} />
72
+ return <TerminalItemView item={item} fileUrl={fileUrl} attachmentUrl={attachmentUrl} />
71
73
  }
72
74
  switch (item.kind) {
73
75
  case 'user': {
74
76
  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>
77
+ <WithActions actions={<BookmarkAction id={item.id} />}>
78
+ <Message from="user">
79
+ {item.origin ? <div className="wd-peer-origin text-xs text-muted-foreground">{peerLabel(item.origin)}</div> : null}
80
+ {item.attachments?.length ? <SentAttachments attachments={item.attachments} attachmentUrl={attachmentUrl} /> : null}
81
+ {item.text ? (
82
+ <MessageContent>
83
+ <PromptTokenText text={item.text} />
84
+ </MessageContent>
85
+ ) : null}
86
+ </Message>
87
+ </WithActions>
84
88
  )
85
89
  }
86
90
  case 'assistant_text': {
87
91
  return (
88
- <Message from="assistant">
89
- <MessageContent>
90
- <Response streaming={item.streaming}>{item.text}</Response>
91
- </MessageContent>
92
- </Message>
92
+ <WithActions
93
+ actions={
94
+ <>
95
+ <BookmarkAction id={item.id} />
96
+ {item.streaming ? null : <CopyAction text={item.text} label="Copy message" />}
97
+ </>
98
+ }
99
+ >
100
+ <Message from="assistant">
101
+ <MessageContent>
102
+ <Response streaming={item.streaming}>{item.text}</Response>
103
+ </MessageContent>
104
+ </Message>
105
+ </WithActions>
93
106
  )
94
107
  }
95
108
  case 'thinking': {
96
109
  return <Reasoning isStreaming={item.id === 'streaming-thinking'}>{item.text}</Reasoning>
97
110
  }
98
111
  case 'tool_call': {
99
- return <ToolCallCard item={item} hostImage={hostImage} />
112
+ return <ToolCard item={item} hostImage={hostImage} />
100
113
  }
101
114
  case 'turn_result': {
102
115
  return <TurnResultRow item={item} />
@@ -119,70 +132,68 @@ export function TranscriptItemView({
119
132
  }
120
133
  }
121
134
 
135
+ function ToolCard({
136
+ item,
137
+ hostImage,
138
+ }: {
139
+ item: Extract<TranscriptItem, { kind: 'tool_call' }>
140
+ hostImage?: (path: string) => Promise<string | undefined>
141
+ }) {
142
+ const command = (item.input as { command?: unknown } | null)?.command
143
+ const copyable = typeof command === 'string' ? command : (item.result?.text ?? '')
144
+ return (
145
+ <WithActions
146
+ actions={
147
+ <>
148
+ <BookmarkAction id={item.id} />
149
+ {copyable ? <CopyAction text={copyable} label="Copy" /> : null}
150
+ </>
151
+ }
152
+ >
153
+ <ToolCallCard item={item} hostImage={hostImage} />
154
+ </WithActions>
155
+ )
156
+ }
157
+
122
158
  function ShellCard({ item }: { item: ShellItem }) {
123
159
  const [open, setOpen] = useState(false)
124
160
  const actions = useShellActions()
125
- const running = item.shell.status === 'running'
126
161
  const failed = shellFailed(item)
127
162
  const lines = shellBodyLines(item, open)
128
163
  const footer = shellFooterText(item, open, lines.length)
129
164
 
130
165
  const shellId = item.shell.id
131
- const verify = actions.verify
132
- useEffect(() => {
133
- if (running) {
134
- void verify(shellId)
135
- }
136
- }, [running, shellId, verify])
166
+ useVerifyRunning(item)
137
167
 
138
168
  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 ? (
169
+ <WithActions actions={<ShellItemActions item={item} />}>
170
+ <div data-slot="shell" className="overflow-hidden rounded-md border border-border bg-surface">
171
+ <div className="flex items-center gap-2 px-3 py-1.5">
172
+ <span className="font-mono text-label text-[var(--wd-shell-accent)]">$</span>
169
173
  <button
170
174
  type="button"
171
- aria-label="Kill this shell"
172
- className="shrink-0 text-label text-fg-3"
173
- onClick={() => void actions.kill(shellId)}
175
+ aria-expanded={open}
176
+ className="min-w-0 flex-1 truncate text-left font-mono text-label text-fg-1"
177
+ onClick={() => {
178
+ const next = !open
179
+ setOpen(next)
180
+ if (next && item.truncated && item.expanded === undefined && !item.missing) {
181
+ void actions.loadOutput(shellId)
182
+ }
183
+ }}
174
184
  >
175
- ✕
185
+ {shellLabel(item)}
176
186
  </button>
187
+ <span className={cn('shrink-0 text-label', failed ? 'text-danger' : 'text-fg-4')}>{shellStatusText(item)}</span>
188
+ </div>
189
+ {lines.length > 0 ? (
190
+ <pre className="overflow-x-auto border-t border-border px-3 py-2 font-mono text-label whitespace-pre-wrap text-fg-2">
191
+ {lines.join('\n')}
192
+ </pre>
177
193
  ) : null}
194
+ {footer ? <div className="px-3 pb-2 text-label text-fg-4">{footer}</div> : null}
178
195
  </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>
196
+ </WithActions>
186
197
  )
187
198
  }
188
199
 
@@ -233,7 +244,9 @@ function SentAttachments({
233
244
  {attachments.map((attachment) => {
234
245
  const href = attachmentUrl?.(attachment.id)
235
246
  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" />
247
+ <ViewableImage key={attachment.id} image={{ src: href, name: attachment.name }} className="rounded-md">
248
+ <img src={href} alt={attachment.name} className="size-20 rounded-md border border-border object-cover" />
249
+ </ViewableImage>
237
250
  ) : (
238
251
  <span key={attachment.id} className="rounded-full border border-border bg-surface px-2.5 py-1 text-body-xs text-fg-3">
239
252
  {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)
@@ -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
+ }