@workerdeck/ui 2.10.0 → 2.11.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 { At as Spinner, B as EntryIcon, Ft as TooltipProvider, Nt as Tip, Ot as Splitter, an as Button, i as SessionPanel, n as usePathLinks, nn as Input, sn as cn, t as resolveAgainstCwd } from "./use-path-links-CvcxdUXL.mjs";
1
+ import { At as Spinner, B as EntryIcon, Ft as TooltipProvider, Nt as Tip, Ot as Splitter, an as Button, i as SessionPanel, n as usePathLinks, nn as Input, sn as cn, t as resolveAgainstCwd } from "./use-path-links-DO_79-wX.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": "2.10.0",
3
+ "version": "2.11.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/client": "2.10.0",
43
- "@workerdeck/react": "2.10.0",
44
- "@workerdeck/protocol": "2.10.0"
42
+ "@workerdeck/client": "2.11.0",
43
+ "@workerdeck/protocol": "2.11.0",
44
+ "@workerdeck/react": "2.11.0"
45
45
  },
46
46
  "peerDependencies": {
47
47
  "monaco-editor": "^0.56.0",
@@ -1,6 +1,6 @@
1
1
  import { Fragment, useEffect, useRef, useState } from 'react'
2
2
  import type { ReactNode } from 'react'
3
- import { projectLabel, projectSubpath, sessionLabel } from '@workerdeck/protocol'
3
+ import { projectLabel, projectName, projectSubpath, sessionLabel } from '@workerdeck/protocol'
4
4
  import type { SessionRow } from '@workerdeck/protocol'
5
5
  import { ContextRing } from './ContextRing.tsx'
6
6
  import { EngineIcon, vendorMarkClass, vendorTextClass } from './EngineIcon.tsx'
@@ -68,6 +68,7 @@ export function SessionItem({
68
68
 
69
69
  const engine = info.engine ?? 'claude'
70
70
  const project = showProject ? projectLabel(row) : projectSubpath(row)
71
+ const projectTitle = showProject ? projectName(row) : undefined
71
72
  const projectIcon = showProject ? info.project?.icon : undefined
72
73
  const iconSrc = projectIcon?.type === 'image' ? projectIcons?.[projectIcon.hash] : undefined
73
74
  const cost = formatCost(info.costUsd ?? info.totalCostUsd)
@@ -88,8 +89,8 @@ export function SessionItem({
88
89
  }
89
90
  if (project !== undefined) {
90
91
  parts.push(
91
- <span key="project">
92
- <ProjectIcon icon={projectIcon} src={iconSrc} name={project} className="mr-1.5 size-4 align-[-0.3em]" />
92
+ <span key="project" title={projectTitle}>
93
+ <ProjectIcon icon={projectIcon} src={iconSrc} name={projectTitle} className="mr-1.5 size-4 align-[-0.3em]" />
93
94
  {project}
94
95
  </span>,
95
96
  )
@@ -264,6 +264,7 @@ export function SessionPanel({
264
264
  replaying,
265
265
  protocolMismatch,
266
266
  shell,
267
+ pricing,
267
268
  models,
268
269
  effectiveModel,
269
270
  handle,
@@ -835,6 +836,7 @@ export function SessionPanel({
835
836
  totalCostUsd={state.totalCostUsd}
836
837
  costUsd={state.costUsd}
837
838
  usageByModel={state.usageByModel}
839
+ pricing={pricing}
838
840
  spend={profileSpend}
839
841
  updatedAt={usageUpdatedAt}
840
842
  open={panel === 'usage'}
@@ -10,6 +10,7 @@ import { Response } from './Response.tsx'
10
10
  import { ToolCallCard } from './ToolCallCard.tsx'
11
11
  import { Row } from '../terminal/row.tsx'
12
12
  import { TerminalItemView } from '../terminal/TerminalTranscript.tsx'
13
+ import { peerLabel } from '../terminal/items.tsx'
13
14
 
14
15
  function TurnResultRow({ item }: { item: Extract<TranscriptItem, { kind: 'turn_result' }> }) {
15
16
  return (
@@ -69,6 +70,7 @@ export function TranscriptItemView({
69
70
  case 'user': {
70
71
  return (
71
72
  <Message from="user">
73
+ {item.origin ? <div className="wd-peer-origin text-xs text-muted-foreground">{peerLabel(item.origin)}</div> : null}
72
74
  {item.attachments?.length ? <SentAttachments attachments={item.attachments} attachmentUrl={attachmentUrl} /> : null}
73
75
  {item.text ? (
74
76
  <MessageContent>
@@ -6,14 +6,15 @@ import type { PermissionRequest } from '@workerdeck/protocol'
6
6
  import type { TranscriptItem } from '@workerdeck/react'
7
7
  import { cn } from '../../lib/utils.ts'
8
8
  import { resolveAffordances, type TerminalAffordances } from '../terminal/affordances.tsx'
9
- import { ToolRunRow } from '../terminal/items.tsx'
9
+ import { RunRow } from '../terminal/items.tsx'
10
10
  import { parentOf } from '../terminal/blocks.ts'
11
11
  import { briefPx, estimateBlockPx } from '../terminal/height.ts'
12
+ import { PeerNamesProvider } from '../terminal/peer-names.tsx'
12
13
  import { TerminalScrubber } from '../terminal/scrubber.tsx'
13
14
  import { Scrubber } from './Scrubber.tsx'
14
15
  import { gapBefore, positionInRow, rowIndexForItem, type TranscriptRow } from './transcript-rows.ts'
15
16
  import { useHeightEpoch } from './use-height-epoch.ts'
16
- import { useTranscriptJumps } from './use-transcript-jumps.ts'
17
+ import { absorbScrollerResize, useTranscriptJumps } from './use-transcript-jumps.ts'
17
18
  import { BriefRow, TaskRow } from '../terminal/TerminalTranscript.tsx'
18
19
  import { RecapRow, TranscriptItemView } from './TranscriptItemView.tsx'
19
20
 
@@ -167,9 +168,10 @@ export function TranscriptRows({
167
168
  if (height === last) {
168
169
  return
169
170
  }
171
+ const difference = height - last
170
172
  last = height
171
173
  if (stick.state.isAtBottom) {
172
- void stick.scrollToBottom('instant')
174
+ absorbScrollerResize(stick, difference)
173
175
  }
174
176
  })
175
177
  observer.observe(scrollElement)
@@ -318,64 +320,66 @@ export function TranscriptRows({
318
320
 
319
321
  return (
320
322
  <div ref={rowsRef} data-slot="transcript-rows" className="relative w-full" style={{ height: virtualizer.getTotalSize() }}>
321
- {virtualizer.getVirtualItems().map((virtualRow) => {
322
- const row = rows[virtualRow.index]
323
- const gapClass = virtualRow.index > 0 && (!terminal || gapBefore(rows, virtualRow.index)) ? gap.className : undefined
324
- const content =
325
- 'run' in row ? (
326
- <div className={cn(read(boundary, row.index) && 'opacity-45')}>
327
- <ToolRunRow items={row.run} />
328
- </div>
329
- ) : 'item' in row ? (
330
- <div className={cn(read(boundary, row.index) && 'opacity-45', nestedClass(row.item, frameParentId))}>
331
- <TranscriptItemView
332
- item={row.item}
333
- fileUrl={fileUrl}
334
- attachmentUrl={attachmentUrl}
335
- hostImage={hostImage}
323
+ <PeerNamesProvider items={items}>
324
+ {virtualizer.getVirtualItems().map((virtualRow) => {
325
+ const row = rows[virtualRow.index]
326
+ const gapClass = virtualRow.index > 0 && (!terminal || gapBefore(rows, virtualRow.index)) ? gap.className : undefined
327
+ const content =
328
+ 'run' in row ? (
329
+ <div className={cn(read(boundary, row.index) && 'opacity-45')}>
330
+ <RunRow items={row.run} />
331
+ </div>
332
+ ) : 'item' in row ? (
333
+ <div className={cn(read(boundary, row.index) && 'opacity-45', nestedClass(row.item, frameParentId))}>
334
+ <TranscriptItemView
335
+ item={row.item}
336
+ fileUrl={fileUrl}
337
+ attachmentUrl={attachmentUrl}
338
+ hostImage={hostImage}
339
+ terminal={terminal}
340
+ />
341
+ </div>
342
+ ) : 'text' in row && row.key === 'brief' ? (
343
+ <BriefRow text={row.text} terminal={terminal} />
344
+ ) : 'line' in row ? (
345
+ <RecapRow line={row.line} since={since} terminal={terminal} />
346
+ ) : (
347
+ <div className={cn(read(boundary, row.index) && 'opacity-45')}>
348
+ <TaskRow block={row} fileUrl={fileUrl} onOpenSubagent={onOpenSubagent} />
349
+ </div>
350
+ )
351
+ if (stickyPrompt && 'item' in row && row.item.kind === 'user' && parentOf(row.item) === undefined) {
352
+ const next = promptRows.find((index) => index > virtualRow.index)
353
+ const laneEnd = next === undefined ? virtualizer.getTotalSize() : (measurements[next]?.start ?? virtualRow.start)
354
+ return (
355
+ <StickyPromptLane
356
+ key={row.key}
357
+ top={virtualRow.start}
358
+ height={Math.max(laneEnd - virtualRow.start, 0)}
359
+ gapClass={gapClass}
360
+ gapPx={gap.px}
336
361
  terminal={terminal}
362
+ scrollRoot={scrollElement}
363
+ index={virtualRow.index}
364
+ measureRef={virtualizer.measureElement}
365
+ head={terminal ? content : promptHeadText(row.item)}
366
+ content={content}
337
367
  />
338
- </div>
339
- ) : 'text' in row && row.key === 'brief' ? (
340
- <BriefRow text={row.text} terminal={terminal} />
341
- ) : 'line' in row ? (
342
- <RecapRow line={row.line} since={since} terminal={terminal} />
343
- ) : (
344
- <div className={cn(read(boundary, row.index) && 'opacity-45')}>
345
- <TaskRow block={row} fileUrl={fileUrl} onOpenSubagent={onOpenSubagent} />
346
- </div>
347
- )
348
- if (stickyPrompt && 'item' in row && row.item.kind === 'user' && parentOf(row.item) === undefined) {
349
- const next = promptRows.find((index) => index > virtualRow.index)
350
- const laneEnd = next === undefined ? virtualizer.getTotalSize() : (measurements[next]?.start ?? virtualRow.start)
368
+ )
369
+ }
351
370
  return (
352
- <StickyPromptLane
371
+ <div
353
372
  key={row.key}
354
- top={virtualRow.start}
355
- height={Math.max(laneEnd - virtualRow.start, 0)}
356
- gapClass={gapClass}
357
- gapPx={gap.px}
358
- terminal={terminal}
359
- scrollRoot={scrollElement}
360
- index={virtualRow.index}
361
- measureRef={virtualizer.measureElement}
362
- head={terminal ? content : promptHeadText(row.item)}
363
- content={content}
364
- />
373
+ ref={virtualizer.measureElement}
374
+ data-index={virtualRow.index}
375
+ className={cn('absolute inset-x-0 top-0', gapClass)}
376
+ style={{ transform: `translateY(${virtualRow.start}px)` }}
377
+ >
378
+ {content}
379
+ </div>
365
380
  )
366
- }
367
- return (
368
- <div
369
- key={row.key}
370
- ref={virtualizer.measureElement}
371
- data-index={virtualRow.index}
372
- className={cn('absolute inset-x-0 top-0', gapClass)}
373
- style={{ transform: `translateY(${virtualRow.start}px)` }}
374
- >
375
- {content}
376
- </div>
377
- )
378
- })}
381
+ })}
382
+ </PeerNamesProvider>
379
383
  {scrubber && scrollElement?.parentElement
380
384
  ? createPortal(
381
385
  terminal ? (
@@ -1,5 +1,5 @@
1
- import type { ByModel, ProfileEngine, ProfileSpend, UsageWindowRow } from '@workerdeck/protocol'
2
- import { PRICING_NOTE } from '@workerdeck/protocol'
1
+ import type { ByModel, PricingTable, ProfileEngine, ProfileSpend, UsageWindowRow } from '@workerdeck/protocol'
2
+ import { PRICING_NOTE, pricingAgeNote } from '@workerdeck/protocol'
3
3
  import { Badge } from '../ui/Badge.tsx'
4
4
  import { Dialog, DialogBody, DialogContent, DialogHeader } from '../ui/Dialog.tsx'
5
5
  import { formatAgoPrecise, formatCost, formatTokens } from '../../lib/format.ts'
@@ -13,6 +13,7 @@ export interface UsageDialogProps {
13
13
  totalCostUsd?: number
14
14
  costUsd?: number
15
15
  usageByModel?: ByModel
16
+ pricing?: PricingTable
16
17
  spend?: ProfileSpend
17
18
  updatedAt?: number
18
19
  open: boolean
@@ -20,6 +21,10 @@ export interface UsageDialogProps {
20
21
  className?: string
21
22
  }
22
23
 
24
+ function engineLabel(engine: ProfileEngine): string {
25
+ return engine === 'claude' ? 'Claude Code' : engine
26
+ }
27
+
23
28
  function Line({ label, value, tone }: { label: string; value: string; tone?: 'muted' }) {
24
29
  return (
25
30
  <div className="flex items-baseline justify-between gap-4">
@@ -62,9 +67,15 @@ function CostEvidence({ cost, engine }: { cost: SessionCost; engine: ProfileEngi
62
67
  rather than an estimate.
63
68
  </p>
64
69
  ) : null}
70
+ {cost.unknownBasisModels.length > 0 ? (
71
+ <p className="text-label text-warning">
72
+ {engineLabel(engine)} matched no rate table of its own to {cost.unknownBasisModels.join(', ')}, so the figure it reports for those
73
+ tokens is a guess rather than a rate.
74
+ </p>
75
+ ) : null}
65
76
  {cost.gapUsd !== undefined ? (
66
77
  <p className="text-label text-fg-4">
67
- {engine === 'claude' ? 'Claude Code' : engine} reports {formatCost(cost.reportedUsd)} for the same tokens.
78
+ {engineLabel(engine)} reports {formatCost(cost.reportedUsd)} for the same tokens.
68
79
  </p>
69
80
  ) : null}
70
81
  </div>
@@ -98,6 +109,7 @@ export function UsageDialog({
98
109
  totalCostUsd,
99
110
  costUsd,
100
111
  usageByModel,
112
+ pricing,
101
113
  spend,
102
114
  updatedAt,
103
115
  open,
@@ -105,13 +117,14 @@ export function UsageDialog({
105
117
  className,
106
118
  }: UsageDialogProps) {
107
119
  const now = useMinuteClock(open)
108
- const cost = sessionCost({ costUsd, totalCostUsd, usageByModel })
120
+ const cost = sessionCost({ costUsd, totalCostUsd, usageByModel, pricing })
121
+ const ageNote = pricingAgeNote(now)
109
122
  return (
110
123
  <Dialog open={open} onOpenChange={onOpenChange}>
111
124
  <DialogContent className={className}>
112
125
  <DialogHeader
113
126
  title="Usage"
114
- description={engine === 'claude' ? 'Claude Code' : engine}
127
+ description={engineLabel(engine)}
115
128
  actions={
116
129
  subscriptionType ? (
117
130
  <Badge variant="accent" className="mt-0.5 shrink-0 capitalize">
@@ -134,7 +147,7 @@ export function UsageDialog({
134
147
  <CostEvidence cost={cost} engine={engine} />
135
148
  </div>
136
149
  {spend ? <ProfileSpendSection spend={spend} /> : null}
137
- <p className="mt-3 text-label text-fg-4">{PRICING_NOTE}</p>
150
+ <p className="mt-3 text-label text-fg-4">{ageNote ? `${PRICING_NOTE} ${ageNote}` : PRICING_NOTE}</p>
138
151
  {updatedAt ? <p className="mt-2 text-label text-fg-4">Updated {formatAgoPrecise(updatedAt, now)}</p> : null}
139
152
  </DialogBody>
140
153
  </DialogContent>
@@ -1,9 +1,57 @@
1
- import { useEffect, useState } from 'react'
1
+ import { useEffect, useState, useSyncExternalStore } from 'react'
2
2
 
3
- export const PULSE_FRAMES = ['⋄', '◇', '◈', '◆'] as const
4
- export const PULSE_MS = 150
3
+ export const PULSE_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] as const
4
+ export const PULSE_MS = 90
5
5
 
6
- export const PULSE_REST = PULSE_FRAMES[PULSE_FRAMES.length - 1]
6
+ export const PULSE_REST = '⠿'
7
+
8
+ const listeners = new Set<() => void>()
9
+ let ticker: ReturnType<typeof setInterval> | undefined
10
+ let current = PULSE_REST
11
+
12
+ export function pulseFrameAt(now: number): string {
13
+ return PULSE_FRAMES[Math.floor(now / PULSE_MS) % PULSE_FRAMES.length]!
14
+ }
15
+
16
+ function tick(): void {
17
+ const next = pulseFrameAt(Date.now())
18
+ if (next === current) {
19
+ return
20
+ }
21
+ current = next
22
+ for (const listener of listeners) {
23
+ listener()
24
+ }
25
+ }
26
+
27
+ function subscribe(listener: () => void): () => void {
28
+ listeners.add(listener)
29
+ if (ticker === undefined) {
30
+ current = pulseFrameAt(Date.now())
31
+ ticker = setInterval(tick, PULSE_MS)
32
+ }
33
+ return () => {
34
+ listeners.delete(listener)
35
+ if (listeners.size === 0 && ticker !== undefined) {
36
+ clearInterval(ticker)
37
+ ticker = undefined
38
+ }
39
+ }
40
+ }
41
+
42
+ function subscribeRest(): () => void {
43
+ return unsubscribeRest
44
+ }
45
+
46
+ function unsubscribeRest(): void {}
47
+
48
+ function readFrame(): string {
49
+ return current
50
+ }
51
+
52
+ function readRest(): string {
53
+ return PULSE_REST
54
+ }
7
55
 
8
56
  export function usePrefersReducedMotion(): boolean {
9
57
  const [reduced, setReduced] = useState(false)
@@ -23,16 +71,5 @@ export function usePrefersReducedMotion(): boolean {
23
71
  export function usePulse(animated: boolean): string {
24
72
  const reduced = usePrefersReducedMotion()
25
73
  const running = animated && !reduced
26
- const [frame, setFrame] = useState(0)
27
- useEffect(() => {
28
- if (!running) {
29
- return
30
- }
31
- const timer = setInterval(() => setFrame((f) => f + 1), PULSE_MS)
32
- return () => clearInterval(timer)
33
- }, [running])
34
- if (!running) {
35
- return PULSE_REST
36
- }
37
- return PULSE_FRAMES[frame % PULSE_FRAMES.length]!
74
+ return useSyncExternalStore(running ? subscribe : subscribeRest, running ? readFrame : readRest, readRest)
38
75
  }
@@ -26,6 +26,31 @@ export function repinToBottom(stick: RepinTarget): void {
26
26
  state.scrollTop = state.calculatedTargetScrollTop
27
27
  }
28
28
 
29
+ // A scroller whose height changes clamps `scrollTop`, and the browser reports that clamp as an
30
+ // ordinary scroll event, which the library reads as scroll-up intent and answers by escaping the
31
+ // bottom lock. Its guard against exactly that, `resizeDifference`, is set only by the observer on
32
+ // the *content*, so a composer collapsing after send breaks the lock while the view still sits at
33
+ // the bottom, and the turn's later output streams past unfollowed (GOTCHAS "A scroller resize
34
+ // escapes the bottom lock"). Set the flag ourselves, on the library's schedule. A live send re-pin
35
+ // owns `state.animation` and `scrollToBottom` would clear it, dropping the `ignoreEscapes` hold
36
+ // into the momentum tail, so press that scroll by hand instead.
37
+ export function absorbScrollerResize(stick: RepinTarget, difference: number): void {
38
+ const { state } = stick
39
+ state.resizeDifference = difference
40
+ requestAnimationFrame(() => {
41
+ setTimeout(() => {
42
+ if (state.resizeDifference === difference) {
43
+ state.resizeDifference = 0
44
+ }
45
+ }, 1)
46
+ })
47
+ if (state.animation?.ignoreEscapes) {
48
+ state.scrollTop = state.calculatedTargetScrollTop
49
+ return
50
+ }
51
+ void stick.scrollToBottom('instant')
52
+ }
53
+
29
54
  export function useTranscriptJumps(options: {
30
55
  rows: TranscriptRow[]
31
56
  terminal: boolean
@@ -8,7 +8,8 @@ import {
8
8
  CompactionRow,
9
9
  FileRow,
10
10
  NoticeRow,
11
- ToolRunRow,
11
+ PeerSendRow,
12
+ RunRow,
12
13
  ThinkingRow,
13
14
  ToolRow,
14
15
  TurnResultRow,
@@ -17,8 +18,9 @@ import {
17
18
  } from './items.tsx'
18
19
  import { blockNeedsBlank, taskChildItems, terminalBlocks, type TaskBlock } from './blocks.ts'
19
20
  import { usePulse } from '../agent/pulse.tsx'
21
+ import { PeerNamesProvider } from './peer-names.tsx'
20
22
  import { Pressable, useRevealOnOpen } from './press.tsx'
21
- import { taskBrief, taskBusy, taskFailed, taskSummary } from './tool-run.ts'
23
+ import { isPeerSend, taskBrief, taskBusy, taskFailed, taskSummary } from './tool-run.ts'
22
24
  import { BRIEF_LINES } from './height.ts'
23
25
  import { Blank, Row } from './row.tsx'
24
26
  import { TerminalSurface } from './surface.tsx'
@@ -44,7 +46,7 @@ export function TerminalItemView({ item, fileUrl }: { item: TranscriptItem; file
44
46
  return <ThinkingRow item={item} />
45
47
  }
46
48
  case 'tool_call': {
47
- return <ToolRow item={item} />
49
+ return isPeerSend(item) ? <PeerSendRow item={item} /> : <ToolRow item={item} />
48
50
  }
49
51
  case 'turn_result': {
50
52
  return <TurnResultRow item={item} />
@@ -110,7 +112,7 @@ export function TaskRow({
110
112
  <div ref={reveal} className={open ? 'term-open' : undefined}>
111
113
  <Pressable onPress={() => setOpen((v) => !v)} expanded={open}>
112
114
  <Row glyph={busy ? pulse : '●'} glyphTone={failed ? 'red' : busy ? 'mark' : 'dim'} tone={failed ? 'red' : 'green'}>
113
- {taskSummary(block.task, children)}
115
+ {busy ? <span className="term-shimmer">{taskSummary(block.task, children)}</span> : taskSummary(block.task, children)}
114
116
  </Row>
115
117
  </Pressable>
116
118
  {open ? (
@@ -119,7 +121,7 @@ export function TaskRow({
119
121
  {block.children.map((leaf, index) => (
120
122
  <Fragment key={leaf.key}>
121
123
  {index > 0 && blockNeedsBlank(block.children[index - 1]!, leaf) ? <Blank /> : null}
122
- {'run' in leaf ? <ToolRunRow items={leaf.run} /> : <TerminalItemView item={leaf.item} fileUrl={fileUrl} />}
124
+ {'run' in leaf ? <RunRow items={leaf.run} /> : <TerminalItemView item={leaf.item} fileUrl={fileUrl} />}
123
125
  </Fragment>
124
126
  ))}
125
127
  </div>
@@ -170,28 +172,30 @@ export function TerminalTranscript({ state, fileUrl, fontSize, lineHeight, affor
170
172
  bleed="1ch"
171
173
  className={cn('term-transcript', className)}
172
174
  >
173
- {blocks.map((block, index) => (
174
- <Fragment key={block.key}>
175
- {index > 0 && blockNeedsBlank(blocks[index - 1]!, block) ? <Blank /> : null}
176
- {'run' in block ? (
177
- <ToolRunRow items={block.run} />
178
- ) : 'item' in block ? (
179
- <TerminalItemView item={block.item} fileUrl={fileUrl} />
180
- ) : (
181
- <TaskRow block={block} fileUrl={fileUrl} />
182
- )}
183
- </Fragment>
184
- ))}
185
- {working(state) ? (
186
- <>
187
- {state.items.length > 0 ? <Blank /> : null}
188
- <WorkingRow
189
- label={state.status === 'starting' ? 'Starting…' : 'Working…'}
190
- startedAt={runStartedAt}
191
- tokens={state.contextUsage?.totalTokens}
192
- />
193
- </>
194
- ) : null}
175
+ <PeerNamesProvider items={state.items}>
176
+ {blocks.map((block, index) => (
177
+ <Fragment key={block.key}>
178
+ {index > 0 && blockNeedsBlank(blocks[index - 1]!, block) ? <Blank /> : null}
179
+ {'run' in block ? (
180
+ <RunRow items={block.run} />
181
+ ) : 'item' in block ? (
182
+ <TerminalItemView item={block.item} fileUrl={fileUrl} />
183
+ ) : (
184
+ <TaskRow block={block} fileUrl={fileUrl} />
185
+ )}
186
+ </Fragment>
187
+ ))}
188
+ {working(state) ? (
189
+ <>
190
+ {state.items.length > 0 ? <Blank /> : null}
191
+ <WorkingRow
192
+ label={state.status === 'starting' ? 'Starting…' : 'Working…'}
193
+ startedAt={runStartedAt}
194
+ tokens={state.contextUsage?.totalTokens}
195
+ />
196
+ </>
197
+ ) : null}
198
+ </PeerNamesProvider>
195
199
  </TerminalSurface>
196
200
  )
197
201
  }
@@ -1,5 +1,5 @@
1
1
  import type { TranscriptItem } from '@workerdeck/react'
2
- import { foldsTogether } from './tool-run.ts'
2
+ import { foldsTogether, isPeerSend } from './tool-run.ts'
3
3
 
4
4
  export type ToolCallItem = Extract<TranscriptItem, { kind: 'tool_call' }>
5
5
 
@@ -42,7 +42,7 @@ export function taskChildItems(block: TaskBlock): TranscriptItem[] {
42
42
 
43
43
  function pushLeaf(out: LeafBlock[], item: TranscriptItem, index: number): void {
44
44
  const previous = out.at(-1)
45
- if (isRunCall(item)) {
45
+ if (isRunCall(item) && !isPeerSend(item)) {
46
46
  if (previous && 'run' in previous && foldsTogether(previous.run[0]!, item)) {
47
47
  previous.run.push(item)
48
48
  previous.indices.push(index)
@@ -112,13 +112,21 @@ export function terminalBlocks(items: readonly TranscriptItem[], offset = 0, fol
112
112
  }
113
113
 
114
114
  export function needsBlank(previous: TranscriptItem, next: TranscriptItem): boolean {
115
- if (previous.kind === 'tool_call' && next.kind === 'tool_call') {
116
- return false
115
+ if (isPeerSend(previous) || isPeerSend(next)) {
116
+ return true
117
117
  }
118
- return true
118
+ return !(previous.kind === 'tool_call' && next.kind === 'tool_call')
119
+ }
120
+
121
+ function blockKind(block: TerminalBlock): string {
122
+ if (!('item' in block)) {
123
+ return 'tool_call'
124
+ }
125
+ // A peer send is a tool call on the wire and a message on the page, so it takes the blank line
126
+ // every other message gets rather than sitting flush against the run above it.
127
+ return isPeerSend(block.item) ? 'peer_send' : block.item.kind
119
128
  }
120
129
 
121
130
  export function blockNeedsBlank(previous: TerminalBlock, next: TerminalBlock): boolean {
122
- const kind = (block: TerminalBlock) => ('item' in block ? block.item.kind : 'tool_call')
123
- return !(kind(previous) === 'tool_call' && kind(next) === 'tool_call')
131
+ return !(blockKind(previous) === 'tool_call' && blockKind(next) === 'tool_call')
124
132
  }
@@ -5,7 +5,7 @@ import { taskChildItems, type TerminalBlock, type ToolCallItem } from './blocks.
5
5
  import { IMAGE_BOX_LINES } from './image-box.ts'
6
6
  import { collapsedResult } from './result-preview.ts'
7
7
  import { todoLine, todoPreview } from './todos.ts'
8
- import { runSummary, taskSummary } from './tool-run.ts'
8
+ import { isPeerSend, peerName, planRun, runSummary, taskSummary } from './tool-run.ts'
9
9
 
10
10
  export type CellMetrics = {
11
11
  width: number
@@ -595,8 +595,9 @@ export function itemHeight(item: TranscriptItem, m: CellMetrics): ComputedHeight
595
595
  acc = add(acc, rowH(item.attachments.map((a) => a.name).join(', '), m, { extraPx }))
596
596
  }
597
597
  if (item.text) {
598
- for (const line of item.text.split('\n')) {
599
- acc = add(acc, rowH(line || ' ', m, { extraPx }))
598
+ const who = item.origin ? `${peerName(item.origin)}: ` : ''
599
+ for (const [index, line] of item.text.split('\n').entries()) {
600
+ acc = add(acc, rowH((index === 0 ? who : '') + (line || ' '), m, { extraPx }))
600
601
  }
601
602
  }
602
603
  return acc
@@ -608,6 +609,10 @@ export function itemHeight(item: TranscriptItem, m: CellMetrics): ComputedHeight
608
609
  return rowH(item.text, m, { extraPx })
609
610
  }
610
611
  case 'tool_call': {
612
+ // A closed peer send is exactly one ellipsised row, whatever the message's length.
613
+ if (isPeerSend(item)) {
614
+ return { px: m.line + extraPx, exact: true }
615
+ }
611
616
  return toolRowHeight(item, m, extraPx)
612
617
  }
613
618
  case 'turn_result': {
@@ -640,8 +645,12 @@ export function blockHeight(block: TerminalBlock, m: CellMetrics): ComputedHeigh
640
645
  return rowH(taskSummary(block.task, taskChildItems(block)), m)
641
646
  }
642
647
  if ('run' in block) {
643
- const busy = block.run.some((item) => item.status === 'running' || item.status === 'pending')
644
- return rowH(runSummary(block.run, busy), m)
648
+ const plan = planRun(block.run)
649
+ if (plan.kind === 'call') {
650
+ return itemHeight(plan.item, m)
651
+ }
652
+ const busy = plan.items.some((item) => item.status === 'running' || item.status === 'pending')
653
+ return rowH(runSummary(plan.items, busy), m)
645
654
  }
646
655
  return itemHeight(block.item, m)
647
656
  }