@workerdeck/ui 3.3.0 → 3.5.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.
@@ -1,7 +1,9 @@
1
1
  import { Fragment, useEffect, useRef, useState } from 'react'
2
+ import { Users } from 'lucide-react'
2
3
  import type { ReactNode } from 'react'
3
4
  import { projectLabel, projectName, projectSubpath, sessionLabel } from '@workerdeck/protocol'
4
- import type { SessionRow, SessionTask, StepDisplay, SubagentDisplay } from '@workerdeck/protocol'
5
+ import type { SessionRow, SessionState, SessionTask, StepDisplay, SubagentDisplay } from '@workerdeck/protocol'
6
+ import { AgentAvatar, LeadChip, avatarOf, type AgentAvatars } from './AgentAvatar.tsx'
5
7
  import { ContextRing } from './ContextRing.tsx'
6
8
  import { EngineIcon, vendorMarkClass, vendorTextClass } from './EngineIcon.tsx'
7
9
  import { ProjectIcon } from './ProjectIcon.tsx'
@@ -36,6 +38,19 @@ export interface SessionItemProps {
36
38
  editing?: boolean
37
39
  onEditingChange?: (editing: boolean) => void
38
40
  actions?: ReactNode
41
+ // Drawn when `row.info.agent` is set: the card then leads with the avatar and names the agent.
42
+ avatars?: AgentAvatars
43
+ // `member` is the one-row card a team draws under its lead.
44
+ variant?: 'card' | 'member'
45
+ // The state the avatar badge draws, for a folded lead standing in for its team.
46
+ badgeState?: SessionState
47
+ // Unread summed over a folded team's members, drawn with the team glyph beside the lead's own.
48
+ teamUnseen?: number
49
+ // A folded lead's members, drawn on its first line before the context ring.
50
+ teamTray?: ReactNode
51
+ // Draws a session with no agent in the agent card's shape (an engine-mark tile for the avatar), so a list mixing
52
+ // agents and plain sessions keeps one column.
53
+ tile?: boolean
39
54
  className?: string
40
55
  }
41
56
 
