@workerdeck/ui 3.2.0 → 3.4.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.
@@ -0,0 +1,248 @@
1
+ import { useState } from 'react'
2
+ import type { DragEvent, HTMLAttributes, ReactNode } from 'react'
3
+ import { GripVertical, Pencil, Plus, Trash2 } from 'lucide-react'
4
+ import {
5
+ addCustomGroup,
6
+ moveCustomGroup,
7
+ moveToCustomGroup,
8
+ newCustomGroupId,
9
+ removeCustomGroup,
10
+ renameCustomGroup,
11
+ } from '@workerdeck/protocol'
12
+ import type { CustomGroup, SessionGroup } from '@workerdeck/protocol'
13
+ import { Button } from '../ui/Button.tsx'
14
+ import { Input } from '../ui/Input.tsx'
15
+ import { cn } from '../../lib/utils.ts'
16
+
17
+ type DragItem = { kind: 'session'; key: string } | { kind: 'group'; id: string }
18
+
19
+ export type GroupDrag = ReturnType<typeof useGroupDrag>
20
+
21
+ // The payload lives in state rather than `dataTransfer`, which is unreadable during `dragover`; `setData` is still
22
+ // written because some engines refuse to start a drag without it.
23
+ export function useGroupDrag(groups: readonly CustomGroup[], onChange: (groups: CustomGroup[]) => void) {
24
+ const [item, setItem] = useState<DragItem>()
25
+ const [over, setOver] = useState<string>()
26
+ const end = () => {
27
+ setItem(undefined)
28
+ setOver(undefined)
29
+ }
30
+ const start = (e: DragEvent, next: DragItem) => {
31
+ e.stopPropagation()
32
+ e.dataTransfer.effectAllowed = 'move'
33
+ e.dataTransfer.setData('text/plain', next.kind === 'session' ? next.key : next.id)
34
+ setItem(next)
35
+ }
36
+ const accept = (e: DragEvent, spot: string) => {
37
+ e.preventDefault()
38
+ e.stopPropagation()
39
+ e.dataTransfer.dropEffect = 'move'
40
+ setOver(spot)
41
+ }
42
+
43
+ function session(key: string, group: SessionGroup): HTMLAttributes<HTMLDivElement> {
44
+ return {
45
+ draggable: true,
46
+ onDragStart: (e) => start(e, { kind: 'session', key }),
47
+ onDragEnd: end,
48
+ onDragOver: (e) => {
49
+ if (item?.kind === 'session') {
50
+ accept(e, `row:${key}`)
51
+ }
52
+ },
53
+ onDrop: (e) => {
54
+ if (item?.kind !== 'session') {
55
+ return
56
+ }
57
+ e.preventDefault()
58
+ e.stopPropagation()
59
+ if (item.key !== key) {
60
+ onChange(moveToCustomGroup(groups, item.key, group.custom, group.custom ? key : undefined))
61
+ }
62
+ end()
63
+ },
64
+ }
65
+ }
66
+
67
+ function container(group: SessionGroup): HTMLAttributes<HTMLDivElement> {
68
+ return {
69
+ onDragOver: (e) => {
70
+ if (item) {
71
+ accept(e, `group:${group.key}`)
72
+ }
73
+ },
74
+ onDrop: (e) => {
75
+ e.preventDefault()
76
+ if (item?.kind === 'session') {
77
+ onChange(moveToCustomGroup(groups, item.key, group.custom))
78
+ } else if (item?.kind === 'group') {
79
+ onChange(moveCustomGroup(groups, item.id, group.custom))
80
+ }
81
+ end()
82
+ },
83
+ }
84
+ }
85
+
86
+ function header(group: SessionGroup): HTMLAttributes<HTMLDivElement> {
87
+ const id = group.custom
88
+ if (id === undefined) {
89
+ return {}
90
+ }
91
+ return { draggable: true, onDragStart: (e) => start(e, { kind: 'group', id }), onDragEnd: end }
92
+ }
93
+
94
+ return {
95
+ dragging: item !== undefined,
96
+ isOver: (spot: string) => over === spot,
97
+ session,
98
+ container,
99
+ header,
100
+ create: (name = 'New group') => {
101
+ const id = newCustomGroupId()
102
+ onChange(addCustomGroup(groups, name, id))
103
+ return id
104
+ },
105
+ rename: (id: string, name: string) => onChange(renameCustomGroup(groups, id, name)),
106
+ remove: (id: string) => onChange(removeCustomGroup(groups, id)),
107
+ }
108
+ }
109
+
110
+ export function CustomGroupHeader({
111
+ group,
112
+ editing,
113
+ onEditingChange,
114
+ onRename,
115
+ onRemove,
116
+ dragProps,
117
+ }: {
118
+ group: SessionGroup
119
+ editing: boolean
120
+ onEditingChange: (editing: boolean) => void
121
+ onRename: (name: string) => void
122
+ onRemove: () => void
123
+ dragProps: HTMLAttributes<HTMLDivElement>
124
+ }) {
125
+ if (editing) {
126
+ return <GroupNameInput initial={group.label ?? ''} onRename={onRename} onDone={() => onEditingChange(false)} />
127
+ }
128
+
129
+ const custom = group.custom !== undefined
130
+ return (
131
+ <GroupHeading
132
+ {...dragProps}
133
+ label={group.label ?? ''}
134
+ count={group.rows.length}
135
+ className={cn('group/heading', custom && 'cursor-grab')}
136
+ onDoubleClick={custom ? () => onEditingChange(true) : undefined}
137
+ leading={custom ? <GripVertical className="-ml-1.5 size-3 opacity-0 group-hover/heading:opacity-60" /> : undefined}
138
+ actions={
139
+ custom ? (
140
+ <>
141
+ <HeadingAction label="Rename group" onClick={() => onEditingChange(true)}>
142
+ <Pencil className="size-3" />
143
+ </HeadingAction>
144
+ <HeadingAction label="Delete group" title="Delete the group - its sessions move to Ungrouped" onClick={onRemove}>
145
+ <Trash2 className="size-3" />
146
+ </HeadingAction>
147
+ </>
148
+ ) : undefined
149
+ }
150
+ />
151
+ )
152
+ }
153
+
154
+ function GroupNameInput({ initial, onRename, onDone }: { initial: string; onRename: (name: string) => void; onDone: () => void }) {
155
+ const [draft, setDraft] = useState(initial)
156
+ const commit = () => {
157
+ const name = draft.trim()
158
+ if (name && name !== initial) {
159
+ onRename(name)
160
+ }
161
+ onDone()
162
+ }
163
+ return (
164
+ <div className="px-2">
165
+ <Input
166
+ autoFocus
167
+ aria-label="Group name"
168
+ value={draft}
169
+ onFocus={(e) => e.currentTarget.select()}
170
+ onChange={(e) => setDraft(e.target.value)}
171
+ onBlur={commit}
172
+ onKeyDown={(e) => {
173
+ if (e.key === 'Enter') {
174
+ commit()
175
+ } else if (e.key === 'Escape') {
176
+ e.stopPropagation()
177
+ onDone()
178
+ }
179
+ }}
180
+ className="h-6 text-label"
181
+ />
182
+ </div>
183
+ )
184
+ }
185
+
186
+ export function GroupHeading({
187
+ label,
188
+ count,
189
+ leading,
190
+ actions,
191
+ className,
192
+ ...rest
193
+ }: {
194
+ label: ReactNode
195
+ count: number
196
+ leading?: ReactNode
197
+ actions?: ReactNode
198
+ } & HTMLAttributes<HTMLDivElement>) {
199
+ return (
200
+ <div {...rest} className={cn('group/heading flex min-h-5 items-center gap-2 px-2 text-label font-medium text-fg-4', className)}>
201
+ {leading}
202
+ <span className="min-w-0 truncate uppercase tracking-wide">{label}</span>
203
+ <span className="text-fg-4/70">{count}</span>
204
+ {actions ? (
205
+ <span className="ml-auto flex items-center gap-0.5 opacity-0 transition-opacity group-hover/heading:opacity-100 focus-within:opacity-100">
206
+ {actions}
207
+ </span>
208
+ ) : null}
209
+ </div>
210
+ )
211
+ }
212
+
213
+ export function HeadingAction({
214
+ label,
215
+ title,
216
+ onClick,
217
+ children,
218
+ }: {
219
+ label: string
220
+ title?: string
221
+ onClick: () => void
222
+ children: ReactNode
223
+ }) {
224
+ return (
225
+ <Button
226
+ variant="ghost"
227
+ size="icon-sm"
228
+ aria-label={label}
229
+ title={title ?? label}
230
+ className="size-5 text-fg-3"
231
+ onClick={(e) => {
232
+ e.stopPropagation()
233
+ onClick()
234
+ }}
235
+ >
236
+ {children}
237
+ </Button>
238
+ )
239
+ }
240
+
241
+ export function NewGroupButton({ onClick }: { onClick: () => void }) {
242
+ return (
243
+ <Button variant="ghost" size="xs" className="mx-1 self-start text-fg-3" onClick={onClick}>
244
+ <Plus />
245
+ New group
246
+ </Button>
247
+ )
248
+ }
@@ -130,6 +130,8 @@ export function SessionItem({
130
130
  <div
131
131
  data-slot="session-item"
132
132
  data-active={active || undefined}
133
+ data-asleep={info.engineAsleep || undefined}
134
+ title={info.engineAsleep ? 'Asleep, wakes on your next message' : undefined}
133
135
  role="button"
134
136
  tabIndex={0}
135
137
  onClick={(e) => {
@@ -154,7 +156,7 @@ export function SessionItem({
154
156
  className,
155
157
  )}
156
158
  >
157
- <div className="flex flex-col gap-1 py-0.5 pr-0.5 pl-1.5">
159
+ <div className={cn('flex flex-col gap-1 py-0.5 pr-0.5 pl-1.5', info.engineAsleep && 'opacity-60')}>
158
160
  <div className="flex h-5 items-center gap-1.5 overflow-hidden">
159
161
  <Gutter>
160
162
  <SessionStatusIcon row={row} />
@@ -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
  }
@@ -4,6 +4,7 @@ import { compactionText, formatBytes, formatCost, formatDuration, toolInputPrevi
4
4
  import { taskChildItems, type TerminalBlock, type ToolCallItem } from './blocks.ts'
5
5
  import { IMAGE_BOX_LINES, hostImagePathOf } from './image-box.ts'
6
6
  import { collapsedResult } from './result-preview.ts'
7
+ import { ELAPSED_WIDEST, liveTailLines, runStartedAt, runTailLines, toolBusy } from './live-tool.ts'
7
8
  import { shellBodyLines, shellFooterText, shellHeaderText } from './shell-row.ts'
8
9
  import { todoLine, todoPreview } from './todos.ts'
9
10
  import { isPeerSend, peerName, planRun, runSummary, taskSummary } from './tool-run.ts'
@@ -565,7 +566,11 @@ function toolRowHeight(item: ToolCallItem, m: CellMetrics, extraPx: number): Acc
565
566
  const todos = todoPreview(item.name, item.input)
566
567
  const preview = todos ? todos.summary : toolInputPreview(item.input)
567
568
  const backend = item.backend && item.backend !== 'server' ? ` · ${item.backend}` : ''
568
- let acc = rowH(`${item.name}(${preview})${backend}`, m, { gutterCells: 2, extraPx })
569
+ const elapsed = toolBusy(item) && item.ts !== undefined ? ELAPSED_WIDEST : ''
570
+ let acc = rowH(`${item.name}(${preview})${backend}${elapsed}`, m, { gutterCells: 2, extraPx })
571
+ for (const line of liveTailLines(item)) {
572
+ acc = add(acc, rowH(line || ' ', m, { indentCells: 3, gutterCells: 3, extraPx }))
573
+ }
569
574
 
570
575
  const boxes = (item.result?.images?.length ?? 0) + (hostImagePathOf(item) === undefined ? 0 : 1)
571
576
  if (boxes > 0) {
@@ -680,7 +685,12 @@ export function blockHeight(block: TerminalBlock, m: CellMetrics): ComputedHeigh
680
685
  return itemHeight(plan.item, m)
681
686
  }
682
687
  const busy = plan.items.some((item) => item.status === 'running' || item.status === 'pending')
683
- return rowH(runSummary(plan.items, busy), m)
688
+ const elapsed = runStartedAt(plan.items) === undefined ? '' : ELAPSED_WIDEST
689
+ let acc = rowH(runSummary(plan.items, busy) + elapsed, m)
690
+ for (const line of runTailLines(plan.items)) {
691
+ acc = add(acc, rowH(line || ' ', m, { indentCells: 3, gutterCells: 3 }))
692
+ }
693
+ return acc
684
694
  }
685
695
  return itemHeight(block.item, m)
686
696
  }