@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
@@ -1,18 +1,21 @@
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'
5
6
  import { usePulse } from '../agent/pulse.tsx'
7
+ import { PromptTokenText } from '../agent/PromptTokenText.tsx'
6
8
  import { BookmarkAction, CopyAction, WithActions } from './affordances.tsx'
7
9
  import { TerminalDiff } from './diff.tsx'
8
10
  import { TerminalMarkdown } from './markdown.tsx'
11
+ import { usePeerNames } from './peer-names.tsx'
9
12
  import { Pressable, useRevealOnOpen } from './press.tsx'
10
13
  import { IMAGE_BOX_LINES, IMAGE_UNAVAILABLE, imagePlaceholder } from './image-box.ts'
11
14
  import { collapsedResult } from './result-preview.ts'
12
15
  import { useToolResultFetcher } from '../agent/tool-result-fetch.tsx'
13
16
  import { useToolTitle } from '../agent/tool-titles.tsx'
14
17
  import { useToolResultImageSrc } from '../agent/tool-result-image.tsx'
15
- import { runFailed, runSummary } from './tool-run.ts'
18
+ import { isPeerSend, peerName, peerOneLine, peerSendTarget, peerSendText, planRun, runFailed, runSummary } from './tool-run.ts'
16
19
  import { todoLine, todoPreview, type TodoPreview, type TodoStatus } from './todos.ts'
17
20
  import { type ToolCallItem } from './blocks.ts'
18
21
  import { Band, Blank, Ink, Row, type Tone } from './row.tsx'
@@ -34,21 +37,97 @@ function clipToChars(lines: string[], maxChars: number): string[] {
34
37
  return out
35
38
  }
