@workerdeck/ui 2.9.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.
Files changed (46) hide show
  1. package/build/index.d.mts +54 -7
  2. package/build/index.mjs +34 -16
  3. package/build/index.mjs.map +1 -1
  4. package/build/scoped.css +76 -1
  5. package/build/{SessionPanel-BClL7zUS.mjs → use-path-links-DO_79-wX.mjs} +834 -303
  6. package/build/use-path-links-DO_79-wX.mjs.map +1 -0
  7. package/build/workspace.mjs +3 -83
  8. package/build/workspace.mjs.map +1 -1
  9. package/package.json +4 -4
  10. package/src/components/agent/Composer.tsx +7 -4
  11. package/src/components/agent/PromptTokenText.tsx +11 -3
  12. package/src/components/agent/SessionInfoDialog.tsx +1 -1
  13. package/src/components/agent/SessionItem.tsx +13 -8
  14. package/src/components/agent/SessionList.tsx +1 -1
  15. package/src/components/agent/SessionPanel.tsx +6 -1
  16. package/src/components/agent/StatusBar.tsx +1 -1
  17. package/src/components/agent/Transcript.tsx +83 -79
  18. package/src/components/agent/TranscriptItemView.tsx +3 -1
  19. package/src/components/agent/TranscriptRows.tsx +60 -56
  20. package/src/components/agent/UsageDialog.tsx +101 -7
  21. package/src/components/agent/composer-commands.ts +14 -2
  22. package/src/components/agent/pulse.tsx +53 -16
  23. package/src/components/agent/use-path-links.ts +5 -19
  24. package/src/components/agent/use-transcript-jumps.ts +25 -0
  25. package/src/components/prompt-area/clipboard-helpers.ts +13 -4
  26. package/src/components/prompt-area/dom-helpers.ts +10 -1
  27. package/src/components/prompt-area/index.ts +10 -1
  28. package/src/components/prompt-area/prompt-area-engine.ts +23 -14
  29. package/src/components/prompt-area/types.ts +20 -0
  30. package/src/components/prompt-area/use-prompt-area-keydown.ts +2 -1
  31. package/src/components/prompt-area/use-prompt-area.ts +8 -2
  32. package/src/components/terminal/StatusLine.tsx +3 -2
  33. package/src/components/terminal/TerminalTranscript.tsx +31 -27
  34. package/src/components/terminal/blocks.ts +15 -7
  35. package/src/components/terminal/height.ts +14 -5
  36. package/src/components/terminal/items.tsx +99 -12
  37. package/src/components/terminal/peer-names.tsx +16 -0
  38. package/src/components/terminal/row.tsx +1 -1
  39. package/src/components/terminal/tool-run.ts +68 -0
  40. package/src/index.ts +3 -1
  41. package/src/lib/path-match.ts +51 -0
  42. package/src/lib/session-cost.ts +75 -0
  43. package/src/lib/tool-icon.ts +5 -1
  44. package/src/styles/terminal.css +93 -0
  45. package/src/styles/theme.css +1 -1
  46. package/build/SessionPanel-BClL7zUS.mjs.map +0 -1
@@ -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
@@ -6,8 +6,16 @@
6
6
  * clipboard I/O concerns alongside its DOM traversal and chip-accessor helpers.
7
7
  */
8
8
  import type { Segment, ChipSegment } from './types.ts'
9
- import { chipNodeToSegment, getChipDisplay, getChipTrigger, getSelectionRange, isChipElement, isHTMLElement } from './dom-helpers.ts'
10
- import { mergeAdjacentTextSegments } from './prompt-area-engine.ts'
9
+ import {
10
+ chipNodeToSegment,
11
+ getChipDisplay,
12
+ getChipSigil,
13
+ getChipTrigger,
14
+ getSelectionRange,
15
+ isChipElement,
16
+ isHTMLElement,
17
+ } from './dom-helpers.ts'
18
+ import { chipPlainText, mergeAdjacentTextSegments } from './prompt-area-engine.ts'
11
19
  import { getTextLengthInRange } from './cursor-helpers.ts'
