@workerdeck/ui 2.10.0 → 2.12.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 (37) hide show
  1. package/build/{SessionPanel-BVbau2eR.d.mts → SessionPanel-CJwp5B3q.d.mts} +8 -3
  2. package/build/index.d.mts +39 -20
  3. package/build/index.mjs +119 -52
  4. package/build/index.mjs.map +1 -1
  5. package/build/scoped.css +60 -11
  6. package/build/{use-path-links-CvcxdUXL.mjs → use-path-links-CepFsMdW.mjs} +541 -254
  7. package/build/use-path-links-CepFsMdW.mjs.map +1 -0
  8. package/build/workspace.d.mts +1 -1
  9. package/build/workspace.mjs +1 -1
  10. package/package.json +4 -4
  11. package/src/components/agent/Composer.tsx +28 -5
  12. package/src/components/agent/PromptTokenText.tsx +9 -1
  13. package/src/components/agent/SessionBrowser.tsx +17 -1
  14. package/src/components/agent/SessionItem.tsx +10 -26
  15. package/src/components/agent/SessionPanel.tsx +12 -0
  16. package/src/components/agent/SessionSteps.tsx +15 -55
  17. package/src/components/agent/SubagentToggle.tsx +69 -0
  18. package/src/components/agent/Transcript.tsx +91 -80
  19. package/src/components/agent/TranscriptItemView.tsx +2 -0
  20. package/src/components/agent/TranscriptRows.tsx +60 -56
  21. package/src/components/agent/UsageDialog.tsx +19 -6
  22. package/src/components/agent/composer-commands.ts +18 -0
  23. package/src/components/agent/pulse.tsx +53 -16
  24. package/src/components/agent/use-transcript-jumps.ts +25 -0
  25. package/src/components/terminal/TerminalTranscript.tsx +31 -27
  26. package/src/components/terminal/blocks.ts +15 -7
  27. package/src/components/terminal/height.ts +14 -5
  28. package/src/components/terminal/items.tsx +97 -11
  29. package/src/components/terminal/peer-names.tsx +16 -0
  30. package/src/components/terminal/row.tsx +1 -1
  31. package/src/components/terminal/tool-run.ts +68 -0
  32. package/src/index.ts +10 -2
  33. package/src/lib/session-cost.ts +12 -2
  34. package/src/lib/tool-icon.ts +5 -1
  35. package/src/styles/terminal.css +102 -1
  36. package/src/styles/theme.css +1 -1
  37. package/build/use-path-links-CvcxdUXL.mjs.map +0 -1
@@ -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
  }
@@ -1,4 +1,5 @@
1
- import { useEffect, useState } from 'react'
1
+ import { useEffect, useState, type ReactNode } from 'react'
2
+ import type { MessageOrigin } from '@workerdeck/protocol'
2
3
  import type { TranscriptItem } from '@workerdeck/react'
3
4
  import { compactionText, formatBytes, formatCost, formatDuration, toolInputPreview } from '../../lib/format.ts'
4
5
  import { isMutatingTool } from '../../lib/tool-icon.ts'
@@ -7,13 +8,14 @@ import { PromptTokenText } from '../agent/PromptTokenText.tsx'
7
8
  import { BookmarkAction, CopyAction, WithActions } from './affordances.tsx'
8
9
  import { TerminalDiff } from './diff.tsx'
9
10
  import { TerminalMarkdown } from './markdown.tsx'
11
+ import { usePeerNames } from './peer-names.tsx'
10
12
  import { Pressable, useRevealOnOpen } from './press.tsx'
11
13
  import { IMAGE_BOX_LINES, IMAGE_UNAVAILABLE, imagePlaceholder } from './image-box.ts'
12
14
  import { collapsedResult } from './result-preview.ts'
13
15
  import { useToolResultFetcher } from '../agent/tool-result-fetch.tsx'
14
16
  import { useToolTitle } from '../agent/tool-titles.tsx'
15
17
  import { useToolResultImageSrc } from '../agent/tool-result-image.tsx'