36
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
+
37
102
  export function UserRow({ item }: { item: Extract<TranscriptItem, { kind: 'user' }> }) {
38
103
  return (
39
104
  <WithActions actions={<BookmarkAction id={item.id} />}>
40
- <div className="term-user">
105
+ <div className="term-user" data-peer={item.origin ? '' : undefined}>
41
106
  {item.attachments?.length ? (
42
107
  <Row glyph={PROMPT_GLYPH} glyphTone="dim" tone="dim">
43
108
  {item.attachments.map((attachment) => attachment.name).join(', ')}
44
109
  </Row>
45
110
  ) : null}
46
111
  {item.text
47
- ? item.text.split('\n').map((line, index) => (
48
- <Row key={index} glyph={index === 0 ? PROMPT_GLYPH : undefined} glyphTone="dim" tone="fg">
49
- {line || ' '}
50
- </Row>
51
- ))
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
+ )
52
131
  : null}
53
132
  </div>
54
133
  </WithActions>
@@ -127,7 +206,7 @@ export function ToolRow({ item }: { item: ToolCallItem }) {
127
206
  >
128
207
  <Pressable onPress={() => setOpen((v) => !v)} expanded={open}>
129
208
  <Row glyph={busy ? pulse : '●'} glyphTone={tone} tone="fg" title={title ? item.name : undefined}>
130
- <Ink bold tone="bright">
209
+ <Ink bold tone="bright" className={busy ? 'term-shimmer' : undefined}>
131
210
  {title ?? item.name}
132
211
  </Ink>
133
212
  <Ink tone="dim">({todos ? todos.summary : toolInputPreview(item.input)})</Ink>
@@ -252,7 +331,7 @@ export function ToolRunRow({ items }: { items: ToolCallItem[] }) {
252
331
  <div ref={reveal} className={open ? 'term-open' : undefined}>
253
332
  <Pressable onPress={() => setOpen((v) => !v)} expanded={open}>
254
333
  <Row glyph={busy ? pulse : undefined} glyphTone={busy ? 'mark' : undefined} tone={failed ? 'red' : 'dim'}>
255
- {runSummary(items, busy)}
334
+ {busy ? <span className="term-shimmer">{runSummary(items, busy)}</span> : runSummary(items, busy)}
256
335
  </Row>
257
336
  </Pressable>
258
337
  {open ? (
@@ -266,11 +345,19 @@ export function ToolRunRow({ items }: { items: ToolCallItem[] }) {
266
345
  )
267
346
  }
268
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
+
269
356
  export function TurnResultRow({ item }: { item: Extract<TranscriptItem, { kind: 'turn_result' }> }) {
270
357
  return (
271
358
  <div>
272
359
  <Row tone={item.isError ? 'red' : 'faint'}>
273
- {item.isError ? item.subtype : 'done'} · {formatDuration(item.durationMs)} · {formatCost(item.totalCostUsd)}
360
+ {item.isError ? item.subtype : 'done'} · {formatDuration(item.durationMs)} · {formatCost(item.costUsd ?? item.totalCostUsd)}
274
361
  </Row>
275
362
  {item.errors?.map((message, index) => (
276
363
  <Row key={index} tone="red">
@@ -334,7 +421,7 @@ export function WorkingRow({ label, startedAt, tokens }: { label: string; starte
334
421
  const readings = [elapsed, tokens ? `↓ ${(tokens / 1000).toFixed(1)}k tokens` : undefined].filter(Boolean)
335
422
  return (
336
423
  <Row glyph={pulse} glyphTone="mark" tone="mark">
337
- {label}
424
+ <span className="term-shimmer">{label}</span>
338
425
  {readings.length ? <Ink tone="faint"> ({readings.join(' · ')})</Ink> : null}
339
426
  </Row>
340
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
@@ -63,6 +63,8 @@ export { Band, Blank, Ink, Row, type RowProps, type Tone } from './components/te
63
63
  export { CopyAction, WithActions, useAffordances, type TerminalAffordances } from './components/terminal/affordances.tsx'
64
64
  export { FileLinkProvider, useFileLinks, type FileLinkHandle, type FileLinkOpener } from './components/terminal/file-link.tsx'
65
65
  export { parseFileLink, type FileLink } from './lib/file-link.ts'
66
+ export { matchPath, type PathHit, type PathMatch } from './lib/path-match.ts'
67
+ export { usePathLinks, resolveAgainstCwd } from './components/agent/use-path-links.ts'
66
68
  export { Conversation, ConversationContent, ConversationScrollButton, type ConversationProps } from './components/agent/Conversation.tsx'
67
69
  export { Message, MessageContent, type MessageProps } from './components/agent/Message.tsx'
68
70
  export {
@@ -119,7 +121,7 @@ export { SkillsDialog, type SkillsDialogProps } from './components/agent/SkillsD
119
121
  export { HostFilesDialog, type HostFilesDialogProps } from './components/agent/HostFilesDialog.tsx'
120
122
  export { SessionList, SessionListItem, type SessionListItemProps, type SessionListProps } from './components/agent/SessionList.tsx'
121
123
  export { SessionBrowser, rowShapeClass, type SessionBrowserProps } from './components/agent/SessionBrowser.tsx'
122
- export { SessionItem, type SessionItemProps } from './components/agent/SessionItem.tsx'
124
+ export { SessionItem, type SelectModifiers, type SessionItemProps } from './components/agent/SessionItem.tsx'
123
125
  export { SessionStatusIcon } from './components/agent/SessionStatusIcon.tsx'
124
126
  export { EngineIcon, engineMark, vendorMarkClass, vendorTextClass } from './components/agent/EngineIcon.tsx'
125
127
  export { type Step, StepRow, StepToggle, runningSteps, sessionSteps } from './components/agent/SessionSteps.tsx'
@@ -0,0 +1,51 @@
1
+ // One path segment: no whitespace, no separator, none of the characters that bracket a path in
2
+ // prose. `:` is out because it introduces `:line`.
3
+ const SEG = '[^\\s:\'"`()\\[\\]{}<>|]+'
4
+
5
+ // The first segment of a relative path additionally cannot start with `@`: in an `@file` mention
6
+ // (`@_docs/BACKLOG.md`) the `@` is the mention's sigil, not part of the name.
7
+ const HEAD = '[^\\s:\'"`()\\[\\]{}<>|@,]+'
8
+
9
+ // The match must start at a token boundary, never consumed into the path: unanchored,
10
+ // `@_docs/BACKLOG.md` matched the suffix `/BACKLOG.md` and opened at the filesystem root.
11
+ const PATH_PATTERN = new RegExp(`(?:^|[\\s'"\`([{<@,])((?:\\.\\.?)?(?:/${SEG})+|${HEAD}(?:/${SEG})+)(?::(\\d+))?`)
12
+
13
+ const FILE_PATTERN = /^([\w.@-]+\.[A-Za-z0-9]{1,10})(?::(\d+))?$/
14
+
15
+ const TRAILING = /[.,;:!?)\]}'"`]+$/
16
+
17
+ const FILENAME = /\.[A-Za-z0-9]{1,10}$/
18
+
19
+ export type PathHit = { path: string; line?: number }
20
+
21
+ export type PathMatch = PathHit & {
22
+ // How much of the inspected text the path accounted for, the "mostly a path" test the hover makes.
23
+ length: number
24
+ }
25
+
26
+ export function matchPath(text: string | null | undefined, inCode = false): PathMatch | undefined {
27
+ const source = text ?? ''
28
+ const match = PATH_PATTERN.exec(source)
29
+ if (!match) {
30
+ return inCode ? matchFilename(source) : undefined
31
+ }
32
+ const path = match[1]!.replace(TRAILING, '')
33
+ if (!path.includes('/') || path.endsWith('/')) {
34
+ return undefined
35
+ }
36
+ const rooted = path.startsWith('/') || path.startsWith('./') || path.startsWith('../')
37
+ if (!rooted && !FILENAME.test(path)) {
38
+ return undefined
39
+ }
40
+ const line = match[2] ? Number(match[2]) : undefined
41
+ return { path, line, length: path.length + (match[2] ? match[2].length + 1 : 0) }
42
+ }
43
+
44
+ function matchFilename(text: string): PathMatch | undefined {
45
+ const hit = FILE_PATTERN.exec(text.trim())
46
+ if (!hit) {
47
+ return undefined
48
+ }
49
+ const line = hit[2] ? Number(hit[2]) : undefined
50
+ return { path: hit[1]!, line, length: hit[0].length }
51
+ }
@@ -0,0 +1,75 @@
1
+ import {
2
+ type ByModel,
3
+ type CostBasis,
4
+ type CostBreakdown,
5
+ type PricingTable,
6
+ type TokenUsage,
7
+ UNPRICED_WARN_SHARE,
8
+ canonicalModel,
9
+ costOf,
10
+ costOfByModel,
11
+ rateFor,
12
+ totalTokens,
13
+ unknownBasisModels,
14
+ } from '@workerdeck/protocol'
15
+
16
+ export type SessionCostRow = {
17
+ model: string
18
+ usage: TokenUsage
19
+ tokens: number
20
+ costUsd: number | undefined
21
+ costBasis: CostBasis | undefined
22
+ }
23
+
24
+ export type SessionCost = {
25
+ displayUsd: number | undefined
26
+ computedUsd: number | undefined
27
+ reportedUsd: number | undefined
28
+ breakdown: CostBreakdown
29
+ rows: SessionCostRow[]
30
+ unpricedShare: number
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[]
34
+ // Both figures price the same tokens, one by the engine and one by us, so a gap is the thing worth reading.
35
+ gapUsd: number | undefined
36
+ }
37
+
38
+ export type SessionCostInput = {
39
+ costUsd?: number
40
+ totalCostUsd?: number
41
+ usageByModel?: ByModel
42
+ pricing?: PricingTable
43
+ }
44
+
45
+ const GAP_SHARE = 0.02
46
+
47
+ export function sessionCost(input: SessionCostInput): SessionCost {
48
+ const byModel = input.usageByModel ?? {}
49
+ const pricing = input.pricing
50
+ const breakdown = costOfByModel(byModel, pricing)
51
+ const computedUsd = input.costUsd ?? (breakdown.unpriced || Object.keys(byModel).length === 0 ? undefined : breakdown.total)
52
+ const reportedUsd = input.totalCostUsd
53
+ const rows = Object.entries(byModel)
54
+ .map(([model, usage]) => ({
55
+ model: canonicalModel(model),
56
+ usage,
57
+ tokens: totalTokens(usage),
58
+ costUsd: rateFor(model, pricing) ? costOf(usage, model, pricing).total : undefined,
59
+ costBasis: usage.costBasis,
60
+ }))
61
+ .sort((a, b) => b.tokens - a.tokens)
62
+ const gap = computedUsd !== undefined && reportedUsd !== undefined ? reportedUsd - computedUsd : undefined
63
+ const worthShowing = gap !== undefined && Math.abs(gap) > Math.max(0.01, GAP_SHARE * Math.max(computedUsd ?? 0, reportedUsd ?? 0))
64
+ return {
65
+ displayUsd: computedUsd ?? reportedUsd,
66
+ computedUsd,
67
+ reportedUsd,
68
+ breakdown,
69
+ rows,
70
+ unpricedShare: breakdown.unpricedShare,
71
+ showUnpricedWarning: breakdown.unpricedShare > UNPRICED_WARN_SHARE,
72
+ unknownBasisModels: unknownBasisModels(byModel),
73
+ gapUsd: worthShowing ? gap : undefined,
74
+ }
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': {
@@ -79,11 +79,24 @@
79
79
  --term-faint: #6a6a6a;
80
80
 
81
81
  --term-mark: #d97757;
82
+ /* The working mark in motion. Five stops travelling violet to cyan and back,
83
+ lifted from box's `paint()` so an in-progress line reads the same here as
84
+ it does in the terminal. Deliberately not the clay accent: the mark states
85
+ whose turn it is, the shimmer states that something is still moving. */
86
+ --term-shimmer-1: #c5a3e8;
87
+ --term-shimmer-2: #a3a4e8;
88
+ --term-shimmer-3: #7b7ef5;
89
+ --term-shimmer-4: #8aaeea;
90
+ --term-shimmer-5: #a8e5ed;
82
91
  --term-blue: #afb9fe;
83
92
  --term-green: #4ec9a0;
84
93
  --term-red: #f14c4c;
85
94
  --term-yellow: #d7ba7d;
86
95
  --term-magenta: #c586c0;
96
+ /* Peer traffic - a message from or to another session. Cyan is the one ANSI slot
97
+ the theme had left, and peer rows must not be confusable with a tool call
98
+ (blue), a success (green) or a wait (yellow). */
99
+ --term-peer: #56d4dd;
87
100
 
88
101
  /* Diff bands. Full-bleed backgrounds, so they are tuned to sit under body
89
102
  text rather than to be looked at: the CLI's added block is a dark green
@@ -118,11 +131,19 @@
118
131
  --term-faint: #8d8d8d;
119
132
 
120
133
  --term-mark: #bf5b3d;
134
+ /* Same five stops walked down for a light ground; the dark set's cyan is
135
+ invisible on white. */
136
+ --term-shimmer-1: #7a4fb0;
137
+ --term-shimmer-2: #5457b8;
138
+ --term-shimmer-3: #4347c9;
139
+ --term-shimmer-4: #3f6fb5;
140
+ --term-shimmer-5: #1f7d8c;
121
141
  --term-blue: #0a66c2;
122
142
  --term-green: #16794a;
123
143
  --term-red: #c62828;
124
144
  --term-yellow: #8a6d00;
125
145
  --term-magenta: #8b3a8b;
146
+ --term-peer: #0e7490;
126
147
 
127
148
  --term-add-fg: #12492a;
128
149
  --term-add-bg: #e2f6e5;
@@ -262,6 +283,63 @@
262
283
  [data-terminal] [data-tone='magenta'] {
263
284
  color: var(--term-magenta);
264
285
  }
286
+ [data-terminal] [data-tone='peer'] {
287
+ color: var(--term-peer);
288
+ }
289
+
290
+ /* Work in progress. One element, never a span per character: the transcript
291
+ measures its own rows and supports text selection, and both break the moment
292
+ a line is shredded into glyphs. The tile is periodic (it ends on the colour
293
+ it starts on) and the animation travels exactly one tile width, so the loop
294
+ has no seam. Scoped under `[data-terminal]` to outrank the tone rules, which
295
+ would otherwise repaint the clipped text opaque. */
296
+ [data-terminal] .term-shimmer {
297
+ background-image: linear-gradient(
298
+ 90deg,
299
+ var(--term-shimmer-1),
300
+ var(--term-shimmer-2),
301
+ var(--term-shimmer-3),
302
+ var(--term-shimmer-4),
303
+ var(--term-shimmer-5),
304
+ var(--term-shimmer-4),
305
+ var(--term-shimmer-3),
306
+ var(--term-shimmer-2),
307
+ var(--term-shimmer-1)
308
+ );
309
+ background-size: 200% 100%;
310
+ -webkit-background-clip: text;
311
+ background-clip: text;
312
+ -webkit-text-fill-color: transparent;
313
+ color: transparent;
314
+ animation: term-shimmer 3s linear infinite;
315
+ }
316
+
317
+ @keyframes term-shimmer {
318
+ from {
319
+ background-position: 0% 0;
320
+ }
321
+ to {
322
+ background-position: 200% 0;
323
+ }
324
+ }
325
+
326
+ /* Clipped text with a transparent fill selects invisibly - the highlight paints
327
+ under glyphs that have no colour of their own. */
328
+ [data-terminal] .term-shimmer::selection {
329
+ color: var(--term-bright);
330
+ -webkit-text-fill-color: var(--term-bright);
331
+ }
332
+
333
+ @media (prefers-reduced-motion: reduce) {
334
+ [data-terminal] .term-shimmer {
335
+ animation: none;
336
+ background-image: none;
337
+ -webkit-background-clip: border-box;
338
+ background-clip: border-box;
339
+ -webkit-text-fill-color: var(--term-shimmer-3);
340
+ color: var(--term-shimmer-3);
341
+ }
342
+ }
265
343
 
266
344
  /* Weight is the only other axis. A terminal has one type size, so emphasis has
267
345
  to be carried by weight and colour - never by a larger glyph. */
@@ -411,6 +489,12 @@
411
489
  cursor: text;
412
490
  }
413
491
 
492
+ /* A peer's message is not what you typed, so it drops the prompt band: the peer
493
+ tone and the arrow carry it, and the band is reserved for the human's turn. */
494
+ [data-terminal] .term-user[data-peer] {
495
+ background: none;
496
+ }
497
+
414
498
  [data-terminal] .term-link {
415
499
  text-decoration: underline;
416
500
  text-underline-offset: 2px;
@@ -1130,6 +1214,15 @@ button.term-link {
1130
1214
  overflow: hidden;
1131
1215
  }
1132
1216
 
1217
+ /* A closed peer send: one row, ellipsised. The row must stay exactly one line
1218
+ tall or the virtualizer's estimate for it is wrong. */
1219
+ .term-clip-1 {
1220
+ display: block;
1221
+ overflow: hidden;
1222
+ white-space: nowrap;
1223
+ text-overflow: ellipsis;
1224
+ }
1225
+
1133
1226
  .term-scrubber .term-scrub-ex {
1134
1227
  display: -webkit-box;
1135
1228
  -webkit-line-clamp: 4;
@@ -271,7 +271,7 @@
271
271
  --ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
272
272
 
273
273
  --cw-font-sans: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
274
- --cw-font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', 'Cascadia Code', Menlo, Consolas, monospace;
274
+ --cw-font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', 'Cascadia Code', Menlo, Consolas, 'DejaVu Sans Mono', monospace;
275
275
 
276
276
  --motion-fast: 120ms;
277
277
  --motion-base: 150ms;