@workerdeck/ui 2.9.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/index.d.mts +52 -6
- package/build/index.mjs +23 -9
- package/build/index.mjs.map +1 -1
- package/build/scoped.css +20 -0
- package/build/{SessionPanel-BClL7zUS.mjs → use-path-links-CvcxdUXL.mjs} +437 -135
- package/build/use-path-links-CvcxdUXL.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 +9 -5
- package/src/components/agent/SessionList.tsx +1 -1
- package/src/components/agent/SessionPanel.tsx +4 -1
- 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/composer-commands.ts +14 -2
- package/src/components/agent/use-path-links.ts +5 -19
- 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/items.tsx +3 -2
- package/src/index.ts +3 -1
- package/src/lib/path-match.ts +51 -0
- package/src/lib/session-cost.ts +65 -0
- 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
|
}
|
|
@@ -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>
|
|
@@ -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,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({
|
|
@@ -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
|
}
|
|
@@ -139,6 +139,13 @@ export function getChipData(node: Node): unknown {
|
|
|
139
139
|
return safeJsonParse(raw)
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
export function getChipSigil(node: Node): string | undefined {
|
|
143
|
+
if (!isChipElement(node)) {
|
|
144
|
+
return undefined
|
|
145
|
+
}
|
|
146
|
+
return node.dataset.chipSigil
|
|
147
|
+
}
|
|
148
|
+
|
|
142
149
|
/**
|
|
143
150
|
* Length of a chip's plain-text representation (`trigger + displayText`).
|
|
144
151
|
*
|
|
@@ -148,7 +155,7 @@ export function getChipData(node: Node): unknown {
|
|
|
148
155
|
* `textContent` for resilience against externally-mutated nodes.
|
|
149
156
|
*/
|
|
150
157
|
export function chipNodeTextLength(node: HTMLElement): number {
|
|
151
|
-
const trigger = node.dataset.chipTrigger ?? ''
|
|
158
|
+
const trigger = node.dataset.chipSigil ?? node.dataset.chipTrigger ?? ''
|
|
152
159
|
const display = node.dataset.chipDisplay ?? node.textContent ?? ''
|
|
153
160
|
return trigger.length + display.length
|
|
154
161
|
}
|
|
@@ -174,6 +181,7 @@ export function chipNodeToSegment(node: Node): ChipSegment | null {
|
|
|
174
181
|
return null
|
|
175
182
|
}
|
|
176
183
|
|
|
184
|
+
const sigil = getChipSigil(node)
|
|
177
185
|
const data = getChipData(node)
|
|
178
186
|
const autoResolved = getChipAutoResolved(node)
|
|
179
187
|
|
|
@@ -182,6 +190,7 @@ export function chipNodeToSegment(node: Node): ChipSegment | null {
|
|
|
182
190
|
trigger,
|
|
183
191
|
value,
|
|
184
192
|
displayText,
|
|
193
|
+
...(sigil !== undefined ? { sigil } : {}),
|
|
185
194
|
...(data !== undefined ? { data } : {}),
|
|
186
195
|
...(autoResolved ? { autoResolved: true } : {}),
|
|
187
196
|
}
|
|
@@ -7,4 +7,13 @@ export { PromptArea } from './prompt-area.tsx'
|
|
|
7
7
|
export { usePromptAreaState } from './use-prompt-area-state.ts'
|
|
8
8
|
export { commandTrigger, mentionTrigger, hashtagTrigger, launchTrigger } from './trigger-presets.ts'
|
|
9
9
|
export { segmentsToPlainText, plainTextToSegments, isSegmentsEmpty, getChipsByTrigger } from './segment-helpers.ts'
|
|
10
|
-
export type {
|
|
10
|
+
export type {
|
|
11
|
+
ChipOptions,
|
|
12
|
+
PromptAreaHandle,
|
|
13
|
+
PromptAreaProps,
|
|
14
|
+
Segment,
|
|
15
|
+
ChipSegment,
|
|
16
|
+
TextSegment,
|
|
17
|
+
TriggerConfig,
|
|
18
|
+
TriggerSuggestion,
|
|
19
|
+
} from './types.ts'
|
|
@@ -6,9 +6,16 @@ import type { Segment, ChipSegment, TriggerConfig, TriggerPosition, ActiveTrigge
|
|
|
6
6
|
|
|
7
7
|
// Serialization
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* A chip's plain-text form: `{sigil ?? trigger}{displayText}` (e.g., "@Alice").
|
|
11
|
+
*/
|
|
12
|
+
export function chipPlainText(seg: ChipSegment): string {
|
|
13
|
+
return `${seg.sigil ?? seg.trigger}${seg.displayText}`
|
|
14
|
+
}
|
|
15
|
+
|
|
9
16
|
/**
|
|
10
17
|
* Converts an array of segments to a plain text string.
|
|
11
|
-
* Chips are represented
|
|
18
|
+
* Chips are represented by {@link chipPlainText}.
|
|
12
19
|
*/
|
|
13
20
|
export function segmentsToPlainText(segments: Segment[]): string {
|
|
14
21
|
return segments
|
|
@@ -16,7 +23,7 @@ export function segmentsToPlainText(segments: Segment[]): string {
|
|
|
16
23
|
if (seg.type === 'text') {
|
|
17
24
|
return seg.text
|
|
18
25
|
}
|
|
19
|
-
return
|
|
26
|
+
return chipPlainText(seg)
|
|
20
27
|
})
|
|
21
28
|
.join('')
|
|
22
29
|
}
|
|
@@ -45,7 +52,7 @@ export function truncateSegmentsToLength(segments: Segment[], maxLength: number)
|
|
|
45
52
|
const result: Segment[] = []
|
|
46
53
|
let length = 0
|
|
47
54
|
for (const seg of segments) {
|
|
48
|
-
const segLength = seg.type === 'text' ? seg.text.length : seg.
|
|
55
|
+
const segLength = seg.type === 'text' ? seg.text.length : chipPlainText(seg).length
|
|
49
56
|
if (length + segLength <= maxLength) {
|
|
50
57
|
result.push(seg)
|
|
51
58
|
length += segLength
|
|
@@ -195,7 +202,8 @@ export function detectActiveTrigger(text: string, cursorPos: number, triggers: T
|
|
|
195
202
|
export function resolveChip(
|
|
196
203
|
segments: Segment[],
|
|
197
204
|
activeTrigger: ActiveTrigger,
|
|
198
|
-
chip: { value: string; displayText: string; data?: unknown; autoResolved?: boolean },
|
|
205
|
+
chip: { value: string; displayText: string; sigil?: string; data?: unknown; autoResolved?: boolean },
|
|
206
|
+
trailingText = ' ',
|
|
199
207
|
): { segments: Segment[]; cursorOffset: number } {
|
|
200
208
|
const triggerStart = activeTrigger.startOffset
|
|
201
209
|
const triggerEnd = triggerStart + 1 + activeTrigger.query.length // +1 for trigger char
|
|
@@ -205,7 +213,7 @@ export function resolveChip(
|
|
|
205
213
|
|
|
206
214
|
for (const seg of segments) {
|
|
207
215
|
if (seg.type === 'chip') {
|
|
208
|
-
const chipText =
|
|
216
|
+
const chipText = chipPlainText(seg)
|
|
209
217
|
const chipStart = offset
|
|
210
218
|
const chipEnd = offset + chipText.length
|
|
211
219
|
|
|
@@ -239,16 +247,17 @@ export function resolveChip(
|
|
|
239
247
|
trigger: activeTrigger.config.char,
|
|
240
248
|
value: chip.value,
|
|
241
249
|
displayText: chip.displayText,
|
|
250
|
+
...(chip.sigil !== undefined ? { sigil: chip.sigil } : {}),
|
|
242
251
|
...(chip.data !== undefined ? { data: chip.data } : {}),
|
|
243
252
|
...(chip.autoResolved ? { autoResolved: true } : {}),
|
|
244
253
|
}
|
|
245
254
|
newSegments.push(newChip)
|
|
246
255
|
|
|
247
|
-
// Add trailing space after chip, then any remaining text
|
|
256
|
+
// Add the trailing text (a space by default) after the chip, then any remaining text
|
|
248
257
|
if (afterText) {
|
|
249
|
-
newSegments.push({ type: 'text', text:
|
|
258
|
+
newSegments.push({ type: 'text', text: trailingText + afterText.replace(/^\s/, '') })
|
|
250
259
|
} else {
|
|
251
|
-
newSegments.push({ type: 'text', text:
|
|
260
|
+
newSegments.push({ type: 'text', text: trailingText })
|
|
252
261
|
}
|
|
253
262
|
}
|
|
254
263
|
|
|
@@ -266,14 +275,14 @@ export function resolveChip(
|
|
|
266
275
|
if (seg.type === 'text') {
|
|
267
276
|
runningOffset += seg.text.length
|
|
268
277
|
} else {
|
|
269
|
-
runningOffset += seg.
|
|
278
|
+
runningOffset += chipPlainText(seg).length
|
|
270
279
|
if (seg.value === chip.value && seg.displayText === chip.displayText && seg.trigger === activeTrigger.config.char) {
|
|
271
280
|
lastChipEndOffset = runningOffset
|
|
272
281
|
}
|
|
273
282
|
}
|
|
274
283
|
}
|
|
275
|
-
//
|
|
276
|
-
const cursorOffset = lastChipEndOffset === -1 ? runningOffset : lastChipEndOffset +
|
|
284
|
+
// The caret lands after the chip and its trailing text
|
|
285
|
+
const cursorOffset = lastChipEndOffset === -1 ? runningOffset : lastChipEndOffset + trailingText.length
|
|
277
286
|
|
|
278
287
|
return { segments: merged, cursorOffset }
|
|
279
288
|
}
|
|
@@ -302,7 +311,7 @@ export function resolveText(
|
|
|
302
311
|
|
|
303
312
|
for (const seg of segments) {
|
|
304
313
|
if (seg.type === 'chip') {
|
|
305
|
-
const chipEnd = offset +
|
|
314
|
+
const chipEnd = offset + chipPlainText(seg).length
|
|
306
315
|
// A trigger range can never overlap a chip - chips are atomic - so a chip
|
|
307
316
|
// is either wholly before or wholly after, and is kept either way.
|
|
308
317
|
if (chipEnd <= triggerStart || offset >= triggerEnd) {
|
|
@@ -377,7 +386,7 @@ export function revertChipAtIndex(segments: Segment[], index: number): { segment
|
|
|
377
386
|
return null
|
|
378
387
|
}
|
|
379
388
|
|
|
380
|
-
const revertedText =
|
|
389
|
+
const revertedText = chipPlainText(seg)
|
|
381
390
|
const result = [...segments.slice(0, index), { type: 'text' as const, text: revertedText }, ...segments.slice(index + 1)]
|
|
382
391
|
return { segments: mergeAdjacentTextSegments(result), revertedText }
|
|
383
392
|
}
|
|
@@ -491,7 +500,7 @@ export function replaceTextRange(segments: Segment[], start: number, end: number
|
|
|
491
500
|
|
|
492
501
|
for (const seg of segments) {
|
|
493
502
|
if (seg.type === 'chip') {
|
|
494
|
-
const chipText =
|
|
503
|
+
const chipText = chipPlainText(seg)
|
|
495
504
|
const chipStart = offset
|
|
496
505
|
const chipEnd = offset + chipText.length
|
|
497
506
|
|
|
@@ -23,6 +23,12 @@ export type ChipSegment = {
|
|
|
23
23
|
value: string
|
|
24
24
|
/** The display text shown in the chip */
|
|
25
25
|
displayText: string
|
|
26
|
+
/**
|
|
27
|
+
* The character the chip serialises and renders with, when it differs from
|
|
28
|
+
* `trigger`. The trigger is the menu the chip came from; the sigil is the
|
|
29
|
+
* token the host parses. Unset, the trigger character is the sigil.
|
|
30
|
+
*/
|
|
31
|
+
sigil?: string
|
|
26
32
|
/** Optional data payload attached to the chip */
|
|
27
33
|
data?: unknown
|
|
28
34
|
/**
|
|
@@ -74,6 +80,11 @@ export type TriggerSuggestion = {
|
|
|
74
80
|
data?: unknown
|
|
75
81
|
}
|
|
76
82
|
|
|
83
|
+
export type ChipOptions = {
|
|
84
|
+
sigil?: string
|
|
85
|
+
trailingText?: string
|
|
86
|
+
}
|
|
87
|
+
|
|
77
88
|
/**
|
|
78
89
|
* Configuration for a trigger character.
|
|
79
90
|
*/
|
|
@@ -113,6 +124,15 @@ export type TriggerConfig = {
|
|
|
113
124
|
* `onChipAdd` does not fire either.
|
|
114
125
|
*/
|
|
115
126
|
insertAsText?: (suggestion: TriggerSuggestion) => string | undefined
|
|
127
|
+
/**
|
|
128
|
+
* For 'dropdown' mode: per-suggestion overrides for the chip a selection
|
|
129
|
+
* resolves to. `sigil` replaces the trigger character in the chip's text and
|
|
130
|
+
* serialisation (a `/` menu inserting a `$name` token); `trailingText` is
|
|
131
|
+
* the editable text placed after the chip in place of the default single
|
|
132
|
+
* space (it is inserted verbatim, so include the leading space). Return
|
|
133
|
+
* undefined for the plain chip.
|
|
134
|
+
*/
|
|
135
|
+
chipOptions?: (suggestion: TriggerSuggestion) => ChipOptions | undefined
|
|
116
136
|
/**
|
|
117
137
|
* For 'callback' and 'launch' modes: called when the trigger is activated.
|
|
118
138
|
* Receives the full input text and cursor position. For 'launch' it fires on
|