@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.
- package/build/{SessionPanel-BVbau2eR.d.mts → SessionPanel-CJwp5B3q.d.mts} +8 -3
- package/build/index.d.mts +39 -20
- package/build/index.mjs +119 -52
- package/build/index.mjs.map +1 -1
- package/build/scoped.css +60 -11
- package/build/{use-path-links-CvcxdUXL.mjs → use-path-links-CepFsMdW.mjs} +541 -254
- package/build/use-path-links-CepFsMdW.mjs.map +1 -0
- package/build/workspace.d.mts +1 -1
- package/build/workspace.mjs +1 -1
- package/package.json +4 -4
- package/src/components/agent/Composer.tsx +28 -5
- package/src/components/agent/PromptTokenText.tsx +9 -1
- package/src/components/agent/SessionBrowser.tsx +17 -1
- package/src/components/agent/SessionItem.tsx +10 -26
- package/src/components/agent/SessionPanel.tsx +12 -0
- package/src/components/agent/SessionSteps.tsx +15 -55
- package/src/components/agent/SubagentToggle.tsx +69 -0
- package/src/components/agent/Transcript.tsx +91 -80
- package/src/components/agent/TranscriptItemView.tsx +2 -0
- package/src/components/agent/TranscriptRows.tsx +60 -56
- package/src/components/agent/UsageDialog.tsx +19 -6
- package/src/components/agent/composer-commands.ts +18 -0
- package/src/components/agent/pulse.tsx +53 -16
- package/src/components/agent/use-transcript-jumps.ts +25 -0
- package/src/components/terminal/TerminalTranscript.tsx +31 -27
- package/src/components/terminal/blocks.ts +15 -7
- package/src/components/terminal/height.ts +14 -5
- package/src/components/terminal/items.tsx +97 -11
- package/src/components/terminal/peer-names.tsx +16 -0
- package/src/components/terminal/row.tsx +1 -1
- package/src/components/terminal/tool-run.ts +68 -0
- package/src/index.ts +10 -2
- package/src/lib/session-cost.ts +12 -2
- package/src/lib/tool-icon.ts +5 -1
- package/src/styles/terminal.css +102 -1
- package/src/styles/theme.css +1 -1
- 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
|
-
|
|
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 ? <
|
|
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
|
-
{
|
|
174
|
-
|
|
175
|
-
{
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
|
116
|
-
return
|
|
115
|
+
if (isPeerSend(previous) || isPeerSend(next)) {
|
|
116
|
+
return true
|
|
117
117
|
}
|
|
118
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
599
|
-
|
|
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
|
|
644
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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,
|
|
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
|
|
package/src/lib/session-cost.ts
CHANGED
|
@@ -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
|
|
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
|
}
|
package/src/lib/tool-icon.ts
CHANGED
|
@@ -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': {
|