@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.
@@ -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
  }
@@ -5,7 +5,8 @@ import { compactionText, formatBytes, formatCost, formatDuration, toolInputPrevi
5
5
  import { isMutatingTool } from '../../lib/tool-icon.ts'
6
6
  import { usePulse } from '../agent/pulse.tsx'
7
7
  import { PromptTokenText } from '../agent/PromptTokenText.tsx'
8
- import { ActionPlacementProvider, BookmarkAction, CopyAction, WithActions } from './affordances.tsx'
8
+ import { ActionPlacementProvider, BackgroundAction, BookmarkAction, CopyAction, WithActions } from './affordances.tsx'
9
+ import { canBackground, elapsedLabel, liveTailLines, runStartedAt, runTailLines } from './live-tool.ts'
9
10
  import { TerminalDiff } from './diff.tsx'
10
11
  import { TerminalMarkdown } from './markdown.tsx'
11
12
  import { usePeerNames } from './peer-names.tsx'
@@ -191,6 +192,9 @@ export function ToolRow({ item }: { item: ToolCallItem }) {
191
192
  const busy = status === 'running' || status === 'pending'
192
193
  const isError = status === 'failed' || item.result?.isError === true
193
194
  const pulse = usePulse(busy)
195
+ const now = useTicker(busy && item.ts !== undefined)
196
+ const elapsed = busy ? elapsedLabel(item.ts, now) : undefined
197
+ const tail = liveTailLines(item)
194
198
 
195
199
  const text = item.result?.text ?? ''
196
200
  const lines = text.trimEnd().split('\n')
@@ -214,6 +218,7 @@ export function ToolRow({ item }: { item: ToolCallItem }) {
214
218
  <WithActions
215
219
  actions={
216
220
  <>
221
+ {canBackground(item) ? <BackgroundAction toolUseId={item.id} /> : null}
217
222
  <BookmarkAction id={item.id} />
218
223
  {copyable ? <CopyAction text={copyable} label="Copy" /> : null}
219
224
  </>
@@ -225,10 +230,18 @@ export function ToolRow({ item }: { item: ToolCallItem }) {
225
230
  {title ?? item.name}
226
231
  </Ink>
227
232
  <Ink tone="dim">({todos ? todos.summary : toolInputPreview(item.input)})</Ink>
233
+ {elapsed ? (
234
+ <Ink tone="faint">
235
+ {' '}
236
+ ·{'\u00a0'}
237
+ {elapsed}
238
+ </Ink>
239
+ ) : null}
228
240
  {title && open ? <Ink tone="faint"> · {item.name}</Ink> : null}
229
241
  {item.backend && item.backend !== 'server' ? <Ink tone="faint"> · {item.backend}</Ink> : null}
230
242
  </Row>
231
243
  </Pressable>
244
+ <LiveTail lines={tail} />
232
245
  {hostPath !== undefined ? <TerminalHostImage path={hostPath} /> : null}
233
246
  {item.result?.images?.map((image) => (
234
247
  <TerminalImage key={image.partIndex} toolUseId={item.id} name={resultImageName(item.name, image)} image={image} />
@@ -373,14 +386,25 @@ export function ToolRunRow({ items }: { items: ToolCallItem[] }) {
373
386
  })
374
387
  const failed = runFailed(items)
375
388
  const pulse = usePulse(busy)
389
+ const startedAt = runStartedAt(items)
390
+ const now = useTicker(startedAt !== undefined)
391
+ const elapsed = elapsedLabel(startedAt, now)
376
392
 
377
393
  return (
378
394
  <div ref={reveal} className={open ? 'term-open' : undefined}>
379
395
  <Pressable onPress={() => setOpen((v) => !v)} expanded={open}>
380
396
  <Row glyph={busy ? pulse : undefined} glyphTone={busy ? 'mark' : undefined} tone={failed ? 'red' : 'dim'}>
381
397
  {busy ? <span className="term-shimmer">{runSummary(items, busy)}</span> : runSummary(items, busy)}
398
+ {elapsed ? (
399
+ <Ink tone="faint">
400
+ {' '}
401
+ ·{'\u00a0'}
402
+ {elapsed}
403
+ </Ink>
404
+ ) : null}
382
405
  </Row>
383
406
  </Pressable>
407
+ {open ? null : <LiveTail lines={runTailLines(items)} />}
384
408
  {open ? (
385
409
  <div>
386
410
  {items.map((item, index) => (
@@ -511,6 +535,14 @@ export function FileRow({ item, href }: { item: Extract<TranscriptItem, { kind:
511
535
  )
512
536
  }
513
537
 
538
+ function LiveTail({ lines }: { lines: string[] }) {
539
+ return lines.map((line, index) => (
540
+ <Row key={index} indent={1} columns={3} glyph={index === 0 ? '⎿' : undefined} tone="faint">
541
+ {line || ' '}
542
+ </Row>
543
+ ))
544
+ }
545
+
514
546
  export function useTicker(on: boolean): number {
515
547
  const [now, setNow] = useState(() => Date.now())
516
548
  useEffect(() => {
@@ -0,0 +1,71 @@
1
+ import { formatDuration } from '../../lib/format.ts'
2
+ import type { TranscriptItem } from '@workerdeck/react'
3
+ import type { ToolCallItem } from './blocks.ts'
4
+
5
+ export const LIVE_TAIL_LINES = 5
6
+ export const ELAPSED_AFTER_MS = 5000
7
+ export const SEND_NOW_AFTER_MS = 5000
8
+ // The widest label the header grows by while busy, so a height estimate never undercounts a wrap.
9
+ export const ELAPSED_WIDEST = ' ·\u00a059m\u00a059s'
10
+
11
+ const BACKGROUNDABLE = new Set(['Bash', 'Task', 'Agent'])
12
+
13
+ export function toolBusy(item: ToolCallItem): boolean {
14
+ const status = item.status ?? (item.result === undefined ? 'running' : 'settled')
15
+ return status === 'running' || status === 'pending'
16
+ }
17
+
18
+ export function liveTailLines(item: ToolCallItem): string[] {
19
+ if (!item.liveTail || !toolBusy(item)) {
20
+ return []
21
+ }
22
+ return item.liveTail.split('\n').slice(-LIVE_TAIL_LINES)
23
+ }
24
+
25
+ export function elapsedLabel(startedAt: number | undefined, now: number): string | undefined {
26
+ if (startedAt === undefined || now - startedAt < ELAPSED_AFTER_MS) {
27
+ return undefined
28
+ }
29
+ return formatDuration(Math.floor((now - startedAt) / 1000) * 1000).replace(' ', '\u00a0')
30
+ }
31
+
32
+ export function runStartedAt(items: readonly ToolCallItem[]): number | undefined {
33
+ let earliest: number | undefined
34
+ for (const item of items) {
35
+ if (toolBusy(item) && item.ts !== undefined && (earliest === undefined || item.ts < earliest)) {
36
+ earliest = item.ts
37
+ }
38
+ }
39
+ return earliest
40
+ }
41
+
42
+ export function canBackground(item: ToolCallItem): boolean {
43
+ return BACKGROUNDABLE.has(item.name) && item.parentToolUseId == null && toolBusy(item)
44
+ }
45
+
46
+ export function runTailLines(items: readonly ToolCallItem[]): string[] {
47
+ for (let index = items.length - 1; index >= 0; index--) {
48
+ const lines = liveTailLines(items[index]!)
49
+ if (lines.length > 0) {
50
+ return lines
51
+ }
52
+ }
53
+ return []
54
+ }
55
+
56
+ // Walks back only through the current turn: a running call is never older than the last prompt.
57
+ // The earliest start among them, or 0 when a call carries no `ts`.
58
+ export function backgroundableSince(items: readonly TranscriptItem[]): number | undefined {
59
+ let since: number | undefined
60
+ for (let index = items.length - 1; index >= 0; index--) {
61
+ const item = items[index]!
62
+ if (item.kind === 'user' && item.parentToolUseId == null) {
63
+ break
64
+ }
65
+ if (item.kind === 'tool_call' && canBackground(item)) {
66
+ const ts = item.ts ?? 0
67
+ since = since === undefined ? ts : Math.min(since, ts)
68
+ }
69
+ }
70
+ return since
71
+ }
package/src/index.ts CHANGED
@@ -123,6 +123,7 @@ export {
123
123
  type ComposerRow,
124
124
  } from './components/agent/composer-commands.ts'
125
125
  export { ModelSelect, type ModelSelectProps } from './components/agent/ModelSelect.tsx'
126
+ export { EffortSelect, effortChoices, type EffortSelectProps } from './components/agent/EffortSelect.tsx'
126
127
  export {
127
128
  PERMISSION_MODES,
128
129
  PermissionModeSelect,
@@ -483,9 +483,13 @@
483
483
  :is([data-terminal], [data-cards]) [data-slot='transcript-rows'] [data-index]:not(.absolute) {
484
484
  position: relative;
485
485
  }
486
- :is([data-terminal], [data-cards]) [data-slot='transcript-rows'] [data-index]:is(:hover, :focus-within) {
486
+ :is([data-terminal], [data-cards]) [data-slot='transcript-rows'] [data-index]:focus-within {
487
487
  z-index: 1;
488
488
  }
489
+ /* Above a focused row too: the row just clicked open keeps focus and would cover the bar beneath the hovered one. */
490
+ :is([data-terminal], [data-cards]) [data-slot='transcript-rows'] [data-index]:hover {
491
+ z-index: 2;
492
+ }
489
493
  @media (hover: none) {
490
494
  :is([data-terminal], [data-cards]) .term-actions {
491
495
  opacity: 1;
@@ -785,9 +789,12 @@ button.term-link {
785
789
  * it is the same `--bg`, and the row it covers is its own content.
786
790
  */
787
791
  [data-terminal] [data-slot='transcript-rows'] > [data-sticky-lane] {
788
- z-index: 3;
789
792
  pointer-events: none;
790
793
  }
794
+ /* Raised only while pinned: a raised idle lane covered the hover bar of the row above its prompt. */
795
+ [data-terminal] [data-slot='transcript-rows'] > [data-sticky-lane]:has(> [data-sticky-headlane] > [data-stuck]) {
796
+ z-index: 3;
797
+ }
791
798
 
792
799
  /* The prompt itself is *inside* the lane, not beneath it, so the lane's `none`
793
800
  reached it too and made a user message the one block in the transcript the
@@ -743,9 +743,11 @@
743
743
  * row's first line, so it has no in-flow y to match, and the 1st prompt (no
744
744
  * gap) and every later one (pt-4/pt-2) share these rules unchanged. */
745
745
  [data-slot='transcript-rows'] > [data-sticky-lane='cards'] {
746
- z-index: 3;
747
746
  pointer-events: none;
748
747
  }
748
+ [data-slot='transcript-rows'] > [data-sticky-lane='cards']:has(> [data-sticky-headlane] > [data-stuck]) {
749
+ z-index: 3;
750
+ }
749
751
 
750
752
  /* The head's own strip: the full lane, out of flow - see terminal.css for why
751
753
  sticky confinement wants the border box, not a cancelled margin box. */