@workerdeck/ui 3.3.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.
@@ -1,4 +1,4 @@
1
- import { r as SessionPanelProps } from "./SessionPanel-BNNc8grK.mjs";
1
+ import { r as SessionPanelProps } from "./SessionPanel-DIi4sEgh.mjs";
2
2
  import { CSSProperties } from "react";
3
3
  import { ShellInfo } from "@workerdeck/protocol";
4
4
  import { OpenFile, UseHostFileSearchResult, UseHostFileTreeResult } from "@workerdeck/react";
@@ -1,4 +1,4 @@
1
- import { $t as Spinner, En as Input, N as ShellTerminal, Zt as Splitter, i as SessionPanel, in as TooltipProvider, jn as cn, kn as Button, n as usePathLinks, nn as Tip, q as EntryIcon, t as resolveAgainstCwd } from "./use-path-links-Cby4i_VL.mjs";
1
+ import { $t as Splitter, N as ShellTerminal, Nn as cn, On as Input, Y as EntryIcon, i as SessionPanel, in as Tip, jn as Button, n as usePathLinks, on as TooltipProvider, t as resolveAgainstCwd, tn as Spinner } from "./use-path-links-WCsysQPy.mjs";
2
2
  import { m as formatBytes } from "./status-kmsBhuFG.mjs";
3
3
  import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
4
4
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@workerdeck/ui",
3
- "version": "3.3.0",
3
+ "version": "3.4.0",
4
4
  "type": "module",
5
5
  "description": "Styled agent-control component library for WorkerDeck: session panel, transcript with streaming, tool-call cards, permission prompts, session list, composer. Tailwind v4 + Base UI + cva. Pairs with the headless @workerdeck/react layer.",
6
6
  "license": "MIT",
@@ -39,9 +39,9 @@
39
39
  "streamdown": "^2.6.0",
40
40
  "tailwind-merge": "^3.7.0",
41
41
  "use-stick-to-bottom": "^1.1.6",
42
- "@workerdeck/protocol": "3.3.0",
43
- "@workerdeck/react": "3.3.0",
44
- "@workerdeck/client": "3.3.0"
42
+ "@workerdeck/client": "3.4.0",
43
+ "@workerdeck/react": "3.4.0",
44
+ "@workerdeck/protocol": "3.4.0"
45
45
  },
