@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.
Files changed (42) hide show
  1. package/build/{SessionPanel-_lQLhWOw.d.mts → SessionPanel-BVbau2eR.d.mts} +68 -4
  2. package/build/index.d.mts +62 -9
  3. package/build/index.mjs +24 -10
  4. package/build/index.mjs.map +1 -1
  5. package/build/scoped.css +44 -0
  6. package/build/{SessionPanel-Dg4xf-ql.mjs → use-path-links-CvcxdUXL.mjs} +1080 -497
  7. package/build/use-path-links-CvcxdUXL.mjs.map +1 -0
  8. package/build/workspace.d.mts +3 -2
  9. package/build/workspace.mjs +5 -86
  10. package/build/workspace.mjs.map +1 -1
  11. package/package.json +4 -4
  12. package/src/components/agent/Composer.tsx +88 -83
  13. package/src/components/agent/PromptTokenText.tsx +11 -3
  14. package/src/components/agent/SessionInfoDialog.tsx +1 -1
  15. package/src/components/agent/SessionItem.tsx +12 -6
  16. package/src/components/agent/SessionList.tsx +1 -1
  17. package/src/components/agent/SessionPanel.tsx +308 -225
  18. package/src/components/agent/SessionWorkspace.tsx +3 -0
  19. package/src/components/agent/StatusBar.tsx +1 -1
  20. package/src/components/agent/Transcript.tsx +83 -79
  21. package/src/components/agent/TranscriptItemView.tsx +1 -1
  22. package/src/components/agent/UsageDialog.tsx +87 -6
  23. package/src/components/agent/UsageMeters.tsx +17 -4
  24. package/src/components/agent/composer-commands.ts +154 -0
  25. package/src/components/agent/use-path-links.ts +7 -26
  26. package/src/components/prompt-area/clipboard-helpers.ts +13 -4
  27. package/src/components/prompt-area/dom-helpers.ts +10 -1
  28. package/src/components/prompt-area/index.ts +10 -1
  29. package/src/components/prompt-area/prompt-area-engine.ts +23 -14
  30. package/src/components/prompt-area/types.ts +20 -0
  31. package/src/components/prompt-area/use-prompt-area-keydown.ts +2 -1
  32. package/src/components/prompt-area/use-prompt-area.ts +8 -2
  33. package/src/components/terminal/StatusLine.tsx +3 -2
  34. package/src/components/terminal/file-link.tsx +20 -0
  35. package/src/components/terminal/items.tsx +3 -2
  36. package/src/components/terminal/markdown.tsx +31 -5
  37. package/src/index.ts +14 -2
  38. package/src/lib/file-link.ts +70 -0
  39. package/src/lib/path-match.ts +51 -0
  40. package/src/lib/session-cost.ts +65 -0
  41. package/src/styles/terminal.css +15 -0
  42. 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
- <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
  }
@@ -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: number
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 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>
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 { UsageWindowRow } from '@workerdeck/protocol'
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 className={cn('shrink-0 font-mono text-body-sm font-medium', info.status === 'rejected' ? 'text-danger' : 'text-fg-1')}>
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 ? <span>{formatAgoPrecise(updatedAt, now)}</span> : null}
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 = { path: string; line?: number }
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.path.length < text.trim().length * COVERAGE) {
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
- if (path.startsWith('/')) {
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 { chipNodeToSegment, getChipDisplay, getChipTrigger, getSelectionRange, isChipElement, isHTMLElement } from './dom-helpers.ts'
10
- import { mergeAdjacentTextSegments } from './prompt-area-engine.ts'
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.trigger.length + seg.displayText.length
185
+ const chipLen = chipPlainText(seg).length
177
186
  if (offset >= cursorOffset) {
178
187
  insertOnce()
179
188
  }