@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.
Files changed (34) hide show
  1. package/build/index.d.mts +52 -6
  2. package/build/index.mjs +23 -9
  3. package/build/index.mjs.map +1 -1
  4. package/build/scoped.css +20 -0
  5. package/build/{SessionPanel-BClL7zUS.mjs → use-path-links-CvcxdUXL.mjs} +437 -135
  6. package/build/use-path-links-CvcxdUXL.mjs.map +1 -0
  7. package/build/workspace.mjs +3 -83
  8. package/build/workspace.mjs.map +1 -1
  9. package/package.json +4 -4
  10. package/src/components/agent/Composer.tsx +7 -4
  11. package/src/components/agent/PromptTokenText.tsx +11 -3
  12. package/src/components/agent/SessionInfoDialog.tsx +1 -1
  13. package/src/components/agent/SessionItem.tsx +9 -5
  14. package/src/components/agent/SessionList.tsx +1 -1
  15. package/src/components/agent/SessionPanel.tsx +4 -1
  16. package/src/components/agent/StatusBar.tsx +1 -1
  17. package/src/components/agent/Transcript.tsx +83 -79
  18. package/src/components/agent/TranscriptItemView.tsx +1 -1
  19. package/src/components/agent/UsageDialog.tsx +87 -6
  20. package/src/components/agent/composer-commands.ts +14 -2
  21. package/src/components/agent/use-path-links.ts +5 -19
  22. package/src/components/prompt-area/clipboard-helpers.ts +13 -4
  23. package/src/components/prompt-area/dom-helpers.ts +10 -1
  24. package/src/components/prompt-area/index.ts +10 -1
  25. package/src/components/prompt-area/prompt-area-engine.ts +23 -14
  26. package/src/components/prompt-area/types.ts +20 -0
  27. package/src/components/prompt-area/use-prompt-area-keydown.ts +2 -1
  28. package/src/components/prompt-area/use-prompt-area.ts +8 -2
  29. package/src/components/terminal/StatusLine.tsx +3 -2
  30. package/src/components/terminal/items.tsx +3 -2
  31. package/src/index.ts +3 -1
  32. package/src/lib/path-match.ts +51 -0
  33. package/src/lib/session-cost.ts +65 -0
  34. package/build/SessionPanel-BClL7zUS.mjs.map +0 -1
@@ -10,6 +10,7 @@ import { subagentItems, terminalBlocks, type ToolCallItem } from '../terminal/bl
10
10
  import { taskBrief, taskBusy } from '../terminal/tool-run.ts'
11
11
  import { type TranscriptRow } from './transcript-rows.ts'
12
12
  import { TranscriptRows } from './TranscriptRows.tsx'
13
+ import { SkillNamesProvider } from './PromptTokenText.tsx'
13
14
  import { TerminalSurface } from '../terminal/surface.tsx'
14
15
  import { ROW_GAP, TranscriptVariantProvider, type TranscriptDensity, type TranscriptVariant } from './transcript-variant.tsx'
15
16
 
@@ -136,90 +137,93 @@ export function Transcript({
136
137
  }
137
138
  return [...fold(0, boundary), { key: 'recap' as const, line: recap }, ...fold(boundary, items.length)]
138
139
  }, [items, boundary, recap, terminal, brief])