46
46
  "peerDependencies": {
47
47
  "monaco-editor": "^0.56.0",
@@ -39,6 +39,8 @@ export type ComposerHandle = {
39
39
 
40
40
  export interface ComposerProps {
41
41
  onSend: (text: string, attachmentIds: string[]) => void
42
+ // Present while a foreground command the engine can background is running: ⌥↵ sends and moves it to the background.
43
+ onSendNow?: (text: string, attachmentIds: string[]) => void
42
44
  onInterrupt: () => void
43
45
  busy: boolean
44
46
  disabled?: boolean
@@ -70,6 +72,7 @@ export interface ComposerProps {
70
72
 
71
73
  export function Composer({
72
74
  onSend,
75
+ onSendNow,
73
76
  onInterrupt,
74
77
  busy,
75
78
  disabled,
@@ -184,7 +187,7 @@ export function Composer({
184
187
  rankPeerSessions(query, peers).map((peer) => ({
185
188
  value: peer.slug,
186
189
  label: peer.label,
187
- description: [peer.engine, peer.project, peer.status].filter(Boolean).join(' · '),
190
+ description: [peer.gateway, peer.engine, peer.project, peer.status].filter(Boolean).join(' · '),
188
191
  icon: <Users className="size-3.5 text-fg-3" />,
189
192
  })),
190
193
  onSelect: (suggestion) => suggestion.value,
@@ -214,7 +217,7 @@ export function Composer({
214
217
  const staged = attachments?.items ?? []
215
218
  const canSend = !disabled && (!isEmpty || staged.length > 0) && !attachments?.uploading && !attachments?.hasFailure
216
219
 
217
- const submit = () => {
220
+ const submit = (now = false) => {
218
221
  if (!canSend) {
219
222
  return
220
223
  }
@@ -230,7 +233,8 @@ export function Composer({
230
233
  focus()
231
234
  return
232
235
  }
233
- onSend(plainText.trim(), attachments?.readyIds ?? [])
236
+ const deliver = now && onSendNow ? onSendNow : onSend
237
+ deliver(plainText.trim(), attachments?.readyIds ?? [])
234
238
  attachments?.clear()
235
239
  draft?.clear()
236
240
  clear()
@@ -275,6 +279,11 @@ export function Composer({
275
279
  setHelpOpen(true)
276
280
  return
277
281
  }
282
+ if (e.key === 'Enter' && e.altKey && onSendNow && canSend) {
283
+ e.preventDefault()
284
+ submit(true)
285
+ return
286
+ }
278
287
  if (helpOpen) {
279
288
  setHelpOpen(false)
280
289
  }
@@ -351,9 +360,16 @@ export function Composer({
351
360
  </span>
352
361
  )
353
362
 
363
+ const sendNow =
364
+ onSendNow && canSend && !shellMode ? (
365
+ <button type="button" className="term-press term-link text-label" onClick={() => submit(true)}>
366
+ ⌥↵ send now - moves the running command to the background
367
+ </button>
368
+ ) : null
369
+
354
370
  const interrupting = busy && !canSend
355
371
  const submitButton = terminal ? (
356
- <GlyphButton label="Send" disabled={!canSend} onClick={submit} tone={canSend ? 'blue' : undefined}>
372
+ <GlyphButton label="Send" disabled={!canSend} onClick={() => submit()} tone={canSend ? 'blue' : undefined}>
357
373
  ↵
358
374
  </GlyphButton>
359
375
  ) : interrupting ? (
@@ -361,7 +377,7 @@ export function Composer({
361
377
  <Square className="size-3" />
362
378
  </Button>
363
379
  ) : (
364
- <Button size="icon-sm" aria-label="Send" className="rounded-full" disabled={!canSend} onClick={submit}>
380
+ <Button size="icon-sm" aria-label="Send" className="rounded-full" disabled={!canSend} onClick={() => submit()}>
365
381
  <ArrowUp className="size-4" />
366
382
  </Button>
367
383
  )
@@ -402,7 +418,7 @@ export function Composer({
402
418
  triggers={triggers}
403
419
  markdown={false}
404
420
  normalizeBullets={false}
405
- onSubmit={submit}
421
+ onSubmit={() => submit()}
406
422
  disabled={disabled}
407
423
  placeholder={disabled ? 'Session ended' : shellMode ? SHELL_PLACEHOLDER : placeholder}
408
424
  minHeight={minHeight}
@@ -428,6 +444,12 @@ export function Composer({
428
444
  className={cn('term-composer', shellMode && 'term-composer-shell', disabled && 'opacity-60')}
429
445
  >
430
446
  <div className="term-composer-body">
447
+ {sendNow ? (
448
+ <div className="term-row">
449
+ <span aria-hidden className="term-gutter" />
450
+ {sendNow}
451
+ </div>
452
+ ) : null}
431
453
  {stagedStrip}
432
454
  <div className="term-row">
433
455
  {canAttach && !shellMode ? fileField : null}
@@ -464,6 +486,7 @@ export function Composer({
464
486
  return (
465
487
  <div data-slot="composer" className={cn('px-[var(--wd-composer-padding)] pb-[var(--wd-composer-padding)]', className)}>
466
488
  {helpRow}
489
+ {sendNow ? <div className="mx-auto mb-1 w-full max-w-[var(--wd-transcript-max-width)] text-text-muted">{sendNow}</div> : null}
467
490
  <div
468
491
  {...dropHandlers}
469
492
  className={cn(
@@ -0,0 +1,64 @@
1
+ import type { EngineCapabilities, ModelOption } from '@workerdeck/protocol'
2
+ import { Brain } from 'lucide-react'
3
+ import { Select, SelectContent, SelectItem, SelectItemText, SelectTrigger, SelectValue } from '../ui/Select.tsx'
4
+ import { cn } from '../../lib/utils.ts'
5
+ import { matchModel } from './ModelSelect.tsx'
6
+
7
+ const DEFAULT_VALUE = 'default'
8
+
9
+ export function effortChoices(
10
+ models: readonly ModelOption[],
11
+ model: string | undefined,
12
+ capabilities: Pick<EngineCapabilities, 'reasoningEfforts'>,
13
+ ): readonly string[] {
14
+ return matchModel(models, model)?.reasoningEfforts ?? capabilities.reasoningEfforts ?? []
15
+ }
16
+
17
+ export interface EffortSelectProps {
18
+ efforts: readonly string[]
19
+ effort?: string | null
20
+ onEffortChange: (effort?: string) => void
21
+ variant?: 'toolbar' | 'form'
22
+ disabled?: boolean
23
+ className?: string
24
+ }
25
+
26
+ export function EffortSelect({ efforts, effort, onEffortChange, variant = 'toolbar', disabled, className }: EffortSelectProps) {
27
+ const items = [{ value: DEFAULT_VALUE, label: 'Default' }, ...efforts.map((value) => ({ value, label: value }))]
28
+ return (
29
+ <Select
30
+ items={items}
31
+ value={effort ?? null}
32
+ onValueChange={(value) => {
33
+ if (typeof value !== 'string' || value === effort) {
34
+ return
35
+ }
36
+ onEffortChange(value === DEFAULT_VALUE ? undefined : value)
37
+ }}
38
+ disabled={disabled}
39
+ >
40
+ <SelectTrigger
41
+ aria-label="Reasoning effort"
42
+ className={cn(
43
+ variant === 'toolbar' && 'h-6 max-w-32 border-transparent bg-transparent text-fg-3 hover:bg-surface-hover',
44
+ className,
45
+ )}
46
+ >
47
+ <span className={cn('flex items-center gap-1 truncate', variant === 'toolbar' && 'font-mono text-label')}>
48
+ <Brain className="size-3 shrink-0" />
49
+ <SelectValue placeholder="effort" />
50
+ </span>
51
+ </SelectTrigger>
52
+ <SelectContent className="min-w-40">
53
+ {items.map((item) => (
54
+ <SelectItem key={item.value} value={item.value}>
55
+ <SelectItemText>
56
+ <span className="font-medium">{item.label}</span>
57
+ </SelectItemText>
58
+ {item.value === DEFAULT_VALUE ? <span className="text-label text-fg-4">The model's configured default</span> : null}
59
+ </SelectItem>
60
+ ))}
61
+ </SelectContent>
62
+ </Select>
63
+ )
64
+ }
@@ -39,7 +39,7 @@ function optionMatches(option: ModelOption, model: string): boolean {
39
39
  return token !== '' && token === family(dropVariant(option.value))
40
40
  }
41
41
 
42
- function matchModel(models: ModelOption[], model?: string): ModelOption | undefined {
42
+ export function matchModel(models: readonly ModelOption[], model?: string): ModelOption | undefined {
43
43
  if (!model) {
44
44
  return undefined
45
45
  }
@@ -1,6 +1,6 @@
1
1
  import { useMemo, useState } from 'react'
2
2
  import type { HTMLAttributes, ReactNode } from 'react'
3
- import { Eraser, FolderOpen, Layers, Pencil, Plus, Search, SearchX, Trash2, X } from 'lucide-react'
3
+ import { BedDouble, Eraser, FolderOpen, Layers, Pencil, Plus, Search, SearchX, Trash2, X } from 'lucide-react'
4
4
  import { clearFilters, filterRows, groupRows, hasFacetFilter, scopeActive, sessionKey, subsetSummary } from '@workerdeck/protocol'
5
5
  import type { SessionGroup, SessionRow, SessionTask, StepDisplay, SubagentDisplay, ViewConfig, WorkspaceScope } from '@workerdeck/protocol'
6
6
  import { Button } from '../ui/Button.tsx'
@@ -34,6 +34,7 @@ export interface SessionBrowserProps {
34
34
  onDelete?: (row: SessionRow) => void
35
35
  onRename?: (row: SessionRow, title: string) => void
36
36
  onClearContext?: (row: SessionRow) => void
37
+ onSleep?: (row: SessionRow) => void
37
38
  onSelectSubagent?: (row: SessionRow, toolUseId: string) => void
38
39
  onSelectTask?: (row: SessionRow, task: SessionTask) => void
39
40
  onStopTask?: (row: SessionRow, toolUseId: string) => void
@@ -74,6 +75,7 @@ export function SessionBrowser({
74
75
  onDelete,
75
76
  onRename,
76
77
  onClearContext,
78
+ onSleep,
77
79
  onSelectSubagent,
78
80
  onSelectTask,
79
81
  onStopTask,
@@ -119,7 +121,17 @@ export function SessionBrowser({
119
121
  const target = project && group.hostId ? { hostId: group.hostId, cwd: group.cwd } : undefined
120
122
  return (
121
123
  <GroupHeading
122
- label={group.label}
124
+ label={
125
+ group.gateway && group.project ? (
126
+ <>
127
+ {group.gateway}
128
+ <span aria-hidden> · </span>
129
+ <span className="text-fg-1">{group.project}</span>
130
+ </>
131
+ ) : (
132
+ group.label
133
+ )
134
+ }
123
135
  count={group.rows.length}
124
136
  title={project && group.cwd ? group.cwd : undefined}
125
137
  leading={
@@ -218,6 +230,7 @@ export function SessionBrowser({
218
230
  onDelete={onDelete}
219
231
  onRename={onRename}
220
232
  onClearContext={onClearContext}
233
+ onSleep={onSleep}
221
234
  onSelectSubagent={onSelectSubagent}
222
235
  onSelectTask={onSelectTask}
223
236
  onStopTask={onStopTask}
@@ -259,6 +272,7 @@ interface SessionRowItemProps {
259
272
  onDelete?: (row: SessionRow) => void
260
273
  onRename?: (row: SessionRow, title: string) => void
261
274
  onClearContext?: (row: SessionRow) => void
275
+ onSleep?: (row: SessionRow) => void
262
276
  onSelectSubagent?: (row: SessionRow, toolUseId: string) => void
263
277
  onSelectTask?: (row: SessionRow, task: SessionTask) => void
264
278
  onStopTask?: (row: SessionRow, toolUseId: string) => void
@@ -286,6 +300,7 @@ function SessionRowItem({
286
300
  onDelete,
287
301
  onRename,
288
302
  onClearContext,
303
+ onSleep,
289
304
  onSelectSubagent,
290
305
  onSelectTask,
291
306
  onStopTask,
@@ -336,6 +351,15 @@ function SessionRowItem({
336
351
  <Eraser className="size-3 text-fg-3" />
337
352
  </RowAction>
338
353
  ) : null}
354
+ {onSleep && info.capabilities?.engineSleep && info.status === 'idle' && !info.engineAsleep ? (
355
+ <RowAction
356
+ label="Sleep session"
357
+ title="Stop the engine process to free its memory - the session stays here and wakes on your next message"
358
+ onClick={() => onSleep(row)}
359
+ >
360
+ <BedDouble className="size-3 text-fg-3" />
361
+ </RowAction>
362
+ ) : null}
339
363
  {onDelete ? (
340
364
  <RowAction label="Close session" onClick={() => onDelete(row)}>
341
365
  <Trash2 className="size-3 text-fg-3" />
@@ -191,7 +191,7 @@ export function GroupHeading({
191
191
  className,
192
192
  ...rest
193
193
  }: {
194
- label: string
194
+ label: ReactNode
195
195
  count: number
196
196
  leading?: ReactNode
197
197
  actions?: ReactNode
@@ -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
  }