@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.
- package/build/index.d.mts +54 -7
- package/build/index.mjs +34 -16
- package/build/index.mjs.map +1 -1
- package/build/scoped.css +76 -1
- package/build/{SessionPanel-BClL7zUS.mjs → use-path-links-DO_79-wX.mjs} +834 -303
- package/build/use-path-links-DO_79-wX.mjs.map +1 -0
- package/build/workspace.mjs +3 -83
- package/build/workspace.mjs.map +1 -1
- package/package.json +4 -4
- package/src/components/agent/Composer.tsx +7 -4
- package/src/components/agent/PromptTokenText.tsx +11 -3
- package/src/components/agent/SessionInfoDialog.tsx +1 -1
- package/src/components/agent/SessionItem.tsx +13 -8
- package/src/components/agent/SessionList.tsx +1 -1
- package/src/components/agent/SessionPanel.tsx +6 -1
- package/src/components/agent/StatusBar.tsx +1 -1
- package/src/components/agent/Transcript.tsx +83 -79
- package/src/components/agent/TranscriptItemView.tsx +3 -1
- package/src/components/agent/TranscriptRows.tsx +60 -56
- package/src/components/agent/UsageDialog.tsx +101 -7
- package/src/components/agent/composer-commands.ts +14 -2
- package/src/components/agent/pulse.tsx +53 -16
- package/src/components/agent/use-path-links.ts +5 -19
- package/src/components/agent/use-transcript-jumps.ts +25 -0
- package/src/components/prompt-area/clipboard-helpers.ts +13 -4
- package/src/components/prompt-area/dom-helpers.ts +10 -1
- package/src/components/prompt-area/index.ts +10 -1
- package/src/components/prompt-area/prompt-area-engine.ts +23 -14
- package/src/components/prompt-area/types.ts +20 -0
- package/src/components/prompt-area/use-prompt-area-keydown.ts +2 -1
- package/src/components/prompt-area/use-prompt-area.ts +8 -2
- package/src/components/terminal/StatusLine.tsx +3 -2
- 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 +99 -12
- 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 +3 -1
- package/src/lib/path-match.ts +51 -0
- package/src/lib/session-cost.ts +75 -0
- package/src/lib/tool-icon.ts +5 -1
- package/src/styles/terminal.css +93 -0
- package/src/styles/theme.css +1 -1
- 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
|
-
<
|
|
142
|
-
<
|
|
143
|
-
<
|
|
144
|
-
{
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
emptyState
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
</
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
<
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
<
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
|
|
339
|
-
|
|
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
|
-
<
|
|
371
|
+
<div
|
|
353
372
|
key={row.key}
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
54
|
-
<
|
|
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
|
-
|
|
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 = ['
|
|
4
|
-
export const PULSE_MS =
|
|
3
|
+
export const PULSE_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] as const
|
|
4
|
+
export const PULSE_MS = 90
|
|
5
5
|
|
|
6
|
-
export const PULSE_REST =
|
|
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
|
-
|
|
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
|
|
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.
|
|
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({
|