@@ -64,6 +79,12 @@ export function SessionItem({
64
79
  editing,
65
80
  onEditingChange,
66
81
  actions,
82
+ avatars,
83
+ variant = 'card',
84
+ badgeState,
85
+ teamUnseen = 0,
86
+ teamTray,
87
+ tile = false,
67
88
  className,
68
89
  }: SessionItemProps) {
69
90
  const { info } = row
@@ -125,11 +146,61 @@ export function SessionItem({
125
146
  },
126
147
  )
127
148
  const holdsOpenStep = steps.some((s) => s.key === activeStepKey)
149
+ const agent = info.agent
150
+ const label = agent ? agent.name : sessionLabel(info)
151
+ const editable = agent ? agent.name : (info.title ?? '')
152
+ const time = formatRelativeTime(info.lastActivityAt ?? info.createdAt)
153
+ const nameLabel =
154
+ isEditing && onRename ? (
155
+ <NameEditor
156
+ initial={editable}
157
+ placeholder={agent ? 'Agent name' : 'Session name'}
158
+ onCommit={(title) => {
159
+ setEditing(false)
160
+ if (title !== editable && (title || !agent)) {
161
+ onRename(title)
162
+ }
163
+ }}
164
+ onCancel={() => setEditing(false)}
165
+ />
166
+ ) : (
167
+ <span
168
+ onDoubleClick={
169
+ onRename && renameOn === 'doubleClick'
170
+ ? (e) => {
171
+ e.stopPropagation()
172
+ setEditing(true)
173
+ }
174
+ : undefined
175
+ }
176
+ className="min-w-0 shrink truncate text-body-sm font-medium tracking-[-0.005em] text-fg-1"
177
+ >
178
+ {label}
179
+ </span>
180
+ )
181
+ const unread =
182
+ row.unseen > 0 ? (
183
+ <span
184
+ // Prose, not rows: `unseen` counts messages a person has not read (`proseCount`).
185
+ title={`${row.unseen} new message${row.unseen === 1 ? '' : 's'}`}
186
+ className={cn(
187
+ 'flex h-4 min-w-6 shrink-0 items-center justify-center rounded-full px-2',
188
+ 'text-[0.75rem] leading-none tracking-[-0.005em] tabular-nums',
189
+ // Grey while the turn is still producing, accent once it has stopped: the badge's colour
190
+ // answers "is this waiting for me", and a working session is not yet.
191
+ row.state === 'working' ? 'bg-badge text-badge-fg' : 'bg-accent text-accent-fg',
192
+ )}
193
+ >
194
+ {row.unseen}
195
+ </span>
196
+ ) : null
128
197
 
129
198
  return (
130
199
  <div
131
200
  data-slot="session-item"
132
201
  data-active={active || undefined}
202
+ data-asleep={info.engineAsleep || undefined}
203
+ title={info.engineAsleep ? 'Asleep, wakes on your next message' : undefined}
133
204
  role="button"
134
205
  tabIndex={0}
135
206
  onClick={(e) => {
@@ -154,75 +225,107 @@ export function SessionItem({
154
225
  className,
155
226
  )}
156
227
  >
157
- <div className="flex flex-col gap-1 py-0.5 pr-0.5 pl-1.5">
158
- <div className="flex h-5 items-center gap-1.5 overflow-hidden">
159
- <Gutter>
160
- <SessionStatusIcon row={row} />
161
- </Gutter>
162
- {isEditing && onRename ? (
163
- <NameEditor
164
- initial={info.title ?? ''}
165
- onCommit={(title) => {
166
- setEditing(false)
167
- if (title !== (info.title ?? '')) {
168
- onRename(title)
169
- }
170
- }}
171
- onCancel={() => setEditing(false)}
172
- />
173
- ) : (
174
- <span
175
- onDoubleClick={
176
- onRename && renameOn === 'doubleClick'
177
- ? (e) => {
178
- e.stopPropagation()
179
- setEditing(true)
180
- }
181
- : undefined
182
- }
183
- className="min-w-0 flex-1 truncate text-body-sm font-medium tracking-[-0.005em] text-fg-1"
184
- >
185
- {sessionLabel(info)}
186
- </span>
187
- )}
188
- {row.unseen > 0 ? (
189
- <span
190
- // Prose, not rows: `unseen` counts messages a person has not read (`proseCount`).
191
- title={`${row.unseen} new message${row.unseen === 1 ? '' : 's'}`}
192
- className={cn(
193
- 'flex h-4 min-w-6 shrink-0 items-center justify-center rounded-full px-2',
194
- 'text-[0.75rem] leading-none tracking-[-0.005em] tabular-nums',
195
- // Grey while the turn is still producing, accent once it has stopped: the badge's colour
196
- // answers "is this waiting for me", and a working session is not yet.
197
- row.state === 'working' ? 'bg-badge text-badge-fg' : 'bg-accent text-accent-fg',
228
+ {agent && variant === 'member' ? (
229
+ <div className={cn('flex h-6 items-center gap-1.5 overflow-hidden py-0.5 pr-0.5 pl-1.5', info.engineAsleep && 'opacity-60')}>
230
+ <AgentAvatar row={row} image={avatarOf(avatars, agent)} size={22} />
231
+ {nameLabel}
232
+ {info.title ? <span className="min-w-0 flex-1 truncate text-body-sm text-fg-3">{info.title}</span> : <span className="flex-1" />}
233
+ {unread}
234
+ <span className="shrink-0 text-body-sm text-fg-4">{time}</span>
235
+ {actions}
236
+ </div>
237
+ ) : agent || tile ? (
238
+ <div className={cn('flex items-center gap-2 py-0.5 pr-0.5 pl-1.5', info.engineAsleep && 'opacity-60')}>
239
+ <AgentAvatar row={row} image={avatarOf(avatars, agent)} state={badgeState} />
240
+ <div className="flex min-w-0 flex-1 flex-col gap-1">
241
+ <div className="flex h-5 items-center gap-1.5 overflow-hidden">
242
+ {nameLabel}
243
+ {agent?.leads ? <LeadChip /> : null}
244
+ <span className="flex-1" />
245
+ {unread}
246
+ {teamUnseen > 0 ? (
247
+ <span
248
+ title={`${teamUnseen} new message${teamUnseen === 1 ? '' : 's'} in the team`}
249
+ className="flex h-4 shrink-0 items-center gap-0.5 rounded-full bg-accent px-1.5 text-[0.75rem] leading-none text-accent-fg tabular-nums"
250
+ >
251
+ <Users className="size-3" />
252
+ {teamUnseen}
253
+ </span>
254
+ ) : null}
255
+ {teamTray}
256
+ <ContextRing usage={info.contextUsage} engine={engine} size={16} className="p-0.5" />
257
+ </div>
258
+ <div className="flex h-5 items-center gap-1.5 overflow-hidden text-body-sm tracking-[-0.005em]">
259
+ {agent ? (
260
+ <>
261
+ {info.title ? <span className="min-w-0 truncate text-fg-4">{info.title}</span> : null}
262
+ <span className="shrink-0 text-fg-4">
263
+ {model ? (
264
+ <>
265
+ {info.title ? '· ' : ''}
266
+ <span className={vendorTextClass(engine, info.model)}>{model}</span>
267
+ {' · '}
268
+ </>
269
+ ) : info.title ? (
270
+ '· '
271
+ ) : null}
272
+ {time}
273
+ </span>
274
+ </>
275
+ ) : (
276
+ <>
277
+ <span className="min-w-0 truncate text-fg-4">
278
+ {parts.map((part, i) => (
279
+ <Fragment key={i}>
280
+ {i > 0 ? ' · ' : ''}
281
+ {part}
282
+ </Fragment>
283
+ ))}
284
+ </span>
285
+ <span className="shrink-0 text-fg-4">
286
+ {parts.length > 0 ? '· ' : ''}
287
+ {time}
288
+ </span>
289
+ </>
198
290
  )}
199
- >
200
- {row.unseen}
201
- </span>
202
- ) : null}
203
- <ContextRing usage={info.contextUsage} engine={engine} size={16} className="p-0.5" />
291
+ <span className="min-w-0 flex-1" />
292
+ {actions}
293
+ </div>
294
+ </div>
204
295
  </div>
296
+ ) : (
297
+ <div className={cn('flex flex-col gap-1 py-0.5 pr-0.5 pl-1.5', info.engineAsleep && 'opacity-60')}>
298
+ <div className="flex h-5 items-center gap-1.5 overflow-hidden">
299
+ <Gutter>
300
+ <SessionStatusIcon row={row} />
301
+ </Gutter>
302
+ {nameLabel}
303
+ <span className="flex-1" />
304
+ {unread}
305
+ <ContextRing usage={info.contextUsage} engine={engine} size={16} className="p-0.5" />
306
+ </div>
205
307
 
206
- <div className="flex h-5 items-center gap-1.5 overflow-hidden text-body-sm tracking-[-0.005em]">
207
- <Gutter>
208
- <EngineIcon engine={engine} model={info.model} className={cn('size-4', vendorMarkClass(engine, info.model))} />
209
- </Gutter>
210
- <span className="min-w-0 truncate text-fg-4">
211
- {parts.map((part, i) => (
212
- <Fragment key={i}>
213
- {i > 0 ? ' · ' : ''}
214
- {part}
215
- </Fragment>
216
- ))}
217
- </span>
218
- <span className="shrink-0 text-fg-4">
219
- {parts.length > 0 ? '· ' : ''}
220
- {formatRelativeTime(info.lastActivityAt ?? info.createdAt)}
221
- </span>
222
- <span className="min-w-0 flex-1" />
223
- {actions}
308
+ <div className="flex h-5 items-center gap-1.5 overflow-hidden text-body-sm tracking-[-0.005em]">
309
+ <Gutter>
310
+ <EngineIcon engine={engine} model={info.model} className={cn('size-4', vendorMarkClass(engine, info.model))} />
311
+ </Gutter>
312
+ <span className="min-w-0 truncate text-fg-4">
313
+ {parts.map((part, i) => (
314
+ <Fragment key={i}>
315
+ {i > 0 ? ' · ' : ''}
316
+ {part}
317
+ </Fragment>
318
+ ))}
319
+ </span>
320
+ <span className="shrink-0 text-fg-4">
321
+ {parts.length > 0 ? '· ' : ''}
322
+ {time}
323
+ </span>
324
+ <span className="min-w-0 flex-1" />
325
+ {actions}
326
+ </div>
224
327
  </div>
225
- </div>
328
+ )}
226
329
 
227
330
  {steps.length > 0 ? (
228
331
  <div className="flex flex-col">
@@ -239,7 +342,17 @@ function Gutter({ children }: { children: ReactNode }) {
239
342
  return <span className="flex size-4 shrink-0 items-center justify-center">{children}</span>
240
343
  }
241
344
 
242
- function NameEditor({ initial, onCommit, onCancel }: { initial: string; onCommit: (title: string) => void; onCancel: () => void }) {
345
+ function NameEditor({
346
+ initial,
347
+ placeholder,
348
+ onCommit,
349
+ onCancel,
350
+ }: {
351
+ initial: string
352
+ placeholder: string
353
+ onCommit: (title: string) => void
354
+ onCancel: () => void
355
+ }) {
243
356
  const [value, setValue] = useState(initial)
244
357
  const ref = useRef<HTMLInputElement>(null)
245
358
  useEffect(() => {
@@ -259,8 +372,8 @@ function NameEditor({ initial, onCommit, onCancel }: { initial: string; onCommit
259
372
  ref={ref}
260
373
  value={value}
261
374
  spellCheck={false}
262
- placeholder="Session name"
263
- aria-label="Session name"
375
+ placeholder={placeholder}
376
+ aria-label={placeholder}
264
377
  onClick={(e) => e.stopPropagation()}
265
378
  onDoubleClick={(e) => e.stopPropagation()}
266
379
  onChange={(e) => setValue(e.target.value)}
@@ -54,14 +54,23 @@ import { HostFilesDialog } from './HostFilesDialog.tsx'
54
54
  import { McpDialog } from './McpDialog.tsx'
55
55
  import { SkillsDialog } from './SkillsDialog.tsx'
56
56
  import { ModelSelect } from './ModelSelect.tsx'
57
+ import { EffortSelect, effortChoices } from './EffortSelect.tsx'
57
58
  import { PermissionModeSelect, permissionModeChoices, type PermissionModeChoice } from './PermissionModeSelect.tsx'
58
59
  import { SubagentStrip } from './SubagentStrip.tsx'
59
60
  import { useSubagentFrame } from './use-subagent-frame.ts'
60
61
  import { ShellStrip } from './ShellStrip.tsx'
61
62
  import { ShellTerminal } from './ShellTerminal.tsx'
62
63
  import { useShellFrame } from './use-shell-frame.ts'
63
- import { BookmarkProvider, type BookmarkHandle, type TerminalAffordances } from '../terminal/affordances.tsx'
64
+ import {
65
+ BookmarkProvider,
66
+ TaskControlProvider,
67
+ type BookmarkHandle,
68
+ type TaskControlHandle,
69
+ type TerminalAffordances,
70
+ } from '../terminal/affordances.tsx'
64
71
  import type { FileLinkOpener } from '../terminal/file-link.tsx'
72
+ import { backgroundableSince, SEND_NOW_AFTER_MS } from '../terminal/live-tool.ts'
73
+ import { useTicker } from '../terminal/items.tsx'
65
74
  import { ApprovalPrompts, type ApprovalPromptProps } from './ApprovalPrompts.tsx'
66
75
  import { SessionPanelProviders } from './session-panel-providers.tsx'
67
76
  import { useCatchUp } from './use-catch-up.ts'
@@ -127,6 +136,7 @@ export interface SessionPanelProps {
127
136
 
128
137
  export type SessionControls = {
129
138
  setModel: (model?: string) => void
139
+ setEffort: (effort?: string) => void
130
140
  setPermissionMode: (mode: PermissionMode) => void
131
141
  interrupt: () => void
132
142
  focusComposer: () => void
@@ -169,6 +179,8 @@ export type SessionVitals = {
169
179
  capabilities: TranscriptState['capabilities']
170
180
  model: string | undefined
171
181
  models: ModelOption[]
182
+ effort: TranscriptState['effort']
183
+ efforts: readonly string[]
172
184
  permissionMode: TranscriptState['permissionMode']
173
185
  permissionModes: PermissionModeChoice[]
174
186
  skills: SkillInfo[] | undefined
@@ -252,8 +264,10 @@ export function SessionPanel({
252
264
  approve,
253
265
  deny,
254
266
  interrupt,
267
+ backgroundTask,
255
268
  clearContext,
256
269
  setModel,
270
+ setEffort,
257
271
  setPermissionMode,
258
272
  reconnectNow,
259
273
  loadFullResult,
@@ -351,9 +365,13 @@ export function SessionPanel({
351
365
  )
352
366
 
353
367
  const hasModels = models.length > 0
368
+ const efforts = useMemo(
369
+ () => effortChoices(models, effectiveModel ?? state.model, capabilities),
370
+ [models, effectiveModel, state.model, capabilities],
371
+ )
354
372
  const clientCommands = useMemo(
355
- () => buildClientCommands({ capabilities, hasModels, setModel, setPermissionMode, clearContext, openPanel }),
356
- [capabilities, hasModels, openPanel, clearContext, setModel, setPermissionMode],
373
+ () => buildClientCommands({ capabilities, hasModels, efforts, setModel, setEffort, setPermissionMode, clearContext, openPanel }),
374
+ [capabilities, hasModels, efforts, openPanel, clearContext, setModel, setEffort, setPermissionMode],
357
375
  )
358
376
 
359
377
  const composerCommands = useMemo(
@@ -382,6 +400,8 @@ export function SessionPanel({
382
400
  capabilities: state.capabilities,
383
401
  model: vitalsModel,
384
402
  models,
403
+ effort: state.effort,
404
+ efforts,
385
405
  permissionMode: state.permissionMode,
386
406
  permissionModes,
387
407
  skills: state.skills,
@@ -406,10 +426,11 @@ export function SessionPanel({
406
426
 
407
427
  const onControlsRef = useRef(onControls)
408
428
  onControlsRef.current = onControls
409
- const setters = useRef({ setModel, setPermissionMode, interrupt })
410
- setters.current = { setModel, setPermissionMode, interrupt }
429
+ const setters = useRef({ setModel, setEffort, setPermissionMode, interrupt })
430
+ setters.current = { setModel, setEffort, setPermissionMode, interrupt }
411
431
  const controls = useRef<SessionControls>({
412
432
  setModel: (model) => setters.current.setModel(model),
433
+ setEffort: (effort) => setters.current.setEffort(effort),
413
434
  setPermissionMode: (mode) => setters.current.setPermissionMode(mode),
414
435
  interrupt: () => setters.current.interrupt(),
415
436
  focusComposer: () => composerRef.current?.focus(),
@@ -426,6 +447,12 @@ export function SessionPanel({
426
447
  [bookmarkSet, onToggleBookmark],
427
448
  )
428
449
 
450
+ const canBackgroundTasks = !readOnly && capabilities.backgroundTasks === true
451
+ const taskControl = useMemo<TaskControlHandle | undefined>(
452
+ () => (canBackgroundTasks ? { background: backgroundTask } : undefined),
453
+ [canBackgroundTasks, backgroundTask],
454
+ )
455
+
429
456
  const busy = state.status === 'running' || state.status === 'awaiting_approval'
430
457
  const ended = state.status === 'failed' || state.status === 'closed'
431
458
  const attachments = useAttachments(client, sessionId, {
@@ -463,6 +490,14 @@ export function SessionPanel({
463
490
  send(text, attachmentIds)
464
491
  }
465
492
 
493
+ const foregroundSince = canBackgroundTasks ? backgroundableSince(state.items) : undefined
494
+ const tick = useTicker(foregroundSince !== undefined)
495
+ const runningForeground = foregroundSince !== undefined && tick - foregroundSince >= SEND_NOW_AFTER_MS
496
+ const handleSendNow = (text: string, attachmentIds: string[]) => {
497
+ handleSend(text, attachmentIds)
498
+ backgroundTask()
499
+ }
500
+
466
501
  const menuShows: Record<MenuPanel, boolean> = {
467
502
  context: capabilities.contextUsage,
468
503
  usage: capabilities.rateLimits,
@@ -505,6 +540,15 @@ export function SessionPanel({
505
540
  className={controlsInStatus ? 'h-5' : undefined}
506
541
  />
507
542
  ) : null}
543
+ {efforts.length && state.effort !== null ? (
544
+ <EffortSelect
545
+ efforts={efforts}
546
+ effort={state.effort}
547
+ onEffortChange={setEffort}
548
+ disabled={ended}
549
+ className={controlsInStatus ? 'h-5' : undefined}
550
+ />
551
+ ) : null}
508
552
  {state.permissionMode ? (
509
553
  <PermissionModeSelect
510
554
  mode={state.permissionMode}
@@ -630,29 +674,31 @@ export function SessionPanel({
630
674
  <ShellTerminal key={framedShellId} handle={handle} shellId={framedShellId} fontSize={cell.fontSize} />
631
675
  ) : (
632
676
  <BookmarkProvider value={bookmarkHandle}>
633
- <Transcript
634
- key={subagentId ?? 'session'}
635
- state={state}
636
- fileUrl={sessionId ? (path) => client.sessionFileUrl(sessionId, path) : undefined}
637
- attachmentUrl={sessionId ? (id) => client.attachmentUrl(sessionId, id) : undefined}
638
- canBrowseFiles={hostFiles.available}
639
- sessionNames={peers.names}
640
- hostImage={hostImage}
641
- variant={transcriptVariant}
642
- {...cell}
643
- affordances={affordances}
644
- stickyPrompt={stickyPrompt}
645
- scrubber={scrubber}
646
- bookmarks={bookmarks}
647
- replaying={replaying}
648
- catchUp={catchUp && newCount > 0 ? { from: catchUp.itemCount, since: catchUp.since } : undefined}
649
- reveal={frameReturnReveal ?? reveal}
650
- frame={subagentId === undefined ? undefined : { parentToolUseId: subagentId }}
651
- onOpenSubagent={enterSubagent}
652
- emptyState={emptyState}
653
- jumpToRecapRef={jumpToRecap}
654
- repinRef={repinTranscript}
655
- />
677
+ <TaskControlProvider value={taskControl}>
678
+ <Transcript
679
+ key={subagentId ?? 'session'}
680
+ state={state}
681
+ fileUrl={sessionId ? (path) => client.sessionFileUrl(sessionId, path) : undefined}
682
+ attachmentUrl={sessionId ? (id) => client.attachmentUrl(sessionId, id) : undefined}
683
+ canBrowseFiles={hostFiles.available}
684
+ sessionNames={peers.names}
685
+ hostImage={hostImage}
686
+ variant={transcriptVariant}
687
+ {...cell}
688
+ affordances={affordances}
689
+ stickyPrompt={stickyPrompt}
690
+ scrubber={scrubber}
691
+ bookmarks={bookmarks}
692
+ replaying={replaying}
693
+ catchUp={catchUp && newCount > 0 ? { from: catchUp.itemCount, since: catchUp.since } : undefined}
694
+ reveal={frameReturnReveal ?? reveal}
695
+ frame={subagentId === undefined ? undefined : { parentToolUseId: subagentId }}
696
+ onOpenSubagent={enterSubagent}
697
+ emptyState={emptyState}
698
+ jumpToRecapRef={jumpToRecap}
699
+ repinRef={repinTranscript}
700
+ />
701
+ </TaskControlProvider>
656
702
  </BookmarkProvider>
657
703
  )}
658
704
  {catchUp && newCount > 0 && !replaying && subagentId === undefined && framedShellId === undefined ? (
@@ -680,6 +726,7 @@ export function SessionPanel({
680
726
  <Composer
681
727
  ref={composerRef}
682
728
  onSend={handleSend}
729
+ onSendNow={runningForeground ? handleSendNow : undefined}
683
730
  onInterrupt={interrupt}
684
731
  busy={busy}
685
732
  disabled={ended || !sessionId}
@@ -12,9 +12,10 @@ import { ToolCallCard } from './ToolCallCard.tsx'
12
12
  import { Row } from '../terminal/row.tsx'
13
13
  import { TerminalItemView } from '../terminal/TerminalTranscript.tsx'
14
14
  import { ViewableImage } from './image-viewer.tsx'
15
- import { peerLabel } from '../terminal/items.tsx'
15
+ import { peerLabel, useTicker } from '../terminal/items.tsx'
16
+ import { canBackground, elapsedLabel, liveTailLines, toolBusy } from '../terminal/live-tool.ts'
16
17
  import { ShellItemActions, useShellActions, useVerifyRunning } from './shell-actions.tsx'
17
- import { BookmarkAction, CopyAction, WithActions } from '../terminal/affordances.tsx'
18
+ import { BackgroundAction, BookmarkAction, CopyAction, WithActions } from '../terminal/affordances.tsx'
18
19
  import { shellBodyLines, shellFailed, shellFooterText, shellLabel, shellStatusText } from '../terminal/shell-row.ts'
19
20
 
20
21
  function TurnResultRow({ item }: { item: Extract<TranscriptItem, { kind: 'turn_result' }> }) {
@@ -145,16 +146,33 @@ function ToolCard({
145
146
  <WithActions
146
147
  actions={
147
148
  <>
149
+ {canBackground(item) ? <BackgroundAction toolUseId={item.id} /> : null}
148
150
  <BookmarkAction id={item.id} />
149
151
  {copyable ? <CopyAction text={copyable} label="Copy" /> : null}
150
152
  </>
151
153
  }
152
154
  >
153
155
  <ToolCallCard item={item} hostImage={hostImage} />
156
+ {toolBusy(item) ? <LiveToolStrip item={item} /> : null}
154
157
  </WithActions>
155
158
  )
156
159
  }
157
160
 
161
+ function LiveToolStrip({ item }: { item: Extract<TranscriptItem, { kind: 'tool_call' }> }) {
162
+ const now = useTicker(item.ts !== undefined)
163
+ const elapsed = elapsedLabel(item.ts, now)
164
+ const tail = liveTailLines(item)
165
+ if (!elapsed && tail.length === 0) {
166
+ return null
167
+ }
168
+ return (
169
+ <div data-slot="tool-live" className="mt-1 text-label text-fg-4">
170
+ {elapsed ? <div>running {elapsed}</div> : null}
171
+ {tail.length > 0 ? <pre className="overflow-x-auto font-mono whitespace-pre-wrap text-fg-3">{tail.join('\n')}</pre> : null}
172
+ </div>
173
+ )
174
+ }
175
+
158
176
  function ShellCard({ item }: { item: ShellItem }) {
159
177
  const [open, setOpen] = useState(false)
160
178
  const actions = useShellActions()
@@ -176,7 +176,9 @@ export type ClientCommandPanel = 'info' | 'context' | 'usage' | 'mcp' | 'skills'
176
176
  export type ClientCommandSources = {
177
177
  capabilities: Pick<EngineCapabilities, 'permissionModes' | 'clearContext' | 'mcpStatus' | 'contextUsage' | 'rateLimits' | 'skillsList'>
178
178
  hasModels: boolean
179
+ efforts?: readonly string[]
179
180
  setModel: (model: string) => void
181
+ setEffort?: (effort?: string) => void
180
182
  setPermissionMode: (mode: PermissionMode) => void
181
183
  clearContext: () => void
182
184
  openPanel: (panel: ClientCommandPanel) => void
@@ -185,7 +187,9 @@ export type ClientCommandSources = {
185
187
  export function buildClientCommands({
186
188
  capabilities,
187
189
  hasModels,
190
+ efforts = [],
188
191
  setModel,
192
+ setEffort,
189
193
  setPermissionMode,
190
194
  clearContext,
191
195
  openPanel,
@@ -208,6 +212,26 @@ export function buildClientCommands({
208
212
  },
209
213
  })
210
214
  }
215
+ if (efforts.length && setEffort) {
216
+ built.push({
217
+ name: 'effort',
218
+ description: `Set the reasoning effort (${efforts.join(', ')}, default)`,
219
+ argumentHint: '<level>',
220
+ requiresArgs: true,
221
+ run: (args) => {
222
+ const wanted = args.split(/\s+/)[0]
223
+ if (wanted === 'default') {
224
+ setEffort(undefined)
225
+ return true
226
+ }
227
+ if (!wanted || !efforts.includes(wanted)) {
228
+ return false
229
+ }
230
+ setEffort(wanted)
231
+ return true
232
+ },
233
+ })
234
+ }
211
235
  if (modes.length > 1) {
212
236
  built.push({
213
237
  name: 'permissions',
@@ -1,4 +1,4 @@
1
- import { Bot, BotOff, Check, Copy, Maximize2, SquareTerminal, Star, X, type LucideIcon } from 'lucide-react'
1
+ import { ArrowDownToLine, Bot, BotOff, Check, Copy, Maximize2, SquareTerminal, Star, X, type LucideIcon } from 'lucide-react'
2
2
  import { createContext, useContext, useState, type ReactNode } from 'react'
3
3
  import { copyText } from '../../lib/clipboard.ts'
4
4
  import { cn } from '../../lib/utils.ts'
@@ -134,6 +134,34 @@ export function BookmarkAction({ id }: { id: string }) {
134
134
  )
135
135
  }
136
136
 
137
+ // Present only where the engine can move a running tool call to the background (Claude's Ctrl+B).
138
+ export type TaskControlHandle = { background: (toolUseId?: string) => void }
139
+
140
+ const TaskControlContext = createContext<TaskControlHandle | undefined>(undefined)
141
+
142
+ export function TaskControlProvider({ value, children }: { value: TaskControlHandle | undefined; children: ReactNode }) {
143
+ return <TaskControlContext.Provider value={value}>{children}</TaskControlContext.Provider>
144
+ }
145
+
146
+ export function useTaskControl(): TaskControlHandle | undefined {
147
+ return useContext(TaskControlContext)
148
+ }
149
+
150
+ export function BackgroundAction({ toolUseId }: { toolUseId: string }) {
151
+ const handle = useContext(TaskControlContext)
152
+ if (!handle) {
153
+ return null
154
+ }
155
+ return (
156
+ <ActionButton
157
+ icon={ArrowDownToLine}
158
+ word="background"
159
+ label="Move to background (the command keeps running, the agent continues)"
160
+ onPress={() => handle.background(toolUseId)}
161
+ />
162
+ )
163
+ }
164
+
137
165
  export function OpenSubagentAction({ onOpen, label = 'Open sub-agent' }: { onOpen: () => void; label?: string }) {
138
166
  return <ActionButton icon={Maximize2} word="open" label={label} onPress={onOpen} />
139
167
  }