@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
@@ -10,6 +10,7 @@ import { subagentItems, terminalBlocks, type ToolCallItem } from '../terminal/bl
10
10
  import { taskBrief, taskBusy } from '../terminal/tool-run.ts'
11
11
  import { type TranscriptRow } from './transcript-rows.ts'
12
12
  import { TranscriptRows } from './TranscriptRows.tsx'
13
+ import { SkillNamesProvider } from './PromptTokenText.tsx'
13
14
  import { TerminalSurface } from '../terminal/surface.tsx'
14
15
  import { ROW_GAP, TranscriptVariantProvider, type TranscriptDensity, type TranscriptVariant } from './transcript-variant.tsx'
15
16
 
@@ -136,90 +137,93 @@ export function Transcript({
136
137
  }
137
138
  return [...fold(0, boundary), { key: 'recap' as const, line: recap }, ...fold(boundary, items.length)]
138
139
  }, [items, boundary, recap, terminal, brief])
140
+ const skillNames = useMemo(() => state.skills?.map((skill) => skill.name), [state.skills])
139
141
  return (
140
142
  <TranscriptVariantProvider value={variant}>
141
- <Conversation className={cn(replaying && 'invisible', className)}>
142
- <ConversationContent className={cn(terminal && 'gap-0 p-0')}>
143
- <TerminalShell active={terminal} fontSize={fontSize} lineHeight={lineHeight} affordances={affordances}>
144
- {frame ? (
145
- frameTask === undefined && !replaying ? (
146
- <div className={cn(terminal ? 'term-row text-fg-4' : 'p-4 text-body-sm text-fg-4')}>
147
- This sub-agent's work is not in this transcript.
148
- </div>
149
- ) : null
150
- ) : items.length === 0 && state.status !== 'starting' ? (
151
- emptyState !== undefined ? (
152
- emptyState
153
- ) : (
154
- <SessionEmptyState
155
- cwd={state.cwd}
156
- hasCommands={!!state.commands?.length}
157
- hasSkills={!!state.skills?.some((s) => s.enabled)}
158
- canBrowseFiles={canBrowseFiles}
159
- />
160
- )
161
- ) : null}
162
- {frame && frameTask === undefined && !replaying ? null : (
163
- <TranscriptRows
164
- rows={rows}
165
- boundary={boundary}
166
- since={catchUp?.since}
167
- terminal={terminal}
168
- replaying={replaying}
169
- stickyPrompt={!frame && stickyPrompt}
170
- gap={gap}
171
- fontSize={fontSize}
172
- lineHeight={lineHeight}
173
- items={items}
174
- pendingApprovals={state.pendingApprovals}
175
- scrubber={scrubber}
176
- bookmarks={frame ? undefined : bookmarks}
177
- affordances={affordances}
178
- fileUrl={fileUrl}
179
- attachmentUrl={attachmentUrl}
180
- hostImage={hostImage}
181
- jumpToRecapRef={jumpToRecapRef}
182
- repinRef={repinRef}
183
- reveal={frame ? undefined : reveal}
184
- frameParentId={frame?.parentToolUseId}
185
- onOpenSubagent={frame ? undefined : onOpenSubagent}
186
- />
187
- )}
188
- {(frame ? frameTask !== undefined && taskBusy(frameTask, items) : showLoader(state)) ? (
189
- terminal ? (
190
- <>
191
- {state.items.length > 0 ? <div className="term-blank" aria-hidden /> : null}
192
- <WorkingRow
193
- label={state.status === 'starting' ? 'Starting…' : 'Working…'}
194
- startedAt={runStartedAt}
195
- tokens={state.contextUsage?.totalTokens}
143
+ <SkillNamesProvider names={skillNames}>
144
+ <Conversation className={cn(replaying && 'invisible', className)}>
145
+ <ConversationContent className={cn(terminal && 'gap-0 p-0')}>
146
+ <TerminalShell active={terminal} fontSize={fontSize} lineHeight={lineHeight} affordances={affordances}>
147
+ {frame ? (
148
+ frameTask === undefined && !replaying ? (
149
+ <div className={cn(terminal ? 'term-row text-fg-4' : 'p-4 text-body-sm text-fg-4')}>
150
+ This sub-agent's work is not in this transcript.
151
+ </div>
152
+ ) : null
153
+ ) : items.length === 0 && state.status !== 'starting' ? (
154
+ emptyState !== undefined ? (
155
+ emptyState
156
+ ) : (
157
+ <SessionEmptyState
158
+ cwd={state.cwd}
159
+ hasCommands={!!state.commands?.length}
160
+ hasSkills={!!state.skills?.some((s) => s.enabled)}
161
+ canBrowseFiles={canBrowseFiles}
196
162
  />
197
- </>
198
- ) : (
199
- <Loader label={state.status === 'starting' ? 'Starting session…' : undefined} />
200
- )
201
- ) : null}
202
- </TerminalShell>
203
- </ConversationContent>
204
- <ConversationScrollButton />
205
- {replaying ? (
206
- <div
207
- data-slot="transcript-hold"
208
- aria-hidden
209
- className="wd-hold-appear visible pointer-events-none absolute inset-0 overflow-hidden"
210
- >
211
- <div className={cn('mx-auto w-full max-w-[var(--wd-transcript-max-width)]', !terminal && 'px-4 py-4')}>
212
- {terminal ? (
213
- <TerminalSurface fontSize={fontSize} lineHeight={lineHeight} affordances={false} bleed="1ch" className="term-transcript">
214
- <WorkingRow label="Loading…" />
215
- </TerminalSurface>
216
- ) : (
217
- <Loader label="Loading session…" />
163
+ )
164
+ ) : null}
165
+ {frame && frameTask === undefined && !replaying ? null : (
166
+ <TranscriptRows
167
+ rows={rows}
168
+ boundary={boundary}
169
+ since={catchUp?.since}
170
+ terminal={terminal}
171
+ replaying={replaying}
172
+ stickyPrompt={!frame && stickyPrompt}
173
+ gap={gap}
174
+ fontSize={fontSize}
175
+ lineHeight={lineHeight}
176
+ items={items}
177
+ pendingApprovals={state.pendingApprovals}
178
+ scrubber={scrubber}
179
+ bookmarks={frame ? undefined : bookmarks}
180
+ affordances={affordances}
181
+ fileUrl={fileUrl}
182
+ attachmentUrl={attachmentUrl}
183
+ hostImage={hostImage}
184
+ jumpToRecapRef={jumpToRecapRef}
185
+ repinRef={repinRef}
186
+ reveal={frame ? undefined : reveal}
187
+ frameParentId={frame?.parentToolUseId}
188
+ onOpenSubagent={frame ? undefined : onOpenSubagent}
189
+ />
218
190
  )}
191
+ {(frame ? frameTask !== undefined && taskBusy(frameTask, items) : showLoader(state)) ? (
192
+ terminal ? (
193
+ <>
194
+ {state.items.length > 0 ? <div className="term-blank" aria-hidden /> : null}
195
+ <WorkingRow
196
+ label={state.status === 'starting' ? 'Starting…' : 'Working…'}
197
+ startedAt={runStartedAt}
198
+ tokens={state.contextUsage?.totalTokens}
199
+ />
200
+ </>
201
+ ) : (
202
+ <Loader label={state.status === 'starting' ? 'Starting session…' : undefined} />
203
+ )
204
+ ) : null}
205
+ </TerminalShell>
206
+ </ConversationContent>
207
+ <ConversationScrollButton />
208
+ {replaying ? (
209
+ <div
210
+ data-slot="transcript-hold"
211
+ aria-hidden
212
+ className="wd-hold-appear visible pointer-events-none absolute inset-0 overflow-hidden"
213
+ >
214
+ <div className={cn('mx-auto w-full max-w-[var(--wd-transcript-max-width)]', !terminal && 'px-4 py-4')}>
215
+ {terminal ? (
216
+ <TerminalSurface fontSize={fontSize} lineHeight={lineHeight} affordances={false} bleed="1ch" className="term-transcript">
217
+ <WorkingRow label="Loading…" />
218
+ </TerminalSurface>
219
+ ) : (
220
+ <Loader label="Loading session…" />
221
+ )}
222
+ </div>
219
223
  </div>
220
- </div>
221
- ) : null}
222
- </Conversation>
224
+ ) : null}
225
+ </Conversation>
226
+ </SkillNamesProvider>
223
227
  </TranscriptVariantProvider>
224
228
  )
225
229
  }
@@ -10,6 +10,7 @@ import { Response } from './Response.tsx'
10
10
  import { ToolCallCard } from './ToolCallCard.tsx'
11
11
  import { Row } from '../terminal/row.tsx'
12
12
  import { TerminalItemView } from '../terminal/TerminalTranscript.tsx'
13
+ import { peerLabel } from '../terminal/items.tsx'
13
14
 
14
15
  function TurnResultRow({ item }: { item: Extract<TranscriptItem, { kind: 'turn_result' }> }) {
15
16
  return (
@@ -17,7 +18,7 @@ function TurnResultRow({ item }: { item: Extract<TranscriptItem, { kind: 'turn_r
17
18
  <div className="flex items-center gap-2">
18
19
  <div className="h-px flex-1 bg-border" />
19
20
  <span className={cn('font-mono text-label', item.isError ? 'text-danger' : 'text-fg-4')}>
20
- {item.isError ? item.subtype : 'turn done'} · {formatDuration(item.durationMs)} · {formatCost(item.totalCostUsd)}
21
+ {item.isError ? item.subtype : 'turn done'} · {formatDuration(item.durationMs)} · {formatCost(item.costUsd ?? item.totalCostUsd)}
21
22
  </span>
22
23
  <div className="h-px flex-1 bg-border" />
23
24
  </div>
@@ -69,6 +70,7 @@ export function TranscriptItemView({
69
70
  case 'user': {
70
71
  return (
71
72
  <Message from="user">
73
+ {item.origin ? <div className="wd-peer-origin text-xs text-muted-foreground">{peerLabel(item.origin)}</div> : null}
72
74
  {item.attachments?.length ? <SentAttachments attachments={item.attachments} attachmentUrl={attachmentUrl} /> : null}
73
75
  {item.text ? (
74
76
  <MessageContent>
@@ -6,14 +6,15 @@ import type { PermissionRequest } from '@workerdeck/protocol'
6
6
  import type { TranscriptItem } from '@workerdeck/react'
7
7
  import { cn } from '../../lib/utils.ts'
8
8
  import { resolveAffordances, type TerminalAffordances } from '../terminal/affordances.tsx'
9
- import { ToolRunRow } from '../terminal/items.tsx'
9
+ import { RunRow } from '../terminal/items.tsx'
10
10
  import { parentOf } from '../terminal/blocks.ts'
11
11
  import { briefPx, estimateBlockPx } from '../terminal/height.ts'
12
+ import { PeerNamesProvider } from '../terminal/peer-names.tsx'
12
13
  import { TerminalScrubber } from '../terminal/scrubber.tsx'
13
14
  import { Scrubber } from './Scrubber.tsx'
14
15
  import { gapBefore, positionInRow, rowIndexForItem, type TranscriptRow } from './transcript-rows.ts'
15
16
  import { useHeightEpoch } from './use-height-epoch.ts'
16
- import { useTranscriptJumps } from './use-transcript-jumps.ts'
17
+ import { absorbScrollerResize, useTranscriptJumps } from './use-transcript-jumps.ts'
17
18
  import { BriefRow, TaskRow } from '../terminal/TerminalTranscript.tsx'
18
19
  import { RecapRow, TranscriptItemView } from './TranscriptItemView.tsx'
19
20
 
@@ -167,9 +168,10 @@ export function TranscriptRows({
167
168
  if (height === last) {
168
169
  return
169
170
  }
171
+ const difference = height - last
170
172
  last = height
171
173
  if (stick.state.isAtBottom) {
172
- void stick.scrollToBottom('instant')
174
+ absorbScrollerResize(stick, difference)
173
175
  }
174
176
  })
175
177
  observer.observe(scrollElement)
@@ -318,64 +320,66 @@ export function TranscriptRows({
318
320
 
319
321
  return (
320
322
  <div ref={rowsRef} data-slot="transcript-rows" className="relative w-full" style={{ height: virtualizer.getTotalSize() }}>
321
- {virtualizer.getVirtualItems().map((virtualRow) => {
322
- const row = rows[virtualRow.index]
323
- const gapClass = virtualRow.index > 0 && (!terminal || gapBefore(rows, virtualRow.index)) ? gap.className : undefined
324
- const content =
325
- 'run' in row ? (
326
- <div className={cn(read(boundary, row.index) && 'opacity-45')}>
327
- <ToolRunRow items={row.run} />
328
- </div>
329
- ) : 'item' in row ? (
330
- <div className={cn(read(boundary, row.index) && 'opacity-45', nestedClass(row.item, frameParentId))}>
331
- <TranscriptItemView
332
- item={row.item}
333
- fileUrl={fileUrl}
334
- attachmentUrl={attachmentUrl}
335
- hostImage={hostImage}
323
+ <PeerNamesProvider items={items}>
324
+ {virtualizer.getVirtualItems().map((virtualRow) => {
325
+ const row = rows[virtualRow.index]
326
+ const gapClass = virtualRow.index > 0 && (!terminal || gapBefore(rows, virtualRow.index)) ? gap.className : undefined
327
+ const content =
328
+ 'run' in row ? (
329
+ <div className={cn(read(boundary, row.index) && 'opacity-45')}>
330
+ <RunRow items={row.run} />
331
+ </div>
332
+ ) : 'item' in row ? (
333
+ <div className={cn(read(boundary, row.index) && 'opacity-45', nestedClass(row.item, frameParentId))}>
334
+ <TranscriptItemView
335
+ item={row.item}
336
+ fileUrl={fileUrl}
337
+ attachmentUrl={attachmentUrl}
338
+ hostImage={hostImage}
339
+ terminal={terminal}
340
+ />
341
+ </div>
342
+ ) : 'text' in row && row.key === 'brief' ? (
343
+ <BriefRow text={row.text} terminal={terminal} />
344
+ ) : 'line' in row ? (
345
+ <RecapRow line={row.line} since={since} terminal={terminal} />
346
+ ) : (
347
+ <div className={cn(read(boundary, row.index) && 'opacity-45')}>
348
+ <TaskRow block={row} fileUrl={fileUrl} onOpenSubagent={onOpenSubagent} />
349
+ </div>
350
+ )
351
+ if (stickyPrompt && 'item' in row && row.item.kind === 'user' && parentOf(row.item) === undefined) {
352
+ const next = promptRows.find((index) => index > virtualRow.index)
353
+ const laneEnd = next === undefined ? virtualizer.getTotalSize() : (measurements[next]?.start ?? virtualRow.start)
354
+ return (
355
+ <StickyPromptLane
356
+ key={row.key}
357
+ top={virtualRow.start}
358
+ height={Math.max(laneEnd - virtualRow.start, 0)}
359
+ gapClass={gapClass}
360
+ gapPx={gap.px}
336
361
  terminal={terminal}
362
+ scrollRoot={scrollElement}
363
+ index={virtualRow.index}
364
+ measureRef={virtualizer.measureElement}
365
+ head={terminal ? content : promptHeadText(row.item)}
366
+ content={content}
337
367
  />
338
- </div>
339
- ) : 'text' in row && row.key === 'brief' ? (
340
- <BriefRow text={row.text} terminal={terminal} />
341
- ) : 'line' in row ? (
342
- <RecapRow line={row.line} since={since} terminal={terminal} />
343
- ) : (
344
- <div className={cn(read(boundary, row.index) && 'opacity-45')}>
345
- <TaskRow block={row} fileUrl={fileUrl} onOpenSubagent={onOpenSubagent} />
346
- </div>
347
- )
348
- if (stickyPrompt && 'item' in row && row.item.kind === 'user' && parentOf(row.item) === undefined) {
349
- const next = promptRows.find((index) => index > virtualRow.index)
350
- const laneEnd = next === undefined ? virtualizer.getTotalSize() : (measurements[next]?.start ?? virtualRow.start)
368
+ )
369
+ }
351
370
  return (
352
- <StickyPromptLane
371
+ <div
353
372
  key={row.key}
354
- top={virtualRow.start}
355
- height={Math.max(laneEnd - virtualRow.start, 0)}
356
- gapClass={gapClass}
357
- gapPx={gap.px}
358
- terminal={terminal}
359
- scrollRoot={scrollElement}
360
- index={virtualRow.index}
361
- measureRef={virtualizer.measureElement}
362
- head={terminal ? content : promptHeadText(row.item)}
363
- content={content}
364
- />
373
+ ref={virtualizer.measureElement}
374
+ data-index={virtualRow.index}
375
+ className={cn('absolute inset-x-0 top-0', gapClass)}
376
+ style={{ transform: `translateY(${virtualRow.start}px)` }}
377
+ >
378
+ {content}
379
+ </div>
365
380
  )
366
- }
367
- return (
368
- <div
369
- key={row.key}
370
- ref={virtualizer.measureElement}
371
- data-index={virtualRow.index}
372
- className={cn('absolute inset-x-0 top-0', gapClass)}
373
- style={{ transform: `translateY(${virtualRow.start}px)` }}
374
- >
375
- {content}
376
- </div>
377
- )
378
- })}
381
+ })}
382
+ </PeerNamesProvider>
379
383
  {scrubber && scrollElement?.parentElement
380
384
  ? createPortal(
381
385
  terminal ? (
@@ -1,37 +1,130 @@
1
- import type { ProfileEngine, UsageWindowRow } from '@workerdeck/protocol'
1
+ import type { ByModel, PricingTable, ProfileEngine, ProfileSpend, UsageWindowRow } from '@workerdeck/protocol'
2
+ import { PRICING_NOTE, pricingAgeNote } from '@workerdeck/protocol'
2
3
  import { Badge } from '../ui/Badge.tsx'
3
4
  import { Dialog, DialogBody, DialogContent, DialogHeader } from '../ui/Dialog.tsx'
4
- import { formatAgoPrecise, formatCost } from '../../lib/format.ts'
5
+ import { formatAgoPrecise, formatCost, formatTokens } from '../../lib/format.ts'
6
+ import { type SessionCost, sessionCost } from '../../lib/session-cost.ts'
5
7
  import { UsageMeters, useMinuteClock } from './UsageMeters.tsx'
6
8
 
7
9
  export interface UsageDialogProps {
8
10
  rateLimits: UsageWindowRow[]
9
11
  subscriptionType?: string
10
12
  engine: ProfileEngine
11
- totalCostUsd: number
13
+ totalCostUsd?: number
14
+ costUsd?: number
15
+ usageByModel?: ByModel
16
+ pricing?: PricingTable
17
+ spend?: ProfileSpend
12
18
  updatedAt?: number
13
19
  open: boolean
14
20
  onOpenChange: (open: boolean) => void
15
21
  className?: string
16
22
  }
17
23
 
24
+ function engineLabel(engine: ProfileEngine): string {
25
+ return engine === 'claude' ? 'Claude Code' : engine
26
+ }
27
+
28
+ function Line({ label, value, tone }: { label: string; value: string; tone?: 'muted' }) {
29
+ return (
30
+ <div className="flex items-baseline justify-between gap-4">
31
+ <span className={tone === 'muted' ? 'text-label text-fg-4' : 'text-label text-fg-3'}>{label}</span>
32
+ <span className={`font-mono text-body-sm ${tone === 'muted' ? 'text-fg-4' : 'text-fg-1'}`}>{value}</span>
33
+ </div>
34
+ )
35
+ }
36
+
37
+ function CostEvidence({ cost, engine }: { cost: SessionCost; engine: ProfileEngine }) {
38
+ const { breakdown, rows } = cost
39
+ if (rows.length === 0) {
40
+ return <Line label="This session has cost" value={formatCost(cost.displayUsd)} />
41
+ }
42
+ return (
43
+ <div className="space-y-3">
44
+ <div className="flex items-baseline justify-between gap-4">
45
+ <span className="text-label text-fg-3">This session has cost</span>
46
+ <span className="font-mono text-body text-fg-1">{formatCost(cost.displayUsd)}</span>
47
+ </div>
48
+ <div className="space-y-1 border-t border-border pt-2">
49
+ <Line label="input" value={formatCost(breakdown.input)} tone="muted" />
50
+ <Line label="output" value={formatCost(breakdown.output)} tone="muted" />
51
+ <Line label="cache write" value={formatCost(breakdown.cacheWrite)} tone="muted" />
52
+ <Line label="cache read" value={formatCost(breakdown.cacheRead)} tone="muted" />
53
+ </div>
54
+ <div className="space-y-1 border-t border-border pt-2">
55
+ {rows.map((row) => (
56
+ <div key={row.model} className="flex items-baseline justify-between gap-4">
57
+ <span className="truncate text-label text-fg-4">{row.model}</span>
58
+ <span className="shrink-0 font-mono text-label text-fg-4">
59
+ {formatTokens(row.tokens)} · {row.costUsd === undefined ? 'no rate' : formatCost(row.costUsd)}
60
+ </span>
61
+ </div>
62
+ ))}
63
+ </div>
64
+ {cost.showUnpricedWarning ? (
65
+ <p className="text-label text-warning">
66
+ {Math.round(cost.unpricedShare * 100)}% of these tokens ran on a model with no bundled rate, so the total is an understatement
67
+ rather than an estimate.
68
+ </p>
69
+ ) : null}
70
+ {cost.unknownBasisModels.length > 0 ? (
71
+ <p className="text-label text-warning">
72
+ {engineLabel(engine)} matched no rate table of its own to {cost.unknownBasisModels.join(', ')}, so the figure it reports for those
73
+ tokens is a guess rather than a rate.
74
+ </p>
75
+ ) : null}
76
+ {cost.gapUsd !== undefined ? (
77
+ <p className="text-label text-fg-4">
78
+ {engineLabel(engine)} reports {formatCost(cost.reportedUsd)} for the same tokens.
79
+ </p>
80
+ ) : null}
81
+ </div>
82
+ )
83
+ }
84
+
85
+ function ProfileSpendSection({ spend }: { spend: ProfileSpend }) {
86
+ const { subscription } = spend
87
+ return (
88
+ <div className="mt-4 space-y-1 border-t border-border pt-3">
89
+ <Line label="This profile, last 7 days" value={formatCost(spend.weekUsd)} />
90
+ <Line label="Last 30 days" value={formatCost(spend.monthUsd)} tone="muted" />
91
+ {subscription ? (
92
+ <p className="pt-1 text-label text-fg-4">
93
+ {formatMultiple(subscription.ratio)} the {formatCost(subscription.weeklyShareUsd)} weekly share of your{' '}
94
+ {formatCost(subscription.monthlyUsd)} a month. A subscription is a flat fee, so this is leverage, not a bill.
95
+ </p>
96
+ ) : null}
97
+ </div>
98
+ )
99
+ }
100
+
101
+ function formatMultiple(ratio: number): string {
102
+ return ratio >= 10 ? `${Math.round(ratio)}x` : `${ratio.toFixed(1)}x`
103
+ }
104
+
18
105
  export function UsageDialog({
19
106
  rateLimits,
20
107
  subscriptionType,
21
108
  engine,
22
109
  totalCostUsd,
110
+ costUsd,
111
+ usageByModel,
112
+ pricing,
113
+ spend,
23
114
  updatedAt,
24
115
  open,
25
116
  onOpenChange,
26
117
  className,
27
118
  }: UsageDialogProps) {
28
119
  const now = useMinuteClock(open)
120
+ const cost = sessionCost({ costUsd, totalCostUsd, usageByModel, pricing })
121
+ const ageNote = pricingAgeNote(now)
29
122
  return (
30
123
  <Dialog open={open} onOpenChange={onOpenChange}>
31
124
  <DialogContent className={className}>
32
125
  <DialogHeader
33
126
  title="Usage"
34
- description={engine === 'claude' ? 'Claude Code' : engine}
127
+ description={engineLabel(engine)}
35
128
  actions={
36
129
  subscriptionType ? (
37
130
  <Badge variant="accent" className="mt-0.5 shrink-0 capitalize">
@@ -50,10 +143,11 @@ export function UsageDialog({
50
143
  ) : (
51
144
  <UsageMeters windows={rateLimits} now={now} />
52
145
  )}
53
- <div className="mt-5 flex items-baseline justify-between gap-4 border-t border-border pt-3">
54
- <span className="text-label text-fg-3">This session has cost</span>
55
- <span className="font-mono text-body-sm text-fg-1">{formatCost(totalCostUsd)}</span>
146
+ <div className="mt-5 border-t border-border pt-3">
147
+ <CostEvidence cost={cost} engine={engine} />
56
148
  </div>
149
+ {spend ? <ProfileSpendSection spend={spend} /> : null}
150
+ <p className="mt-3 text-label text-fg-4">{ageNote ? `${PRICING_NOTE} ${ageNote}` : PRICING_NOTE}</p>
57
151
  {updatedAt ? <p className="mt-2 text-label text-fg-4">Updated {formatAgoPrecise(updatedAt, now)}</p> : null}
58
152
  </DialogBody>
59
153
  </DialogContent>
@@ -23,9 +23,21 @@ export function cleanCommandName(name: string): string {
23
23
  return name.replace(/\s*\(MCP\)$/i, '')
24
24
  }
25
25
 
26
+ // Codex's own mention token, which its system prompt names and its runner maps to a skill input item.
27
+ export function skillToken(skill: SkillInfo): string {
28
+ return `$${skill.name}`
29
+ }
30
+
31
+ // What follows the `$name` chip as editable text: the default prompt with the token taken out, or
32
+ // nothing. Always space-terminated so typing continues from a clean boundary.
33
+ export function skillTrailingText(skill: SkillInfo): string {
34
+ const token = skillToken(skill)
35
+ const rest = (skill.defaultPrompt ?? '').split(token).join(' ').replace(/\s+/g, ' ').trim()
36
+ return rest ? ` ${rest} ` : ' '
37
+ }
38
+
26
39
  export function skillPrompt(skill: SkillInfo): string {
27
- const base = skill.defaultPrompt?.trim() || `Use the ${skill.displayName ?? skill.name} skill:`
28
- return /\s$/.test(base) ? base : base + ' '
40
+ return skillToken(skill) + skillTrailingText(skill)
29
41
  }
30
42
 
31
43
  export function mergeComposerRows(sources: {
@@ -1,9 +1,57 @@
1
- import { useEffect, useState } from 'react'
1
+ import { useEffect, useState, useSyncExternalStore } from 'react'
2
2
 
3
- export const PULSE_FRAMES = ['⋄', '◇', '◈', '◆'] as const
4
- export const PULSE_MS = 150
3
+ export const PULSE_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] as const
4
+ export const PULSE_MS = 90
5
5
 
6
- export const PULSE_REST = PULSE_FRAMES[PULSE_FRAMES.length - 1]
6
+ export const PULSE_REST = '⠿'
7
+
8
+ const listeners = new Set<() => void>()
9
+ let ticker: ReturnType<typeof setInterval> | undefined
10
+ let current = PULSE_REST
11
+
12
+ export function pulseFrameAt(now: number): string {
13
+ return PULSE_FRAMES[Math.floor(now / PULSE_MS) % PULSE_FRAMES.length]!
14
+ }
15
+
16
+ function tick(): void {
17
+ const next = pulseFrameAt(Date.now())
18
+ if (next === current) {
19
+ return
20
+ }
21
+ current = next
22
+ for (const listener of listeners) {
23
+ listener()
24
+ }
25
+ }
26
+
27
+ function subscribe(listener: () => void): () => void {
28
+ listeners.add(listener)
29
+ if (ticker === undefined) {
30
+ current = pulseFrameAt(Date.now())
31
+ ticker = setInterval(tick, PULSE_MS)
32
+ }
33
+ return () => {
34
+ listeners.delete(listener)
35
+ if (listeners.size === 0 && ticker !== undefined) {
36
+ clearInterval(ticker)
37
+ ticker = undefined
38
+ }
39
+ }
40
+ }
41
+
42
+ function subscribeRest(): () => void {
43
+ return unsubscribeRest
44
+ }
45
+
46
+ function unsubscribeRest(): void {}
47
+
48
+ function readFrame(): string {
49
+ return current
50
+ }
51
+
52
+ function readRest(): string {
53
+ return PULSE_REST
54
+ }
7
55
 
8
56
  export function usePrefersReducedMotion(): boolean {
9
57
  const [reduced, setReduced] = useState(false)
@@ -23,16 +71,5 @@ export function usePrefersReducedMotion(): boolean {
23
71
  export function usePulse(animated: boolean): string {
24
72
  const reduced = usePrefersReducedMotion()
25
73
  const running = animated && !reduced
26
- const [frame, setFrame] = useState(0)
27
- useEffect(() => {
28
- if (!running) {
29
- return
30
- }
31
- const timer = setInterval(() => setFrame((f) => f + 1), PULSE_MS)
32
- return () => clearInterval(timer)
33
- }, [running])
34
- if (!running) {
35
- return PULSE_REST
36
- }
37
- return PULSE_FRAMES[frame % PULSE_FRAMES.length]!
74
+ return useSyncExternalStore(running ? subscribe : subscribeRest, running ? readFrame : readRest, readRest)
38
75
  }
@@ -1,41 +1,27 @@
1
1
  import { useEffect, type RefObject } from 'react'
2
2
  import { parseFileLink } from '../../lib/file-link.ts'
3
+ import { matchPath, type PathHit } from '../../lib/path-match.ts'
3
4
 
4
- export type PathHit = { path: string; line?: number }
5
+ export type { PathHit } from '../../lib/path-match.ts'
5
6
 
6
7
  const LINKISH = 'wd-path-link'
7
8
 
8
- const PATH_RE = /^(\/[^\s:'"`()[\]{}]+|[\w.@~-]+(?:\/[\w.@~-]+)+)(?::(\d+))?$/
9
- const FILE_RE = /^([\w.@-]+\.[A-Za-z0-9]{1,10})(?::(\d+))?$/
10
-
11
9
  const COVERAGE = 0.6
12
10
 
13
- export function matchPath(text: string, inCode: boolean): PathHit | undefined {
14
- const trimmed = text.trim()
15
- if (!trimmed || trimmed.endsWith('/')) {
16
- return undefined
17
- }
18
- const hit = PATH_RE.exec(trimmed) ?? (inCode ? FILE_RE.exec(trimmed) : null)
19
- if (!hit) {
20
- return undefined
21
- }
22
- return { path: hit[1]!, line: hit[2] ? Number(hit[2]) : undefined }
23
- }
24
-
25
11
  function hitFor(element: HTMLElement | undefined): PathHit | undefined {
26
12
  if (!element) {
27
13
  return undefined
28
14
  }
29
15
  const inCode = element.closest('code') !== null
30
- const text = element.textContent ?? ''
16
+ const text = element.textContent?.trim() ?? ''
31
17
  const hit = matchPath(text, inCode)
32
18
  if (!hit) {
33
19
  return undefined
34
20
  }
35
- if (!inCode && hit.path.length < text.trim().length * COVERAGE) {
21
+ if (!inCode && hit.length < text.length * COVERAGE) {
36
22
  return undefined
37
23
  }
38
- return hit
24
+ return { path: hit.path, line: hit.line }
39
25
  }
40
26
 
41
27
  export function usePathLinks({