140
+ const skillNames = useMemo(() => state.skills?.map((skill) => skill.name), [state.skills])
139
141
  return (
140
142
  <TranscriptVariantProvider value={variant}>
141
- <Conversation className={cn(replaying && 'invisible', className)}>
142
- <ConversationContent className={cn(terminal && 'gap-0 p-0')}>
143
- <TerminalShell active={terminal} fontSize={fontSize} lineHeight={lineHeight} affordances={affordances}>
144
- {frame ? (
145
- frameTask === undefined && !replaying ? (
146
- <div className={cn(terminal ? 'term-row text-fg-4' : 'p-4 text-body-sm text-fg-4')}>
147
- This sub-agent's work is not in this transcript.
148
- </div>
149
- ) : null
150
- ) : items.length === 0 && state.status !== 'starting' ? (
151
- emptyState !== undefined ? (
152
- emptyState
153
- ) : (
154
- <SessionEmptyState
155
- cwd={state.cwd}
156
- hasCommands={!!state.commands?.length}
157
- hasSkills={!!state.skills?.some((s) => s.enabled)}
158
- canBrowseFiles={canBrowseFiles}
159
- />
160
- )
161
- ) : null}
162
- {frame && frameTask === undefined && !replaying ? null : (
163
- <TranscriptRows
164
- rows={rows}
165
- boundary={boundary}
166
- since={catchUp?.since}
167
- terminal={terminal}
168
- replaying={replaying}
169
- stickyPrompt={!frame && stickyPrompt}
170
- gap={gap}
171
- fontSize={fontSize}
172
- lineHeight={lineHeight}
173
- items={items}
174
- pendingApprovals={state.pendingApprovals}
175
- scrubber={scrubber}
176
- bookmarks={frame ? undefined : bookmarks}
177
- affordances={affordances}
178
- fileUrl={fileUrl}
179
- attachmentUrl={attachmentUrl}
180
- hostImage={hostImage}
181
- jumpToRecapRef={jumpToRecapRef}
182
- repinRef={repinRef}
183
- reveal={frame ? undefined : reveal}
184
- frameParentId={frame?.parentToolUseId}
185
- onOpenSubagent={frame ? undefined : onOpenSubagent}
186
- />
187
- )}
188
- {(frame ? frameTask !== undefined && taskBusy(frameTask, items) : showLoader(state)) ? (
189
- terminal ? (
190
- <>
191
- {state.items.length > 0 ? <div className="term-blank" aria-hidden /> : null}
192
- <WorkingRow
193
- label={state.status === 'starting' ? 'Starting…' : 'Working…'}
194
- startedAt={runStartedAt}
195
- tokens={state.contextUsage?.totalTokens}
143
+ <SkillNamesProvider names={skillNames}>
144
+ <Conversation className={cn(replaying && 'invisible', className)}>
145
+ <ConversationContent className={cn(terminal && 'gap-0 p-0')}>
146
+ <TerminalShell active={terminal} fontSize={fontSize} lineHeight={lineHeight} affordances={affordances}>
147
+ {frame ? (
148
+ frameTask === undefined && !replaying ? (
149
+ <div className={cn(terminal ? 'term-row text-fg-4' : 'p-4 text-body-sm text-fg-4')}>
150
+ This sub-agent's work is not in this transcript.
151
+ </div>
152
+ ) : null
153
+ ) : items.length === 0 && state.status !== 'starting' ? (
154
+ emptyState !== undefined ? (
155
+ emptyState
156
+ ) : (
157
+ <SessionEmptyState
158
+ cwd={state.cwd}
159
+ hasCommands={!!state.commands?.length}
160
+ hasSkills={!!state.skills?.some((s) => s.enabled)}
161
+ canBrowseFiles={canBrowseFiles}
196
162
  />
197
- </>
198
- ) : (
199
- <Loader label={state.status === 'starting' ? 'Starting session…' : undefined} />
200
- )
201
- ) : null}
202
- </TerminalShell>
203
- </ConversationContent>
204
- <ConversationScrollButton />
205
- {replaying ? (
206
- <div
207
- data-slot="transcript-hold"
208
- aria-hidden
209
- className="wd-hold-appear visible pointer-events-none absolute inset-0 overflow-hidden"
210
- >
211
- <div className={cn('mx-auto w-full max-w-[var(--wd-transcript-max-width)]', !terminal && 'px-4 py-4')}>
212
- {terminal ? (
213
- <TerminalSurface fontSize={fontSize} lineHeight={lineHeight} affordances={false} bleed="1ch" className="term-transcript">
214
- <WorkingRow label="Loading…" />
215
- </TerminalSurface>
216
- ) : (
217
- <Loader label="Loading session…" />
163
+ )
164
+ ) : null}
165
+ {frame && frameTask === undefined && !replaying ? null : (
166
+ <TranscriptRows
167
+ rows={rows}
168
+ boundary={boundary}
169
+ since={catchUp?.since}
170
+ terminal={terminal}
171
+ replaying={replaying}
172
+ stickyPrompt={!frame && stickyPrompt}
173
+ gap={gap}
174
+ fontSize={fontSize}
175
+ lineHeight={lineHeight}
176
+ items={items}
177
+ pendingApprovals={state.pendingApprovals}
178
+ scrubber={scrubber}
179
+ bookmarks={frame ? undefined : bookmarks}
180
+ affordances={affordances}
181
+ fileUrl={fileUrl}
182
+ attachmentUrl={attachmentUrl}
183
+ hostImage={hostImage}
184
+ jumpToRecapRef={jumpToRecapRef}
185
+ repinRef={repinRef}
186
+ reveal={frame ? undefined : reveal}
187
+ frameParentId={frame?.parentToolUseId}
188
+ onOpenSubagent={frame ? undefined : onOpenSubagent}
189
+ />
218
190
  )}
191
+ {(frame ? frameTask !== undefined && taskBusy(frameTask, items) : showLoader(state)) ? (
192
+ terminal ? (
193
+ <>
194
+ {state.items.length > 0 ? <div className="term-blank" aria-hidden /> : null}
195
+ <WorkingRow
196
+ label={state.status === 'starting' ? 'Starting…' : 'Working…'}
197
+ startedAt={runStartedAt}
198
+ tokens={state.contextUsage?.totalTokens}
199
+ />
200
+ </>
201
+ ) : (
202
+ <Loader label={state.status === 'starting' ? 'Starting session…' : undefined} />
203
+ )
204
+ ) : null}
205
+ </TerminalShell>
206
+ </ConversationContent>
207
+ <ConversationScrollButton />
208
+ {replaying ? (
209
+ <div
210
+ data-slot="transcript-hold"
211
+ aria-hidden
212
+ className="wd-hold-appear visible pointer-events-none absolute inset-0 overflow-hidden"
213
+ >
214
+ <div className={cn('mx-auto w-full max-w-[var(--wd-transcript-max-width)]', !terminal && 'px-4 py-4')}>
215
+ {terminal ? (
216
+ <TerminalSurface fontSize={fontSize} lineHeight={lineHeight} affordances={false} bleed="1ch" className="term-transcript">
217
+ <WorkingRow label="Loading…" />
218
+ </TerminalSurface>
219
+ ) : (
220
+ <Loader label="Loading session…" />
221
+ )}
222
+ </div>
219
223
  </div>
220
- </div>
221
- ) : null}
222
- </Conversation>
224
+ ) : null}
225
+ </Conversation>
226
+ </SkillNamesProvider>
223
227
  </TranscriptVariantProvider>
224
228
  )
225
229
  }
@@ -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>
@@ -23,9 +23,21 @@ export function cleanCommandName(name: string): string {
23
23
  return name.replace(/\s*\(MCP\)$/i, '')
24
24
  }
25
25
 
26
+ // Codex's own mention token, which its system prompt names and its runner maps to a skill input item.
27
+ export function skillToken(skill: SkillInfo): string {
28
+ return `$${skill.name}`
29
+ }
30
+
31
+ // What follows the `$name` chip as editable text: the default prompt with the token taken out, or
32
+ // nothing. Always space-terminated so typing continues from a clean boundary.
33
+ export function skillTrailingText(skill: SkillInfo): string {
34
+ const token = skillToken(skill)
35
+ const rest = (skill.defaultPrompt ?? '').split(token).join(' ').replace(/\s+/g, ' ').trim()
36
+ return rest ? ` ${rest} ` : ' '
37
+ }
38
+
26
39
  export function skillPrompt(skill: SkillInfo): string {
27
- const base = skill.defaultPrompt?.trim() || `Use the ${skill.displayName ?? skill.name} skill:`
28
- return /\s$/.test(base) ? base : base + ' '
40
+ return skillToken(skill) + skillTrailingText(skill)
29
41
  }
30
42
 
31
43
  export function mergeComposerRows(sources: {
@@ -1,41 +1,27 @@
1
1
  import { useEffect, type RefObject } from 'react'
2
2
  import { parseFileLink } from '../../lib/file-link.ts'
3
+ import { matchPath, type PathHit } from '../../lib/path-match.ts'
3
4
 
4
- export type PathHit = { path: string; line?: number }
5
+ export type { PathHit } from '../../lib/path-match.ts'
5
6
 
6
7
  const LINKISH = 'wd-path-link'
7
8
 
8
- const PATH_RE = /^(\/[^\s:'"`()[\]{}]+|[\w.@~-]+(?:\/[\w.@~-]+)+)(?::(\d+))?$/
9
- const FILE_RE = /^([\w.@-]+\.[A-Za-z0-9]{1,10})(?::(\d+))?$/
10
-
11
9
  const COVERAGE = 0.6
12
10
 
13
- export function matchPath(text: string, inCode: boolean): PathHit | undefined {
14
- const trimmed = text.trim()
15
- if (!trimmed || trimmed.endsWith('/')) {
16
- return undefined
17
- }
18
- const hit = PATH_RE.exec(trimmed) ?? (inCode ? FILE_RE.exec(trimmed) : null)
19
- if (!hit) {
20
- return undefined
21
- }
22
- return { path: hit[1]!, line: hit[2] ? Number(hit[2]) : undefined }
23
- }
24
-
25
11
  function hitFor(element: HTMLElement | undefined): PathHit | undefined {
26
12
  if (!element) {
27
13
  return undefined
28
14
  }
29
15
  const inCode = element.closest('code') !== null
30
- const text = element.textContent ?? ''
16
+ const text = element.textContent?.trim() ?? ''
31
17
  const hit = matchPath(text, inCode)
32
18
  if (!hit) {
33
19
  return undefined
34
20
  }
35
- if (!inCode && hit.path.length < text.trim().length * COVERAGE) {
21
+ if (!inCode && hit.length < text.length * COVERAGE) {
36
22
  return undefined
37
23
  }
38
- return hit
24
+ return { path: hit.path, line: hit.line }
39
25
  }
40
26
 
41
27
  export function usePathLinks({
@@ -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
  }
@@ -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 { PromptAreaHandle, PromptAreaProps, Segment, ChipSegment, TextSegment, TriggerConfig, TriggerSuggestion } from './types.ts'
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 as `{trigger}{displayText}` (e.g., "@Alice").
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 `${seg.trigger}${seg.displayText}`
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.trigger.length + seg.displayText.length
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 = `${seg.trigger}${seg.displayText}`
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: ' ' + afterText.replace(/^\s/, '') })
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.trigger.length + seg.displayText.length
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
- // +1 accounts for the trailing space after the chip
276
- const cursorOffset = lastChipEndOffset === -1 ? runningOffset : lastChipEndOffset + 1
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 + `${seg.trigger}${seg.displayText}`.length
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 = `${seg.trigger}${seg.displayText}`
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 = `${seg.trigger}${seg.displayText}`
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