12
20
 
13
21
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -63,7 +71,7 @@ export function serializeFragmentToPlainText(fragment: DocumentFragment): string
63
71
  text += value
64
72
  },
65
73
  onChip: (node) => {
66
- text += (getChipTrigger(node) ?? '') + (getChipDisplay(node) ?? '')
74
+ text += (getChipSigil(node) ?? getChipTrigger(node) ?? '') + (getChipDisplay(node) ?? '')
67
75
  },
68
76
  onBreak: () => {
69
77
  text += '\n'
@@ -129,6 +137,7 @@ export function parseSegmentsFromClipboard(json: string): Segment[] | null {
129
137
  trigger: item.trigger,
130
138
  value: item.value,
131
139
  displayText: item.displayText,
140
+ ...(typeof item.sigil === 'string' ? { sigil: item.sigil } : {}),
132
141
  ...(item.data !== undefined ? { data: item.data } : {}),
133
142
  ...(item.autoResolved ? { autoResolved: true } : {}),
134
143
  }
@@ -173,7 +182,7 @@ export function insertSegmentsAtCursor(currentSegments: Segment[], pastedSegment
173
182
 
174
183
  for (const seg of currentSegments) {
175
184
  if (seg.type === 'chip') {
176
- const chipLen = seg.trigger.length + seg.displayText.length
185
+ const chipLen = chipPlainText(seg).length
177
186
  if (offset >= cursorOffset) {
178
187
  insertOnce()
179
188
  }
@@ -139,6 +139,13 @@ export function getChipData(node: Node): unknown {
139
139
  return safeJsonParse(raw)
140
140
  }
141
141
 
142
+ export function getChipSigil(node: Node): string | undefined {
143
+ if (!isChipElement(node)) {
144
+ return undefined
145
+ }
146
+ return node.dataset.chipSigil
147
+ }
148
+
142
149
  /**
143
150
  * Length of a chip's plain-text representation (`trigger + displayText`).
144
151
  *
@@ -148,7 +155,7 @@ export function getChipData(node: Node): unknown {
148
155
  * `textContent` for resilience against externally-mutated nodes.
149
156
  */
150
157
  export function chipNodeTextLength(node: HTMLElement): number {
151
- const trigger = node.dataset.chipTrigger ?? ''
158
+ const trigger = node.dataset.chipSigil ?? node.dataset.chipTrigger ?? ''
152
159
  const display = node.dataset.chipDisplay ?? node.textContent ?? ''
153
160
  return trigger.length + display.length
154
161
  }
@@ -174,6 +181,7 @@ export function chipNodeToSegment(node: Node): ChipSegment | null {
174
181
  return null
175
182
  }
176
183
 
184
+ const sigil = getChipSigil(node)
177
185
  const data = getChipData(node)
178
186
  const autoResolved = getChipAutoResolved(node)
179
187
 
@@ -182,6 +190,7 @@ export function chipNodeToSegment(node: Node): ChipSegment | null {
182
190
  trigger,
183
191
  value,
184
192
  displayText,
193
+ ...(sigil !== undefined ? { sigil } : {}),
185
194
  ...(data !== undefined ? { data } : {}),
186
195
  ...(autoResolved ? { autoResolved: true } : {}),
187
196
  }
@@ -7,4 +7,13 @@ export { PromptArea } from './prompt-area.tsx'
7
7
  export { usePromptAreaState } from './use-prompt-area-state.ts'
8
8
  export { commandTrigger, mentionTrigger, hashtagTrigger, launchTrigger } from './trigger-presets.ts'
9
9
  export { segmentsToPlainText, plainTextToSegments, isSegmentsEmpty, getChipsByTrigger } from './segment-helpers.ts'
10
- export type { PromptAreaHandle, PromptAreaProps, Segment, ChipSegment, TextSegment, TriggerConfig, TriggerSuggestion } from './types.ts'
10
+ export type {
11
+ ChipOptions,
12
+ PromptAreaHandle,
13
+ PromptAreaProps,
14
+ Segment,
15
+ ChipSegment,
16
+ TextSegment,
17
+ TriggerConfig,
18
+ TriggerSuggestion,
19
+ } from './types.ts'
@@ -6,9 +6,16 @@ import type { Segment, ChipSegment, TriggerConfig, TriggerPosition, ActiveTrigge
6
6
 
7
7
  // Serialization
8
8
 
9
+ /**
10
+ * A chip's plain-text form: `{sigil ?? trigger}{displayText}` (e.g., "@Alice").
11
+ */
12
+ export function chipPlainText(seg: ChipSegment): string {
13
+ return `${seg.sigil ?? seg.trigger}${seg.displayText}`
14
+ }
15
+
9
16
  /**
10
17
  * Converts an array of segments to a plain text string.
11
- * Chips are represented as `{trigger}{displayText}` (e.g., "@Alice").
18
+ * Chips are represented by {@link chipPlainText}.
12
19
  */
13
20
  export function segmentsToPlainText(segments: Segment[]): string {
14
21
  return segments
@@ -16,7 +23,7 @@ export function segmentsToPlainText(segments: Segment[]): string {
16
23
  if (seg.type === 'text') {
17
24
  return seg.text
18
25
  }
19
- return `${seg.trigger}${seg.displayText}`
26
+ return chipPlainText(seg)
20
27
  })
21
28
  .join('')
22
29
  }
@@ -45,7 +52,7 @@ export function truncateSegmentsToLength(segments: Segment[], maxLength: number)
45
52
  const result: Segment[] = []
46
53
  let length = 0
47
54
  for (const seg of segments) {
48
- const segLength = seg.type === 'text' ? seg.text.length : seg.trigger.length + seg.displayText.length
55
+ const segLength = seg.type === 'text' ? seg.text.length : chipPlainText(seg).length
49
56
  if (length + segLength <= maxLength) {
50
57
  result.push(seg)
51
58
  length += segLength
@@ -195,7 +202,8 @@ export function detectActiveTrigger(text: string, cursorPos: number, triggers: T
195
202
  export function resolveChip(
196
203
  segments: Segment[],
197
204
  activeTrigger: ActiveTrigger,
198
- chip: { value: string; displayText: string; data?: unknown; autoResolved?: boolean },
205
+ chip: { value: string; displayText: string; sigil?: string; data?: unknown; autoResolved?: boolean },
206
+ trailingText = ' ',
199
207
  ): { segments: Segment[]; cursorOffset: number } {
200
208
  const triggerStart = activeTrigger.startOffset
201
209
  const triggerEnd = triggerStart + 1 + activeTrigger.query.length // +1 for trigger char
@@ -205,7 +213,7 @@ export function resolveChip(
205
213
 
206
214
  for (const seg of segments) {
207
215
  if (seg.type === 'chip') {
208
- const chipText = `${seg.trigger}${seg.displayText}`
216
+ const chipText = chipPlainText(seg)
209
217
  const chipStart = offset
210
218
  const chipEnd = offset + chipText.length
211
219
 
@@ -239,16 +247,17 @@ export function resolveChip(
239
247
  trigger: activeTrigger.config.char,
240
248
  value: chip.value,
241
249
  displayText: chip.displayText,
250
+ ...(chip.sigil !== undefined ? { sigil: chip.sigil } : {}),
242
251
  ...(chip.data !== undefined ? { data: chip.data } : {}),
243
252
  ...(chip.autoResolved ? { autoResolved: true } : {}),
244
253
  }
245
254
  newSegments.push(newChip)
246
255
 
247
- // Add trailing space after chip, then any remaining text
256
+ // Add the trailing text (a space by default) after the chip, then any remaining text
248
257
  if (afterText) {
249
- newSegments.push({ type: 'text', text: ' ' + afterText.replace(/^\s/, '') })
258
+ newSegments.push({ type: 'text', text: trailingText + afterText.replace(/^\s/, '') })
250
259
  } else {
251
- newSegments.push({ type: 'text', text: ' ' })
260
+ newSegments.push({ type: 'text', text: trailingText })
252
261
  }
253
262
  }
254
263
 
@@ -266,14 +275,14 @@ export function resolveChip(
266
275
  if (seg.type === 'text') {
267
276
  runningOffset += seg.text.length
268
277
  } else {
269
- runningOffset += seg.trigger.length + seg.displayText.length
278
+ runningOffset += chipPlainText(seg).length
270
279
  if (seg.value === chip.value && seg.displayText === chip.displayText && seg.trigger === activeTrigger.config.char) {
271
280
  lastChipEndOffset = runningOffset
272
281
  }
273
282
  }
274
283
  }
275
- // +1 accounts for the trailing space after the chip
276
- const cursorOffset = lastChipEndOffset === -1 ? runningOffset : lastChipEndOffset + 1
284
+ // The caret lands after the chip and its trailing text
285
+ const cursorOffset = lastChipEndOffset === -1 ? runningOffset : lastChipEndOffset + trailingText.length
277
286
 
278
287
  return { segments: merged, cursorOffset }
279
288
  }
@@ -302,7 +311,7 @@ export function resolveText(
302
311
 
303
312
  for (const seg of segments) {
304
313
  if (seg.type === 'chip') {
305
- const chipEnd = offset + `${seg.trigger}${seg.displayText}`.length
314
+ const chipEnd = offset + chipPlainText(seg).length
306
315
  // A trigger range can never overlap a chip - chips are atomic - so a chip
307
316
  // is either wholly before or wholly after, and is kept either way.
308
317
  if (chipEnd <= triggerStart || offset >= triggerEnd) {
@@ -377,7 +386,7 @@ export function revertChipAtIndex(segments: Segment[], index: number): { segment
377
386
  return null
378
387
  }
379
388
 
380
- const revertedText = `${seg.trigger}${seg.displayText}`
389
+ const revertedText = chipPlainText(seg)
381
390
  const result = [...segments.slice(0, index), { type: 'text' as const, text: revertedText }, ...segments.slice(index + 1)]
382
391
  return { segments: mergeAdjacentTextSegments(result), revertedText }
383
392
  }
@@ -491,7 +500,7 @@ export function replaceTextRange(segments: Segment[], start: number, end: number
491
500
 
492
501
  for (const seg of segments) {
493
502
  if (seg.type === 'chip') {
494
- const chipText = `${seg.trigger}${seg.displayText}`
503
+ const chipText = chipPlainText(seg)
495
504
  const chipStart = offset
496
505
  const chipEnd = offset + chipText.length
497
506
 
@@ -23,6 +23,12 @@ export type ChipSegment = {
23
23
  value: string
24
24
  /** The display text shown in the chip */
25
25
  displayText: string
26
+ /**
27
+ * The character the chip serialises and renders with, when it differs from
28
+ * `trigger`. The trigger is the menu the chip came from; the sigil is the
29
+ * token the host parses. Unset, the trigger character is the sigil.
30
+ */
31
+ sigil?: string
26
32
  /** Optional data payload attached to the chip */
27
33
  data?: unknown
28
34
  /**
@@ -74,6 +80,11 @@ export type TriggerSuggestion = {
74
80
  data?: unknown
75
81
  }
76
82
 
83
+ export type ChipOptions = {
84
+ sigil?: string
85
+ trailingText?: string
86
+ }
87
+
77
88
  /**
78
89
  * Configuration for a trigger character.
79
90
  */
@@ -113,6 +124,15 @@ export type TriggerConfig = {
113
124
  * `onChipAdd` does not fire either.
114
125
  */
115
126
  insertAsText?: (suggestion: TriggerSuggestion) => string | undefined
127
+ /**
128
+ * For 'dropdown' mode: per-suggestion overrides for the chip a selection
129
+ * resolves to. `sigil` replaces the trigger character in the chip's text and
130
+ * serialisation (a `/` menu inserting a `$name` token); `trailingText` is
131
+ * the editable text placed after the chip in place of the default single
132
+ * space (it is inserted verbatim, so include the leading space). Return
133
+ * undefined for the plain chip.
134
+ */
135
+ chipOptions?: (suggestion: TriggerSuggestion) => ChipOptions | undefined
116
136
  /**
117
137
  * For 'callback' and 'launch' modes: called when the trigger is activated.
118
138
  * Receives the full input text and cursor position. For 'launch' it fires on
@@ -10,6 +10,7 @@ import {
10
10
  revertChipAtIndex,
11
11
  replaceTextRange,
12
12
  toggleMarkdownWrap,
13
+ chipPlainText,
13
14
  } from './prompt-area-engine.ts'
14
15
  import {
15
16
  getListContext,
@@ -138,7 +139,7 @@ export function usePromptAreaKeydown({
138
139
  if (s.type === 'text') {
139
140
  targetOffset += s.text.length
140
141
  } else {
141
- targetOffset += s.trigger.length + s.displayText.length
142
+ targetOffset += chipPlainText(s).length
142
143
  }
143
144
  }
144
145
  targetOffset += result.revertedText.length
@@ -12,6 +12,7 @@ import {
12
12
  resolveChip,
13
13
  resolveText,
14
14
  truncateSegmentsToLength,
15
+ chipPlainText,
15
16
  } from './prompt-area-engine.ts'
16
17
  import {
17
18
  autoFormatListPrefix,
@@ -223,6 +224,9 @@ export function usePromptArea({
223
224
  chip.dataset.chipTrigger = seg.trigger
224
225
  chip.dataset.chipValue = seg.value
225
226
  chip.dataset.chipDisplay = seg.displayText
227
+ if (seg.sigil !== undefined) {
228
+ chip.dataset.chipSigil = seg.sigil
229
+ }
226
230
  if (seg.data !== undefined) {
227
231
  const json = safeJsonStringify(seg.data)
228
232
  if (json) {
@@ -236,7 +240,7 @@ export function usePromptArea({
236
240
  const chipStyle = triggerConfig?.chipStyle ?? 'pill'
237
241
  chip.dataset.chipStyle = chipStyle
238
242
  chip.className = cn('prompt-area-chip', chipStyle === 'inline' && 'prompt-area-chip--inline', triggerConfig?.chipClassName)
239
- chip.textContent = `${seg.trigger}${seg.displayText}`
243
+ chip.textContent = chipPlainText(seg)
240
244
  chip.setAttribute('role', 'button')
241
245
  chip.setAttribute('tabindex', '-1')
242
246
  editor.appendChild(chip)
@@ -598,10 +602,12 @@ export function usePromptArea({
598
602
  }
599
603
 
600
604
  const displayText = activeTrigger.config.onSelect?.(suggestion) ?? suggestion.label
605
+ const options = activeTrigger.config.chipOptions?.(suggestion)
601
606
 
602
607
  const chipData = {
603
608
  value: suggestion.value,
604
609
  displayText: displayText || suggestion.label,
610
+ ...(options?.sigil !== undefined ? { sigil: options.sigil } : {}),
605
611
  data: suggestion.data,
606
612
  }
607
613
 
@@ -616,7 +622,7 @@ export function usePromptArea({
616
622
  }, 0)
617
623
  return
618
624
  }
619
- const result = resolveChip(segments, activeTrigger, chipData)
625
+ const result = resolveChip(segments, activeTrigger, chipData, options?.trailingText)
620
626
 
621
627
  onChange(result.segments)
622
628
  renderSegmentsToDOM(result.segments)
@@ -75,10 +75,11 @@ export function TerminalStatusLine({
75
75
  )
76
76
  }
77
77
 
78
- if (state.totalCostUsd > 0) {
78
+ const costUsd = state.costUsd ?? (state.totalCostUsd || undefined)
79
+ if (costUsd !== undefined && costUsd > 0) {
79
80
  parts.push(
80
81
  <Ink key="cost" tone="faint">
81
- {formatCost(state.totalCostUsd)}
82
+ {formatCost(costUsd)}
82
83
  </Ink>,
83
84
  )
84
85
  }
@@ -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
  }