16
- import { runFailed, runSummary } from './tool-run.ts'
18
+ import { isPeerSend, peerName, peerOneLine, peerSendTarget, peerSendText, planRun, runFailed, runSummary } from './tool-run.ts'
17
19
  import { todoLine, todoPreview, type TodoPreview, type TodoStatus } from './todos.ts'
18
20
  import { type ToolCallItem } from './blocks.ts'
19
21
  import { Band, Blank, Ink, Row, type Tone } from './row.tsx'
@@ -35,21 +37,97 @@ function clipToChars(lines: string[], maxChars: number): string[] {
35
37
  return out
36
38
  }
37
39
 
40
+ export function peerLabel(origin: MessageOrigin): string {
41
+ const who = origin.name ? `${origin.name} (${origin.sessionId.slice(0, 8)})` : origin.sessionId.slice(0, 8)
42
+ return `message from ${origin.engine ? `${origin.engine} session ` : 'session '}${who}`
43
+ }
44
+
45
+ export const PEER_IN_GLYPH = '↦'
46
+ export const PEER_OUT_GLYPH = '↤'
47
+
48
+ // The row peer traffic draws on, both directions. Never folded into a tool run and never summarised
49
+ // away: one agent talking to another is the transcript's headline, not its evidence.
50
+ function PeerRow({ glyph, who, body, tone = 'peer', clip }: { glyph: string; who: string; body: ReactNode; tone?: Tone; clip?: boolean }) {
51
+ const line = (
52
+ <>
53
+ <Ink tone={tone} bold>
54
+ {who}
55
+ </Ink>
56
+ {': '}
57
+ {body}
58
+ </>
59
+ )
60
+ return (
61
+ <Row glyph={glyph} glyphTone={tone} tone={tone}>
62
+ {clip ? <span className="term-clip-1">{line}</span> : line}
63
+ </Row>
64
+ )
65
+ }
66
+
67
+ export function PeerSendRow({ item }: { item: ToolCallItem }) {
68
+ const [open, setOpen] = useState(false)
69
+ const names = usePeerNames()
70
+ const reveal = useRevealOnOpen(open)
71
+ const lines = peerSendText(item).split('\n')
72
+ const failed = item.status === 'failed' || item.result?.isError === true
73
+ const tone: Tone = failed ? 'red' : 'peer'
74
+
75
+ return (
76
+ <WithActions actions={<BookmarkAction id={item.id} />}>
77
+ <div ref={reveal} className={open ? 'term-open' : undefined}>
78
+ <Pressable onPress={() => setOpen((v) => !v)} expanded={open}>
79
+ <PeerRow
80
+ glyph={PEER_OUT_GLYPH}
81
+ who={peerSendTarget(item, names)}
82
+ tone={tone}
83
+ clip={!open}
84
+ body={open ? lines[0] || ' ' : peerOneLine(lines.join(' '))}
85
+ />
86
+ </Pressable>
87
+ {open ? (
88
+ <div>
89
+ {lines.slice(1).map((line, index) => (
90
+ <Row key={index} tone={tone}>
91
+ {line || ' '}
92
+ </Row>
93
+ ))}
94
+ {item.result ? <Row tone="faint">{peerOneLine(item.result.text)}</Row> : null}
95
+ </div>
96
+ ) : null}
97
+ </div>
98
+ </WithActions>
99
+ )
100
+ }
101
+
38
102
  export function UserRow({ item }: { item: Extract<TranscriptItem, { kind: 'user' }> }) {
39
103
  return (
40
104
  <WithActions actions={<BookmarkAction id={item.id} />}>
41
- <div className="term-user">
105
+ <div className="term-user" data-peer={item.origin ? '' : undefined}>
42
106
  {item.attachments?.length ? (
43
107
  <Row glyph={PROMPT_GLYPH} glyphTone="dim" tone="dim">
44
108
  {item.attachments.map((attachment) => attachment.name).join(', ')}
45
109
  </Row>
46
110
  ) : null}
47
111
  {item.text
48
- ? item.text.split('\n').map((line, index) => (
49
- <Row key={index} glyph={index === 0 ? PROMPT_GLYPH : undefined} glyphTone="dim" tone="fg">
50
- {line ? <PromptTokenText text={line} /> : ' '}
51
- </Row>
52
- ))
112
+ ? item.text.split('\n').map((line, index) =>
113
+ item.origin && index === 0 ? (
114
+ <PeerRow
115
+ key={index}
116
+ glyph={PEER_IN_GLYPH}
117
+ who={peerName(item.origin)}
118
+ body={line ? <PromptTokenText text={line} /> : ' '}
119
+ />
120
+ ) : (
121
+ <Row
122
+ key={index}
123
+ glyph={!item.origin && index === 0 ? PROMPT_GLYPH : undefined}
124
+ glyphTone="dim"
125
+ tone={item.origin ? 'peer' : 'fg'}
126
+ >
127
+ {line ? <PromptTokenText text={line} /> : ' '}
128
+ </Row>
129
+ ),
130
+ )
53
131
  : null}
54
132
  </div>
55
133
  </WithActions>
@@ -128,7 +206,7 @@ export function ToolRow({ item }: { item: ToolCallItem }) {
128
206
  >
129
207
  <Pressable onPress={() => setOpen((v) => !v)} expanded={open}>
130
208
  <Row glyph={busy ? pulse : '●'} glyphTone={tone} tone="fg" title={title ? item.name : undefined}>
131
- <Ink bold tone="bright">
209
+ <Ink bold tone="bright" className={busy ? 'term-shimmer' : undefined}>
132
210
  {title ?? item.name}
133
211
  </Ink>
134
212
  <Ink tone="dim">({todos ? todos.summary : toolInputPreview(item.input)})</Ink>
@@ -253,7 +331,7 @@ export function ToolRunRow({ items }: { items: ToolCallItem[] }) {
253
331
  <div ref={reveal} className={open ? 'term-open' : undefined}>
254
332
  <Pressable onPress={() => setOpen((v) => !v)} expanded={open}>
255
333
  <Row glyph={busy ? pulse : undefined} glyphTone={busy ? 'mark' : undefined} tone={failed ? 'red' : 'dim'}>
256
- {runSummary(items, busy)}
334
+ {busy ? <span className="term-shimmer">{runSummary(items, busy)}</span> : runSummary(items, busy)}
257
335
  </Row>
258
336
  </Pressable>
259
337
  {open ? (
@@ -267,6 +345,14 @@ export function ToolRunRow({ items }: { items: ToolCallItem[] }) {
267
345
  )
268
346
  }
269
347
 
348
+ export function RunRow({ items }: { items: ToolCallItem[] }) {
349
+ const plan = planRun(items)
350
+ if (plan.kind === 'summary') {
351
+ return <ToolRunRow items={plan.items} />
352
+ }
353
+ return isPeerSend(plan.item) ? <PeerSendRow item={plan.item} /> : <ToolRow item={plan.item} />
354
+ }
355
+
270
356
  export function TurnResultRow({ item }: { item: Extract<TranscriptItem, { kind: 'turn_result' }> }) {
271
357
  return (
272
358
  <div>
@@ -335,7 +421,7 @@ export function WorkingRow({ label, startedAt, tokens }: { label: string; starte
335
421
  const readings = [elapsed, tokens ? `↓ ${(tokens / 1000).toFixed(1)}k tokens` : undefined].filter(Boolean)
336
422
  return (
337
423
  <Row glyph={pulse} glyphTone="mark" tone="mark">
338
- {label}
424
+ <span className="term-shimmer">{label}</span>
339
425
  {readings.length ? <Ink tone="faint"> ({readings.join(' · ')})</Ink> : null}
340
426
  </Row>
341
427
  )
@@ -0,0 +1,16 @@
1
+ import { createContext, useContext, useMemo, type ReactNode } from 'react'
2
+ import type { TranscriptItem } from '@workerdeck/react'
3
+ import { peerNamesOf } from './tool-run.ts'
4
+
5
+ const EMPTY: ReadonlyMap<string, string> = new Map()
6
+
7
+ const PeerNamesContext = createContext<ReadonlyMap<string, string>>(EMPTY)
8
+
9
+ export function PeerNamesProvider({ items, children }: { items: readonly TranscriptItem[]; children: ReactNode }) {
10
+ const names = useMemo(() => peerNamesOf(items), [items])
11
+ return <PeerNamesContext.Provider value={names}>{children}</PeerNamesContext.Provider>
12
+ }
13
+
14
+ export function usePeerNames(): ReadonlyMap<string, string> {
15
+ return useContext(PeerNamesContext)
16
+ }
@@ -1,7 +1,7 @@
1
1
  import type { CSSProperties, HTMLAttributes, ReactNode } from 'react'
2
2
  import { cn } from '../../lib/utils.ts'
3
3
 
4
- export type Tone = 'fg' | 'bright' | 'dim' | 'faint' | 'mark' | 'blue' | 'green' | 'red' | 'yellow' | 'magenta'
4
+ export type Tone = 'fg' | 'bright' | 'dim' | 'faint' | 'mark' | 'blue' | 'green' | 'red' | 'yellow' | 'magenta' | 'peer'
5
5
 
6
6
  export interface RowProps extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {
7
7
  glyph?: ReactNode
@@ -1,10 +1,69 @@
1
+ import { isPeerSendTool, peerDeliveredTo, type MessageOrigin } from '@workerdeck/protocol'
1
2
  import type { TranscriptItem } from '@workerdeck/react'
2
3
  import { toolInputPreview } from '../../lib/format.ts'
3
4
  import { isShellTool } from '../../lib/tool-icon.ts'
4
5
 
5
6
  type ToolCallItem = Extract<TranscriptItem, { kind: 'tool_call' }>
6
7
 
8
+ // A message to another session draws as itself, always. Folded into a run it becomes one tick of
9
+ // `Ran 4 tools`, which is exactly the collapse that hid the conversation the reader came for.
10
+ export function isPeerSend(item: TranscriptItem): boolean {
11
+ return item.kind === 'tool_call' && isPeerSendTool(item.name)
12
+ }
13
+
14
+ export function peerName(origin: MessageOrigin): string {
15
+ return origin.name ?? origin.sessionId.slice(0, 8)
16
+ }
17
+
18
+ // Every peer this transcript has named, by session id. A conversation names its peers twice over: an
19
+ // arriving message carries the sender's name, a delivery receipt carries the recipient's. Either one
20
+ // answers for the other direction, so a send that was only ever acknowledged by id still draws as a
21
+ // name once the peer speaks, and a transcript recorded before the receipts carried names heals itself.
22
+ export function peerNamesOf(items: readonly TranscriptItem[]): ReadonlyMap<string, string> {
23
+ const names = new Map<string, string>()
24
+ for (const item of items) {
25
+ if (item.kind === 'user') {
26
+ if (item.origin?.name) {
27
+ names.set(item.origin.sessionId, item.origin.name)
28
+ }
29
+ continue
30
+ }
31
+ if (item.kind === 'tool_call' && isPeerSendTool(item.name) && item.result && !item.result.isError) {
32
+ const delivered = peerDeliveredTo(item.result.text)
33
+ if (delivered?.name) {
34
+ names.set(delivered.sessionId, delivered.name)
35
+ }
36
+ }
37
+ }
38
+ return names
39
+ }
40
+
41
+ // Who a `peers_send` went to, read back out of the tool's own reply. Until the call settles there is
42
+ // no reply to read, so the row falls back to what the rest of the transcript knows, then to the id the
43
+ // model addressed.
44
+ export function peerSendTarget(item: ToolCallItem, names?: ReadonlyMap<string, string>): string {
45
+ const delivered = item.result && !item.result.isError ? peerDeliveredTo(item.result.text) : undefined
46
+ const addressed = (item.input as { sessionId?: unknown } | null)?.sessionId
47
+ const sessionId = delivered?.sessionId ?? (typeof addressed === 'string' ? addressed : undefined)
48
+ if (sessionId === undefined) {
49
+ return 'peer'
50
+ }
51
+ return delivered?.name ?? names?.get(sessionId) ?? sessionId.slice(0, 8)
52
+ }
53
+
54
+ export function peerSendText(item: ToolCallItem): string {
55
+ const text = (item.input as { text?: unknown } | null)?.text
56
+ return typeof text === 'string' ? text : ''
57
+ }
58
+
59
+ export function peerOneLine(text: string): string {
60
+ return text.replace(/\s+/g, ' ').trim()
61
+ }
62
+
7
63
  export function foldsTogether(a: ToolCallItem, b: ToolCallItem): boolean {
64
+ if (isPeerSend(a) || isPeerSend(b)) {
65
+ return false
66
+ }
8
67
  return a.parentToolUseId === b.parentToolUseId
9
68
  }
10
69
 
@@ -19,6 +78,15 @@ export function toolFamily(name: string): string {
19
78
  return name.toLowerCase()
20
79
  }
21
80
 
81
+ export type RunPlan = { kind: 'call'; item: ToolCallItem } | { kind: 'summary'; items: ToolCallItem[] }
82
+
83
+ // Matches iOS's `planRun` (`TerminalPlanner.swift`): a run of exactly one call draws as the call
84
+ // itself, because the summary row would occupy the same one row while throwing away the tool's
85
+ // name, input and result preview.
86
+ export function planRun(items: ToolCallItem[]): RunPlan {
87
+ return items.length === 1 ? { kind: 'call', item: items[0]! } : { kind: 'summary', items }
88
+ }
89
+
22
90
  export function runSummary(items: readonly ToolCallItem[], busy: boolean): string {
23
91
  const verb = busy ? 'Running ' : 'Ran '
24
92
  const tail = busy ? '…' : ''
package/src/index.ts CHANGED
@@ -94,6 +94,7 @@ export { Composer, type ComposerFileMatch, type ComposerHandle, type ComposerPro
94
94
  export {
95
95
  mergeComposerRows,
96
96
  rankComposerRows,
97
+ rankPeerSessions,
97
98
  matchClientCommand,
98
99
  skillPrompt,
99
100
  type ClientCommand,
@@ -124,11 +125,18 @@ export { SessionBrowser, rowShapeClass, type SessionBrowserProps } from './compo
124
125
  export { SessionItem, type SelectModifiers, type SessionItemProps } from './components/agent/SessionItem.tsx'
125
126
  export { SessionStatusIcon } from './components/agent/SessionStatusIcon.tsx'
126
127
  export { EngineIcon, engineMark, vendorMarkClass, vendorTextClass } from './components/agent/EngineIcon.tsx'
127
- export { type Step, StepRow, StepToggle, runningSteps, sessionSteps } from './components/agent/SessionSteps.tsx'
128
+ export { type Step, StepRow, sessionSteps } from './components/agent/SessionSteps.tsx'
129
+ export {
130
+ SUBAGENT_DISPLAY_META,
131
+ SUBAGENT_DISPLAY_ORDER,
132
+ SubagentDisplayIcon,
133
+ SubagentToggle,
134
+ type SubagentToggleProps,
135
+ } from './components/agent/SubagentToggle.tsx'
128
136
  export { SubagentStrip } from './components/agent/SubagentStrip.tsx'
129
137
  export { ProjectIcon } from './components/agent/ProjectIcon.tsx'
130
138
  export { SessionEmptyState, type SessionEmptyStateProps } from './components/agent/SessionEmptyState.tsx'
131
- export { PromptTokenText } from './components/agent/PromptTokenText.tsx'
139
+ export { PromptTokenText, SessionNamesProvider, SkillNamesProvider } from './components/agent/PromptTokenText.tsx'
132
140
  export { STATUS_META } from './components/agent/status.ts'
133
141
  export { ContextRing } from './components/agent/ContextRing.tsx'
134
142
 
@@ -1,6 +1,8 @@
1
1
  import {
2
2
  type ByModel,
3
+ type CostBasis,
3
4
  type CostBreakdown,
5
+ type PricingTable,
4
6
  type TokenUsage,
5
7
  UNPRICED_WARN_SHARE,
6
8
  canonicalModel,
@@ -8,6 +10,7 @@ import {
8
10
  costOfByModel,
9
11
  rateFor,
10
12
  totalTokens,
13
+ unknownBasisModels,
11
14
  } from '@workerdeck/protocol'
12
15
 
13
16
  export type SessionCostRow = {
@@ -15,6 +18,7 @@ export type SessionCostRow = {
15
18
  usage: TokenUsage
16
19
  tokens: number
17
20
  costUsd: number | undefined
21
+ costBasis: CostBasis | undefined
18
22
  }
19
23
 
20
24
  export type SessionCost = {
@@ -25,6 +29,8 @@ export type SessionCost = {
25
29
  rows: SessionCostRow[]
26
30
  unpricedShare: number
27
31
  showUnpricedWarning: boolean
32
+ // Models the engine itself could not match to a rate table, so the figure *it* reports for them is a guess.
33
+ unknownBasisModels: string[]
28
34
  // Both figures price the same tokens, one by the engine and one by us, so a gap is the thing worth reading.
29
35
  gapUsd: number | undefined
30
36
  }
@@ -33,13 +39,15 @@ export type SessionCostInput = {
33
39
  costUsd?: number
34
40
  totalCostUsd?: number
35
41
  usageByModel?: ByModel
42
+ pricing?: PricingTable
36
43
  }
37
44
 
38
45
  const GAP_SHARE = 0.02
39
46
 
40
47
  export function sessionCost(input: SessionCostInput): SessionCost {
41
48
  const byModel = input.usageByModel ?? {}
42
- const breakdown = costOfByModel(byModel)
49
+ const pricing = input.pricing
50
+ const breakdown = costOfByModel(byModel, pricing)
43
51
  const computedUsd = input.costUsd ?? (breakdown.unpriced || Object.keys(byModel).length === 0 ? undefined : breakdown.total)
44
52
  const reportedUsd = input.totalCostUsd
45
53
  const rows = Object.entries(byModel)
@@ -47,7 +55,8 @@ export function sessionCost(input: SessionCostInput): SessionCost {
47
55
  model: canonicalModel(model),
48
56
  usage,
49
57
  tokens: totalTokens(usage),
50
- costUsd: rateFor(model) ? costOf(usage, model).total : undefined,
58
+ costUsd: rateFor(model, pricing) ? costOf(usage, model, pricing).total : undefined,
59
+ costBasis: usage.costBasis,
51
60
  }))
52
61
  .sort((a, b) => b.tokens - a.tokens)
53
62
  const gap = computedUsd !== undefined && reportedUsd !== undefined ? reportedUsd - computedUsd : undefined
@@ -60,6 +69,7 @@ export function sessionCost(input: SessionCostInput): SessionCost {
60
69
  rows,
61
70
  unpricedShare: breakdown.unpricedShare,
62
71
  showUnpricedWarning: breakdown.unpricedShare > UNPRICED_WARN_SHARE,
72
+ unknownBasisModels: unknownBasisModels(byModel),
63
73
  gapUsd: worthShowing ? gap : undefined,
64
74
  }
65
75
  }
@@ -52,7 +52,11 @@ export function toolIcon(toolName: string): LucideIcon {
52
52
  case 'Agent': {
53
53
  return UsersRound
54
54
  }
55
- case 'TodoWrite': {
55
+ case 'TodoWrite':
56
+ case 'TaskCreate':
57
+ case 'TaskUpdate':
58
+ case 'TaskGet':
59
+ case 'TaskList': {
56
60
  return CheckSquare
57
61
  }
58
62
  case 'Skill': {