@workerdeck/ui 2.8.0 → 2.10.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-_lQLhWOw.d.mts → SessionPanel-BVbau2eR.d.mts} +68 -4
- package/build/index.d.mts +62 -9
- package/build/index.mjs +24 -10
- package/build/index.mjs.map +1 -1
- package/build/scoped.css +44 -0
- package/build/{SessionPanel-Dg4xf-ql.mjs → use-path-links-CvcxdUXL.mjs} +1080 -497
- package/build/use-path-links-CvcxdUXL.mjs.map +1 -0
- package/build/workspace.d.mts +3 -2
- package/build/workspace.mjs +5 -86
- package/build/workspace.mjs.map +1 -1
- package/package.json +4 -4
- package/src/components/agent/Composer.tsx +88 -83
- package/src/components/agent/PromptTokenText.tsx +11 -3
- package/src/components/agent/SessionInfoDialog.tsx +1 -1
- package/src/components/agent/SessionItem.tsx +12 -6
- package/src/components/agent/SessionList.tsx +1 -1
- package/src/components/agent/SessionPanel.tsx +308 -225
- package/src/components/agent/SessionWorkspace.tsx +3 -0
- package/src/components/agent/StatusBar.tsx +1 -1
- package/src/components/agent/Transcript.tsx +83 -79
- package/src/components/agent/TranscriptItemView.tsx +1 -1
- package/src/components/agent/UsageDialog.tsx +87 -6
- package/src/components/agent/UsageMeters.tsx +17 -4
- package/src/components/agent/composer-commands.ts +154 -0
- package/src/components/agent/use-path-links.ts +7 -26
- 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/file-link.tsx +20 -0
- package/src/components/terminal/items.tsx +3 -2
- package/src/components/terminal/markdown.tsx +31 -5
- package/src/index.ts +14 -2
- package/src/lib/file-link.ts +70 -0
- package/src/lib/path-match.ts +51 -0
- package/src/lib/session-cost.ts +65 -0
- package/src/styles/terminal.css +15 -0
- package/build/SessionPanel-Dg4xf-ql.mjs.map +0 -1
|
@@ -200,7 +200,7 @@ export function StatusBar({
|
|
|
200
200
|
) : null}
|
|
201
201
|
{controls ? <span className="self-center">{controls}</span> : null}
|
|
202
202
|
<span className="flex-1" />
|
|
203
|
-
<span className="font-mono text-label text-fg-3">{formatCost(state.totalCostUsd)}</span>
|
|
203
|
+
<span className="font-mono text-label text-fg-3">{formatCost(state.costUsd ?? (state.totalCostUsd || undefined))}</span>
|
|
204
204
|
{actions ? <span className="self-center">{actions}</span> : null}
|
|
205
205
|
</div>
|
|
206
206
|
)
|
|
@@ -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
|
}
|
|
@@ -17,7 +17,7 @@ function TurnResultRow({ item }: { item: Extract<TranscriptItem, { kind: 'turn_r
|
|
|
17
17
|
<div className="flex items-center gap-2">
|
|
18
18
|
<div className="h-px flex-1 bg-border" />
|
|
19
19
|
<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)}
|
|
20
|
+
{item.isError ? item.subtype : 'turn done'} · {formatDuration(item.durationMs)} · {formatCost(item.costUsd ?? item.totalCostUsd)}
|
|
21
21
|
</span>
|
|
22
22
|
<div className="h-px flex-1 bg-border" />
|
|
23
23
|
</div>
|
|
@@ -1,31 +1,111 @@
|
|
|
1
|
-
import type { ProfileEngine, UsageWindowRow } from '@workerdeck/protocol'
|
|
1
|
+
import type { ByModel, ProfileEngine, ProfileSpend, UsageWindowRow } from '@workerdeck/protocol'
|
|
2
|
+
import { PRICING_NOTE } 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
|
+
spend?: ProfileSpend
|
|
12
17
|
updatedAt?: number
|
|
13
18
|
open: boolean
|
|
14
19
|
onOpenChange: (open: boolean) => void
|
|
15
20
|
className?: string
|
|
16
21
|
}
|
|
17
22
|
|
|
23
|
+
function Line({ label, value, tone }: { label: string; value: string; tone?: 'muted' }) {
|
|
24
|
+
return (
|
|
25
|
+
<div className="flex items-baseline justify-between gap-4">
|
|
26
|
+
<span className={tone === 'muted' ? 'text-label text-fg-4' : 'text-label text-fg-3'}>{label}</span>
|
|
27
|
+
<span className={`font-mono text-body-sm ${tone === 'muted' ? 'text-fg-4' : 'text-fg-1'}`}>{value}</span>
|
|
28
|
+
</div>
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function CostEvidence({ cost, engine }: { cost: SessionCost; engine: ProfileEngine }) {
|
|
33
|
+
const { breakdown, rows } = cost
|
|
34
|
+
if (rows.length === 0) {
|
|
35
|
+
return <Line label="This session has cost" value={formatCost(cost.displayUsd)} />
|
|
36
|
+
}
|
|
37
|
+
return (
|
|
38
|
+
<div className="space-y-3">
|
|
39
|
+
<div className="flex items-baseline justify-between gap-4">
|
|
40
|
+
<span className="text-label text-fg-3">This session has cost</span>
|
|
41
|
+
<span className="font-mono text-body text-fg-1">{formatCost(cost.displayUsd)}</span>
|
|
42
|
+
</div>
|
|
43
|
+
<div className="space-y-1 border-t border-border pt-2">
|
|
44
|
+
<Line label="input" value={formatCost(breakdown.input)} tone="muted" />
|
|
45
|
+
<Line label="output" value={formatCost(breakdown.output)} tone="muted" />
|
|
46
|
+
<Line label="cache write" value={formatCost(breakdown.cacheWrite)} tone="muted" />
|
|
47
|
+
<Line label="cache read" value={formatCost(breakdown.cacheRead)} tone="muted" />
|
|
48
|
+
</div>
|
|
49
|
+
<div className="space-y-1 border-t border-border pt-2">
|
|
50
|
+
{rows.map((row) => (
|
|
51
|
+
<div key={row.model} className="flex items-baseline justify-between gap-4">
|
|
52
|
+
<span className="truncate text-label text-fg-4">{row.model}</span>
|
|
53
|
+
<span className="shrink-0 font-mono text-label text-fg-4">
|
|
54
|
+
{formatTokens(row.tokens)} · {row.costUsd === undefined ? 'no rate' : formatCost(row.costUsd)}
|
|
55
|
+
</span>
|
|
56
|
+
</div>
|
|
57
|
+
))}
|
|
58
|
+
</div>
|
|
59
|
+
{cost.showUnpricedWarning ? (
|
|
60
|
+
<p className="text-label text-warning">
|
|
61
|
+
{Math.round(cost.unpricedShare * 100)}% of these tokens ran on a model with no bundled rate, so the total is an understatement
|
|
62
|
+
rather than an estimate.
|
|
63
|
+
</p>
|
|
64
|
+
) : null}
|
|
65
|
+
{cost.gapUsd !== undefined ? (
|
|
66
|
+
<p className="text-label text-fg-4">
|
|
67
|
+
{engine === 'claude' ? 'Claude Code' : engine} reports {formatCost(cost.reportedUsd)} for the same tokens.
|
|
68
|
+
</p>
|
|
69
|
+
) : null}
|
|
70
|
+
</div>
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function ProfileSpendSection({ spend }: { spend: ProfileSpend }) {
|
|
75
|
+
const { subscription } = spend
|
|
76
|
+
return (
|
|
77
|
+
<div className="mt-4 space-y-1 border-t border-border pt-3">
|
|
78
|
+
<Line label="This profile, last 7 days" value={formatCost(spend.weekUsd)} />
|
|
79
|
+
<Line label="Last 30 days" value={formatCost(spend.monthUsd)} tone="muted" />
|
|
80
|
+
{subscription ? (
|
|
81
|
+
<p className="pt-1 text-label text-fg-4">
|
|
82
|
+
{formatMultiple(subscription.ratio)} the {formatCost(subscription.weeklyShareUsd)} weekly share of your{' '}
|
|
83
|
+
{formatCost(subscription.monthlyUsd)} a month. A subscription is a flat fee, so this is leverage, not a bill.
|
|
84
|
+
</p>
|
|
85
|
+
) : null}
|
|
86
|
+
</div>
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function formatMultiple(ratio: number): string {
|
|
91
|
+
return ratio >= 10 ? `${Math.round(ratio)}x` : `${ratio.toFixed(1)}x`
|
|
92
|
+
}
|
|
93
|
+
|
|
18
94
|
export function UsageDialog({
|
|
19
95
|
rateLimits,
|
|
20
96
|
subscriptionType,
|
|
21
97
|
engine,
|
|
22
98
|
totalCostUsd,
|
|
99
|
+
costUsd,
|
|
100
|
+
usageByModel,
|
|
101
|
+
spend,
|
|
23
102
|
updatedAt,
|
|
24
103
|
open,
|
|
25
104
|
onOpenChange,
|
|
26
105
|
className,
|
|
27
106
|
}: UsageDialogProps) {
|
|
28
107
|
const now = useMinuteClock(open)
|
|
108
|
+
const cost = sessionCost({ costUsd, totalCostUsd, usageByModel })
|
|
29
109
|
return (
|
|
30
110
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
31
111
|
<DialogContent className={className}>
|
|
@@ -50,10 +130,11 @@ export function UsageDialog({
|
|
|
50
130
|
) : (
|
|
51
131
|
<UsageMeters windows={rateLimits} now={now} />
|
|
52
132
|
)}
|
|
53
|
-
<div className="mt-5
|
|
54
|
-
<
|
|
55
|
-
<span className="font-mono text-body-sm text-fg-1">{formatCost(totalCostUsd)}</span>
|
|
133
|
+
<div className="mt-5 border-t border-border pt-3">
|
|
134
|
+
<CostEvidence cost={cost} engine={engine} />
|
|
56
135
|
</div>
|
|
136
|
+
{spend ? <ProfileSpendSection spend={spend} /> : null}
|
|
137
|
+
<p className="mt-3 text-label text-fg-4">{PRICING_NOTE}</p>
|
|
57
138
|
{updatedAt ? <p className="mt-2 text-label text-fg-4">Updated {formatAgoPrecise(updatedAt, now)}</p> : null}
|
|
58
139
|
</DialogBody>
|
|
59
140
|
</DialogContent>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useEffect, useState } from 'react'
|
|
2
|
-
import type
|
|
2
|
+
import { usageIsStale, type UsageWindowRow } from '@workerdeck/protocol'
|
|
3
3
|
import { RotateCcw } from 'lucide-react'
|
|
4
4
|
import { cn } from '../../lib/utils.ts'
|
|
5
5
|
import { formatAgoPrecise, formatCountdown, formatRateLimitWindowLong, rateLimitWindowSeconds } from '../../lib/format.ts'
|
|
@@ -33,6 +33,9 @@ export function UsageMeters({ windows, now, className }: { windows: UsageWindowR
|
|
|
33
33
|
function UsageMeter({ window, now }: { window: UsageWindowRow; now: number }) {
|
|
34
34
|
const { key: windowKey, info, updatedAt, inferredReset } = window
|
|
35
35
|
const utilization = info.utilization ?? 0
|
|
36
|
+
// Drawn differently rather than hidden: this is still the best answer anyone has, it is just not a claim about
|
|
37
|
+
// right now, and a stale reading asserted as current is how a number from two days ago passes for the truth.
|
|
38
|
+
const stale = usageIsStale(window, now)
|
|
36
39
|
const resetsAtMs = info.resetsAt !== undefined ? info.resetsAt * 1000 : undefined
|
|
37
40
|
const duration = rateLimitWindowSeconds(windowKey)
|
|
38
41
|
const remaining = resetsAtMs !== undefined ? (resetsAtMs - now) / 1000 : undefined
|
|
@@ -44,13 +47,18 @@ function UsageMeter({ window, now }: { window: UsageWindowRow; now: number }) {
|
|
|
44
47
|
<div>
|
|
45
48
|
<div className="flex items-baseline justify-between gap-3">
|
|
46
49
|
<span className="truncate text-body-sm text-fg-1">{formatRateLimitWindowLong(windowKey)}</span>
|
|
47
|
-
<span
|
|
50
|
+
<span
|
|
51
|
+
className={cn(
|
|
52
|
+
'shrink-0 font-mono text-body-sm font-medium',
|
|
53
|
+
info.status === 'rejected' ? 'text-danger' : stale ? 'text-fg-4' : 'text-fg-1',
|
|
54
|
+
)}
|
|
55
|
+
>
|
|
48
56
|
{utilization.toFixed(0)}% used
|
|
49
57
|
</span>
|
|
50
58
|
</div>
|
|
51
59
|
<div className="relative mt-2 h-2 rounded-full bg-border">
|
|
52
60
|
<div
|
|
53
|
-
className={cn('h-full rounded-full', meterTintClass(utilization))}
|
|
61
|
+
className={cn('h-full rounded-full', meterTintClass(utilization), stale && 'opacity-40')}
|
|
54
62
|
style={{ width: `${Math.min(100, Math.max(2, utilization))}%` }}
|
|
55
63
|
/>
|
|
56
64
|
{pace !== undefined ? (
|
|
@@ -71,7 +79,12 @@ function UsageMeter({ window, now }: { window: UsageWindowRow; now: number }) {
|
|
|
71
79
|
{info.isUsingOverage ? <span className="text-warning">overage</span> : null}
|
|
72
80
|
{info.status === 'rejected' ? <span className="text-danger">limit reached</span> : null}
|
|
73
81
|
{inferredReset ? <span>window reset · nothing reported since</span> : null}
|
|
74
|
-
{updatedAt && !inferredReset ?
|
|
82
|
+
{updatedAt && !inferredReset ? (
|
|
83
|
+
<span className={cn(stale && 'text-warning')}>
|
|
84
|
+
{stale ? 'last reported ' : ''}
|
|
85
|
+
{formatAgoPrecise(updatedAt, now)}
|
|
86
|
+
</span>
|
|
87
|
+
) : null}
|
|
75
88
|
</div>
|
|
76
89
|
</div>
|
|
77
90
|
)
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import type { SkillInfo, SlashCommandInfo } from '@workerdeck/protocol'
|
|
2
|
+
|
|
3
|
+
export type ClientCommand = {
|
|
4
|
+
name: string
|
|
5
|
+
description: string
|
|
6
|
+
argumentHint?: string
|
|
7
|
+
// Selecting the row populates the composer with `/name ` instead of resolving to a chip, so the
|
|
8
|
+
// argument is typed before anything is submitted.
|
|
9
|
+
requiresArgs?: boolean
|
|
10
|
+
// True when the command consumed the input. False hands the text back to the engine, which is how
|
|
11
|
+
// `/model` with no argument still reaches a CLI that implements it natively.
|
|
12
|
+
run: (args: string) => boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type ComposerRow =
|
|
16
|
+
| { kind: 'command'; name: string; description?: string; argumentHint?: string; aliases?: string[] }
|
|
17
|
+
| { kind: 'client'; name: string; description?: string; argumentHint?: string; command: ClientCommand }
|
|
18
|
+
| { kind: 'skill'; name: string; label: string; description?: string; enabled: boolean; scope?: string; skill: SkillInfo }
|
|
19
|
+
|
|
20
|
+
const KIND_ORDER: Record<ComposerRow['kind'], number> = { command: 0, client: 1, skill: 2 }
|
|
21
|
+
|
|
22
|
+
export function cleanCommandName(name: string): string {
|
|
23
|
+
return name.replace(/\s*\(MCP\)$/i, '')
|
|
24
|
+
}
|
|
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
|
+
|
|
39
|
+
export function skillPrompt(skill: SkillInfo): string {
|
|
40
|
+
return skillToken(skill) + skillTrailingText(skill)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function mergeComposerRows(sources: {
|
|
44
|
+
commands?: readonly SlashCommandInfo[]
|
|
45
|
+
clientCommands?: readonly ClientCommand[]
|
|
46
|
+
skills?: readonly SkillInfo[]
|
|
47
|
+
}): ComposerRow[] {
|
|
48
|
+
const rows: ComposerRow[] = []
|
|
49
|
+
const engineNames = new Set<string>()
|
|
50
|
+
for (const command of sources.commands ?? []) {
|
|
51
|
+
const name = cleanCommandName(command.name)
|
|
52
|
+
if (engineNames.has(name)) {
|
|
53
|
+
continue
|
|
54
|
+
}
|
|
55
|
+
engineNames.add(name)
|
|
56
|
+
for (const alias of command.aliases ?? []) {
|
|
57
|
+
engineNames.add(cleanCommandName(alias))
|
|
58
|
+
}
|
|
59
|
+
rows.push({ kind: 'command', name, description: command.description, argumentHint: command.argumentHint, aliases: command.aliases })
|
|
60
|
+
}
|
|
61
|
+
for (const command of sources.clientCommands ?? []) {
|
|
62
|
+
if (engineNames.has(command.name)) {
|
|
63
|
+
continue
|
|
64
|
+
}
|
|
65
|
+
rows.push({ kind: 'client', name: command.name, description: command.description, argumentHint: command.argumentHint, command })
|
|
66
|
+
}
|
|
67
|
+
for (const skill of sources.skills ?? []) {
|
|
68
|
+
rows.push({
|
|
69
|
+
kind: 'skill',
|
|
70
|
+
name: skill.name,
|
|
71
|
+
label: skill.displayName ?? skill.name,
|
|
72
|
+
description: skill.shortDescription ?? skill.description,
|
|
73
|
+
enabled: skill.enabled,
|
|
74
|
+
scope: skill.scope,
|
|
75
|
+
skill,
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
return rows
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function haystacks(row: ComposerRow): string[] {
|
|
82
|
+
if (row.kind === 'skill') {
|
|
83
|
+
return [row.name, row.label, ...row.name.split(/[-:_]/)]
|
|
84
|
+
}
|
|
85
|
+
return [row.name, ...(row.kind === 'command' ? (row.aliases ?? []) : []), ...row.name.split(':')]
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function matchScore(query: string, candidates: readonly string[]): number {
|
|
89
|
+
const needle = query.toLowerCase()
|
|
90
|
+
const lowered = candidates.map((s) => s.toLowerCase())
|
|
91
|
+
if (lowered.some((h) => h.startsWith(needle))) {
|
|
92
|
+
return 2
|
|
93
|
+
}
|
|
94
|
+
return lowered.some((h) => h.includes(needle)) ? 1 : 0
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function rankComposerRows(query: string, rows: readonly ComposerRow[]): ComposerRow[] {
|
|
98
|
+
const scored: Array<{ score: number; index: number; row: ComposerRow }> = []
|
|
99
|
+
rows.forEach((row, index) => {
|
|
100
|
+
if (row.kind === 'skill' && !row.enabled) {
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
const score = matchScore(query, haystacks(row))
|
|
104
|
+
if (score > 0) {
|
|
105
|
+
scored.push({ score, index, row })
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
scored.sort((a, b) => b.score - a.score || KIND_ORDER[a.row.kind] - KIND_ORDER[b.row.kind] || a.index - b.index)
|
|
109
|
+
return scored.map(({ row }) => row)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function matchClientCommand(text: string, commands: readonly ClientCommand[]): { command: ClientCommand; args: string } | undefined {
|
|
113
|
+
const match = /^\/([\w:-]+)[ \t]*([\s\S]*)$/.exec(text.trim())
|
|
114
|
+
if (!match) {
|
|
115
|
+
return undefined
|
|
116
|
+
}
|
|
117
|
+
const command = commands.find((c) => c.name === match[1])
|
|
118
|
+
return command ? { command, args: match[2]!.trim() } : undefined
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// The serializable projection a native host (the VS Code QuickPick) renders. `insertText` is resolved here
|
|
122
|
+
// so no second surface reimplements skillPrompt or the leading slash.
|
|
123
|
+
export type ComposerCommandRow = {
|
|
124
|
+
kind: ComposerRow['kind']
|
|
125
|
+
name: string
|
|
126
|
+
label: string
|
|
127
|
+
description?: string
|
|
128
|
+
scope?: string
|
|
129
|
+
enabled: boolean
|
|
130
|
+
insertText: string
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function composerCommandRows(rows: readonly ComposerRow[]): ComposerCommandRow[] {
|
|
134
|
+
return rows.map((row) =>
|
|
135
|
+
row.kind === 'skill'
|
|
136
|
+
? {
|
|
137
|
+
kind: row.kind,
|
|
138
|
+
name: row.name,
|
|
139
|
+
label: row.label,
|
|
140
|
+
description: row.description,
|
|
141
|
+
scope: row.scope,
|
|
142
|
+
enabled: row.enabled,
|
|
143
|
+
insertText: skillPrompt(row.skill),
|
|
144
|
+
}
|
|
145
|
+
: {
|
|
146
|
+
kind: row.kind,
|
|
147
|
+
name: row.name,
|
|
148
|
+
label: `/${row.name}`,
|
|
149
|
+
description: row.description,
|
|
150
|
+
enabled: true,
|
|
151
|
+
insertText: `/${row.name} `,
|
|
152
|
+
},
|
|
153
|
+
)
|
|
154
|
+
}
|
|
@@ -1,40 +1,27 @@
|
|
|
1
1
|
import { useEffect, type RefObject } from 'react'
|
|
2
|
+
import { parseFileLink } from '../../lib/file-link.ts'
|
|
3
|
+
import { matchPath, type PathHit } from '../../lib/path-match.ts'
|
|
2
4
|
|
|
3
|
-
export type PathHit
|
|
5
|
+
export type { PathHit } from '../../lib/path-match.ts'
|
|
4
6
|
|
|
5
7
|
const LINKISH = 'wd-path-link'
|
|
6
8
|
|
|
7
|
-
const PATH_RE = /^(\/[^\s:'"`()[\]{}]+|[\w.@~-]+(?:\/[\w.@~-]+)+)(?::(\d+))?$/
|
|
8
|
-
const FILE_RE = /^([\w.@-]+\.[A-Za-z0-9]{1,10})(?::(\d+))?$/
|
|
9
|
-
|
|
10
9
|
const COVERAGE = 0.6
|
|
11
10
|
|
|
12
|
-
export function matchPath(text: string, inCode: boolean): PathHit | undefined {
|
|
13
|
-
const trimmed = text.trim()
|
|
14
|
-
if (!trimmed || trimmed.endsWith('/')) {
|
|
15
|
-
return undefined
|
|
16
|
-
}
|
|
17
|
-
const hit = PATH_RE.exec(trimmed) ?? (inCode ? FILE_RE.exec(trimmed) : null)
|
|
18
|
-
if (!hit) {
|
|
19
|
-
return undefined
|
|
20
|
-
}
|
|
21
|
-
return { path: hit[1]!, line: hit[2] ? Number(hit[2]) : undefined }
|
|
22
|
-
}
|
|
23
|
-
|
|
24
11
|
function hitFor(element: HTMLElement | undefined): PathHit | undefined {
|
|
25
12
|
if (!element) {
|
|
26
13
|
return undefined
|
|
27
14
|
}
|
|
28
15
|
const inCode = element.closest('code') !== null
|
|
29
|
-
const text = element.textContent ?? ''
|
|
16
|
+
const text = element.textContent?.trim() ?? ''
|
|
30
17
|
const hit = matchPath(text, inCode)
|
|
31
18
|
if (!hit) {
|
|
32
19
|
return undefined
|
|
33
20
|
}
|
|
34
|
-
if (!inCode && hit.
|
|
21
|
+
if (!inCode && hit.length < text.length * COVERAGE) {
|
|
35
22
|
return undefined
|
|
36
23
|
}
|
|
37
|
-
return hit
|
|
24
|
+
return { path: hit.path, line: hit.line }
|
|
38
25
|
}
|
|
39
26
|
|
|
40
27
|
export function usePathLinks({
|
|
@@ -119,11 +106,5 @@ export function usePathLinks({
|
|
|
119
106
|
}
|
|
120
107
|
|
|
121
108
|
export function resolveAgainstCwd(path: string, cwd: string | undefined): string {
|
|
122
|
-
|
|
123
|
-
return path
|
|
124
|
-
}
|
|
125
|
-
if (!cwd) {
|
|
126
|
-
return path
|
|
127
|
-
}
|
|
128
|
-
return `${cwd.replace(/\/$/, '')}/${path.replace(/^\.\//, '')}`
|
|
109
|
+
return parseFileLink(path, cwd)?.path ?? path
|
|
129
110
|
}
|
|
@@ -6,8 +6,16 @@
|
|
|
6
6
|
* clipboard I/O concerns alongside its DOM traversal and chip-accessor helpers.
|
|
7
7
|
*/
|
|
8
8
|
import type { Segment, ChipSegment } from './types.ts'
|
|
9
|
-
import {
|
|
10
|
-
|
|
9
|
+
import {
|
|
10
|
+
chipNodeToSegment,
|
|
11
|
+
getChipDisplay,
|
|
12
|
+
getChipSigil,
|
|
13
|
+
getChipTrigger,
|
|
14
|
+
getSelectionRange,
|
|
15
|
+
isChipElement,
|
|
16
|
+
isHTMLElement,
|
|
17
|
+
} from './dom-helpers.ts'
|
|
18
|
+
import { chipPlainText, mergeAdjacentTextSegments } from './prompt-area-engine.ts'
|
|
11
19
|
import { getTextLengthInRange } from './cursor-helpers.ts'
|
|
12
20
|
|
|
13
21
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
@@ -63,7 +71,7 @@ export function serializeFragmentToPlainText(fragment: DocumentFragment): string
|
|
|
63
71
|
text += value
|
|
64
72
|
},
|
|
65
73
|
onChip: (node) => {
|
|
66
|
-
text += (getChipTrigger(node) ?? '') + (getChipDisplay(node) ?? '')
|
|
74
|
+
text += (getChipSigil(node) ?? getChipTrigger(node) ?? '') + (getChipDisplay(node) ?? '')
|
|
67
75
|
},
|
|
68
76
|
onBreak: () => {
|
|
69
77
|
text += '\n'
|
|
@@ -129,6 +137,7 @@ export function parseSegmentsFromClipboard(json: string): Segment[] | null {
|
|
|
129
137
|
trigger: item.trigger,
|
|
130
138
|
value: item.value,
|
|
131
139
|
displayText: item.displayText,
|
|
140
|
+
...(typeof item.sigil === 'string' ? { sigil: item.sigil } : {}),
|
|
132
141
|
...(item.data !== undefined ? { data: item.data } : {}),
|
|
133
142
|
...(item.autoResolved ? { autoResolved: true } : {}),
|
|
134
143
|
}
|
|
@@ -173,7 +182,7 @@ export function insertSegmentsAtCursor(currentSegments: Segment[], pastedSegment
|
|
|
173
182
|
|
|
174
183
|
for (const seg of currentSegments) {
|
|
175
184
|
if (seg.type === 'chip') {
|
|
176
|
-
const chipLen = seg.
|
|
185
|
+
const chipLen = chipPlainText(seg).length
|
|
177
186
|
if (offset >= cursorOffset) {
|
|
178
187
|
insertOnce()
|
|
179
188
|
}
|