@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
|
@@ -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
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
revertChipAtIndex,
|
|
11
11
|
replaceTextRange,
|
|
12
12
|
toggleMarkdownWrap,
|
|
13
|
+
chipPlainText,
|
|
13
14
|
} from './prompt-area-engine.ts'
|
|
14
15
|
import {
|
|
15
16
|
getListContext,
|
|
@@ -138,7 +139,7 @@ export function usePromptAreaKeydown({
|
|
|
138
139
|
if (s.type === 'text') {
|
|
139
140
|
targetOffset += s.text.length
|
|
140
141
|
} else {
|
|
141
|
-
targetOffset += s.
|
|
142
|
+
targetOffset += chipPlainText(s).length
|
|
142
143
|
}
|
|
143
144
|
}
|
|
144
145
|
targetOffset += result.revertedText.length
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
resolveChip,
|
|
13
13
|
resolveText,
|
|
14
14
|
truncateSegmentsToLength,
|
|
15
|
+
chipPlainText,
|
|
15
16
|
} from './prompt-area-engine.ts'
|
|
16
17
|
import {
|
|
17
18
|
autoFormatListPrefix,
|
|
@@ -223,6 +224,9 @@ export function usePromptArea({
|
|
|
223
224
|
chip.dataset.chipTrigger = seg.trigger
|
|
224
225
|
chip.dataset.chipValue = seg.value
|
|
225
226
|
chip.dataset.chipDisplay = seg.displayText
|
|
227
|
+
if (seg.sigil !== undefined) {
|
|
228
|
+
chip.dataset.chipSigil = seg.sigil
|
|
229
|
+
}
|
|
226
230
|
if (seg.data !== undefined) {
|
|
227
231
|
const json = safeJsonStringify(seg.data)
|
|
228
232
|
if (json) {
|
|
@@ -236,7 +240,7 @@ export function usePromptArea({
|
|
|
236
240
|
const chipStyle = triggerConfig?.chipStyle ?? 'pill'
|
|
237
241
|
chip.dataset.chipStyle = chipStyle
|
|
238
242
|
chip.className = cn('prompt-area-chip', chipStyle === 'inline' && 'prompt-area-chip--inline', triggerConfig?.chipClassName)
|
|
239
|
-
chip.textContent =
|
|
243
|
+
chip.textContent = chipPlainText(seg)
|
|
240
244
|
chip.setAttribute('role', 'button')
|
|
241
245
|
chip.setAttribute('tabindex', '-1')
|
|
242
246
|
editor.appendChild(chip)
|
|
@@ -598,10 +602,12 @@ export function usePromptArea({
|
|
|
598
602
|
}
|
|
599
603
|
|
|
600
604
|
const displayText = activeTrigger.config.onSelect?.(suggestion) ?? suggestion.label
|
|
605
|
+
const options = activeTrigger.config.chipOptions?.(suggestion)
|
|
601
606
|
|
|
602
607
|
const chipData = {
|
|
603
608
|
value: suggestion.value,
|
|
604
609
|
displayText: displayText || suggestion.label,
|
|
610
|
+
...(options?.sigil !== undefined ? { sigil: options.sigil } : {}),
|
|
605
611
|
data: suggestion.data,
|
|
606
612
|
}
|
|
607
613
|
|
|
@@ -616,7 +622,7 @@ export function usePromptArea({
|
|
|
616
622
|
}, 0)
|
|
617
623
|
return
|
|
618
624
|
}
|
|
619
|
-
const result = resolveChip(segments, activeTrigger, chipData)
|
|
625
|
+
const result = resolveChip(segments, activeTrigger, chipData, options?.trailingText)
|
|
620
626
|
|
|
621
627
|
onChange(result.segments)
|
|
622
628
|
renderSegmentsToDOM(result.segments)
|
|
@@ -75,10 +75,11 @@ export function TerminalStatusLine({
|
|
|
75
75
|
)
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
|
|
78
|
+
const costUsd = state.costUsd ?? (state.totalCostUsd || undefined)
|
|
79
|
+
if (costUsd !== undefined && costUsd > 0) {
|
|
79
80
|
parts.push(
|
|
80
81
|
<Ink key="cost" tone="faint">
|
|
81
|
-
{formatCost(
|
|
82
|
+
{formatCost(costUsd)}
|
|
82
83
|
</Ink>,
|
|
83
84
|
)
|
|
84
85
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { createContext, useContext, type ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
export type FileLinkOpener = (path: string, line?: number) => void
|
|
4
|
+
|
|
5
|
+
export type FileLinkHandle = {
|
|
6
|
+
cwd?: string
|
|
7
|
+
open: FileLinkOpener
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const FileLinkContext = createContext<FileLinkHandle | undefined>(undefined)
|
|
11
|
+
|
|
12
|
+
// A missing provider leaves every link an ordinary anchor, so embeddings that have nowhere to
|
|
13
|
+
// open a file pay nothing - the same contract BookmarkContext has.
|
|
14
|
+
export function FileLinkProvider({ value, children }: { value: FileLinkHandle | undefined; children: ReactNode }) {
|
|
15
|
+
return <FileLinkContext.Provider value={value}>{children}</FileLinkContext.Provider>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function useFileLinks(): FileLinkHandle | undefined {
|
|
19
|
+
return useContext(FileLinkContext)
|
|
20
|
+
}
|
|
@@ -3,6 +3,7 @@ import type { TranscriptItem } from '@workerdeck/react'
|
|
|
3
3
|
import { compactionText, formatBytes, formatCost, formatDuration, toolInputPreview } from '../../lib/format.ts'
|
|
4
4
|
import { isMutatingTool } from '../../lib/tool-icon.ts'
|
|
5
5
|
import { usePulse } from '../agent/pulse.tsx'
|
|
6
|
+
import { PromptTokenText } from '../agent/PromptTokenText.tsx'
|
|
6
7
|
import { BookmarkAction, CopyAction, WithActions } from './affordances.tsx'
|
|
7
8
|
import { TerminalDiff } from './diff.tsx'
|
|
8
9
|
import { TerminalMarkdown } from './markdown.tsx'
|
|
@@ -46,7 +47,7 @@ export function UserRow({ item }: { item: Extract<TranscriptItem, { kind: 'user'
|
|
|
46
47
|
{item.text
|
|
47
48
|
? item.text.split('\n').map((line, index) => (
|
|
48
49
|
<Row key={index} glyph={index === 0 ? PROMPT_GLYPH : undefined} glyphTone="dim" tone="fg">
|
|
49
|
-
{line
|
|
50
|
+
{line ? <PromptTokenText text={line} /> : ' '}
|
|
50
51
|
</Row>
|
|
51
52
|
))
|
|
52
53
|
: null}
|
|
@@ -270,7 +271,7 @@ export function TurnResultRow({ item }: { item: Extract<TranscriptItem, { kind:
|
|
|
270
271
|
return (
|
|
271
272
|
<div>
|
|
272
273
|
<Row tone={item.isError ? 'red' : 'faint'}>
|
|
273
|
-
{item.isError ? item.subtype : 'done'} · {formatDuration(item.durationMs)} · {formatCost(item.totalCostUsd)}
|
|
274
|
+
{item.isError ? item.subtype : 'done'} · {formatDuration(item.durationMs)} · {formatCost(item.costUsd ?? item.totalCostUsd)}
|
|
274
275
|
</Row>
|
|
275
276
|
{item.errors?.map((message, index) => (
|
|
276
277
|
<Row key={index} tone="red">
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { memo, type ReactNode } from 'react'
|
|
2
2
|
import { Streamdown, type Components } from 'streamdown'
|
|
3
3
|
import { cn } from '../../lib/utils.ts'
|
|
4
|
+
import { parseFileLink } from '../../lib/file-link.ts'
|
|
4
5
|
import { CopyAction, WithActions } from './affordances.tsx'
|
|
6
|
+
import { useFileLinks } from './file-link.tsx'
|
|
5
7
|
import { Band } from './row.tsx'
|
|
6
8
|
|
|
7
9
|
function codeText(node: ReactNode): string {
|
|
@@ -45,6 +47,34 @@ function heading(tone: 'bright' | 'fg'): Components['h1'] {
|
|
|
45
47
|
}
|
|
46
48
|
}
|
|
47
49
|
|
|
50
|
+
// An agent writes a file as a markdown link far more often than it writes a URL, and the link text is
|
|
51
|
+
// its name, not its path: an anchor to a scheme-less href resolves against the host page and leads nowhere.
|
|
52
|
+
function Link({ href, children }: { href?: string; children: ReactNode }) {
|
|
53
|
+
const links = useFileLinks()
|
|
54
|
+
const file = links ? parseFileLink(href, links.cwd) : undefined
|
|
55
|
+
if (file) {
|
|
56
|
+
return (
|
|
57
|
+
<button
|
|
58
|
+
type="button"
|
|
59
|
+
className="term-link"
|
|
60
|
+
data-tone="blue"
|
|
61
|
+
title={file.line === undefined ? file.path : `${file.path}:${file.line}`}
|
|
62
|
+
onClick={(event) => {
|
|
63
|
+
event.stopPropagation()
|
|
64
|
+
links?.open(file.path, file.line)
|
|
65
|
+
}}
|
|
66
|
+
>
|
|
67
|
+
{children}
|
|
68
|
+
</button>
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
return (
|
|
72
|
+
<a className="term-link" data-tone="blue" href={href} target="_blank" rel="noreferrer">
|
|
73
|
+
{children}
|
|
74
|
+
</a>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
48
78
|
const TERMINAL_COMPONENTS: Components = {
|
|
49
79
|
p: ({ children }) => <div className="term-block">{children}</div>,
|
|
50
80
|
|
|
@@ -85,11 +115,7 @@ const TERMINAL_COMPONENTS: Components = {
|
|
|
85
115
|
</strong>
|
|
86
116
|
),
|
|
87
117
|
em: ({ children }) => <em className="term-em">{children}</em>,
|
|
88
|
-
a: ({ children, href }) =>
|
|
89
|
-
<a className="term-link" data-tone="blue" href={href} target="_blank" rel="noreferrer">
|
|
90
|
-
{children}
|
|
91
|
-
</a>
|
|
92
|
-
),
|
|
118
|
+
a: ({ children, href }) => <Link href={href}>{children}</Link>,
|
|
93
119
|
|
|
94
120
|
table: ({ children }) => (
|
|
95
121
|
<div className="term-block term-table-wrap">
|
package/src/index.ts
CHANGED
|
@@ -61,6 +61,10 @@ export { TerminalDiff, previewPatch } from './components/terminal/diff.tsx'
|
|
|
61
61
|
export { TerminalMarkdown, type TerminalMarkdownProps } from './components/terminal/markdown.tsx'
|
|
62
62
|
export { Band, Blank, Ink, Row, type RowProps, type Tone } from './components/terminal/row.tsx'
|
|
63
63
|
export { CopyAction, WithActions, useAffordances, type TerminalAffordances } from './components/terminal/affordances.tsx'
|
|
64
|
+
export { FileLinkProvider, useFileLinks, type FileLinkHandle, type FileLinkOpener } from './components/terminal/file-link.tsx'
|
|
65
|
+
export { parseFileLink, type FileLink } from './lib/file-link.ts'
|
|
66
|
+
export { matchPath, type PathHit, type PathMatch } from './lib/path-match.ts'
|
|
67
|
+
export { usePathLinks, resolveAgainstCwd } from './components/agent/use-path-links.ts'
|
|
64
68
|
export { Conversation, ConversationContent, ConversationScrollButton, type ConversationProps } from './components/agent/Conversation.tsx'
|
|
65
69
|
export { Message, MessageContent, type MessageProps } from './components/agent/Message.tsx'
|
|
66
70
|
export {
|
|
@@ -86,7 +90,15 @@ export {
|
|
|
86
90
|
type QuestionPromptProps,
|
|
87
91
|
type QuestionBehaviorMeta,
|
|
88
92
|
} from './components/agent/QuestionPrompt.tsx'
|
|
89
|
-
export { Composer,
|
|
93
|
+
export { Composer, type ComposerFileMatch, type ComposerHandle, type ComposerProps } from './components/agent/Composer.tsx'
|
|
94
|
+
export {
|
|
95
|
+
mergeComposerRows,
|
|
96
|
+
rankComposerRows,
|
|
97
|
+
matchClientCommand,
|
|
98
|
+
skillPrompt,
|
|
99
|
+
type ClientCommand,
|
|
100
|
+
type ComposerRow,
|
|
101
|
+
} from './components/agent/composer-commands.ts'
|
|
90
102
|
export { ModelSelect, type ModelSelectProps } from './components/agent/ModelSelect.tsx'
|
|
91
103
|
export {
|
|
92
104
|
PERMISSION_MODES,
|
|
@@ -109,7 +121,7 @@ export { SkillsDialog, type SkillsDialogProps } from './components/agent/SkillsD
|
|
|
109
121
|
export { HostFilesDialog, type HostFilesDialogProps } from './components/agent/HostFilesDialog.tsx'
|
|
110
122
|
export { SessionList, SessionListItem, type SessionListItemProps, type SessionListProps } from './components/agent/SessionList.tsx'
|
|
111
123
|
export { SessionBrowser, rowShapeClass, type SessionBrowserProps } from './components/agent/SessionBrowser.tsx'
|
|
112
|
-
export { SessionItem, type SessionItemProps } from './components/agent/SessionItem.tsx'
|
|
124
|
+
export { SessionItem, type SelectModifiers, type SessionItemProps } from './components/agent/SessionItem.tsx'
|
|
113
125
|
export { SessionStatusIcon } from './components/agent/SessionStatusIcon.tsx'
|
|
114
126
|
export { EngineIcon, engineMark, vendorMarkClass, vendorTextClass } from './components/agent/EngineIcon.tsx'
|
|
115
127
|
export { type Step, StepRow, StepToggle, runningSteps, sessionSteps } from './components/agent/SessionSteps.tsx'
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
export type FileLink = {
|
|
2
|
+
path: string
|
|
3
|
+
line?: number
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const SCHEME = /^[a-zA-Z][a-zA-Z0-9+.-]*:/
|
|
7
|
+
const TRAILING_LINE = /^(.*?):(\d+)(?::\d+)?$/
|
|
8
|
+
const FRAGMENT_LINE = /^L(\d+)/
|
|
9
|
+
|
|
10
|
+
// What an agent writes as a markdown link target when it means a file on the host: `./SPEC.md`,
|
|
11
|
+
// `docs/AUTH.md#anchor`, `/Users/me/project/src/main.ts:42`, `file:///tmp/out.txt`. Anything with a
|
|
12
|
+
// real scheme, and anything relative that arrives before the session's cwd is known, stays a web link.
|
|
13
|
+
export function parseFileLink(href: string | undefined, cwd: string | undefined): FileLink | undefined {
|
|
14
|
+
const raw = href?.trim()
|
|
15
|
+
if (!raw || raw.startsWith('#') || raw.startsWith('//')) {
|
|
16
|
+
return undefined
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let target = raw
|
|
20
|
+
if (SCHEME.test(target)) {
|
|
21
|
+
if (!target.startsWith('file:')) {
|
|
22
|
+
return undefined
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
target = decodeURIComponent(new URL(target).pathname)
|
|
26
|
+
} catch {
|
|
27
|
+
return undefined
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let line: number | undefined
|
|
32
|
+
const hash = target.indexOf('#')
|
|
33
|
+
if (hash !== -1) {
|
|
34
|
+
line = readLine(FRAGMENT_LINE.exec(target.slice(hash + 1))?.[1])
|
|
35
|
+
target = target.slice(0, hash)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const suffix = TRAILING_LINE.exec(target)
|
|
39
|
+
if (suffix) {
|
|
40
|
+
target = suffix[1]!
|
|
41
|
+
line ??= readLine(suffix[2])
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (!target) {
|
|
45
|
+
return undefined
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const path = target.startsWith('/') ? normalize(target) : cwd ? normalize(`${cwd}/${target}`) : undefined
|
|
49
|
+
return path ? { path, ...(line === undefined ? {} : { line }) } : undefined
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function readLine(value: string | undefined): number | undefined {
|
|
53
|
+
const parsed = value === undefined ? Number.NaN : Number(value)
|
|
54
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function normalize(path: string): string {
|
|
58
|
+
const out: string[] = []
|
|
59
|
+
for (const part of path.split('/')) {
|
|
60
|
+
if (part === '' || part === '.') {
|
|
61
|
+
continue
|
|
62
|
+
}
|
|
63
|
+
if (part === '..') {
|
|
64
|
+
out.pop()
|
|
65
|
+
continue
|
|
66
|
+
}
|
|
67
|
+
out.push(part)
|
|
68
|
+
}
|
|
69
|
+
return `/${out.join('/')}`
|
|
70
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// One path segment: no whitespace, no separator, none of the characters that bracket a path in
|
|
2
|
+
// prose. `:` is out because it introduces `:line`.
|
|
3
|
+
const SEG = '[^\\s:\'"`()\\[\\]{}<>|]+'
|
|
4
|
+
|
|
5
|
+
// The first segment of a relative path additionally cannot start with `@`: in an `@file` mention
|
|
6
|
+
// (`@_docs/BACKLOG.md`) the `@` is the mention's sigil, not part of the name.
|
|
7
|
+
const HEAD = '[^\\s:\'"`()\\[\\]{}<>|@,]+'
|
|
8
|
+
|
|
9
|
+
// The match must start at a token boundary, never consumed into the path: unanchored,
|
|
10
|
+
// `@_docs/BACKLOG.md` matched the suffix `/BACKLOG.md` and opened at the filesystem root.
|
|
11
|
+
const PATH_PATTERN = new RegExp(`(?:^|[\\s'"\`([{<@,])((?:\\.\\.?)?(?:/${SEG})+|${HEAD}(?:/${SEG})+)(?::(\\d+))?`)
|
|
12
|
+
|
|
13
|
+
const FILE_PATTERN = /^([\w.@-]+\.[A-Za-z0-9]{1,10})(?::(\d+))?$/
|
|
14
|
+
|
|
15
|
+
const TRAILING = /[.,;:!?)\]}'"`]+$/
|
|
16
|
+
|
|
17
|
+
const FILENAME = /\.[A-Za-z0-9]{1,10}$/
|
|
18
|
+
|
|
19
|
+
export type PathHit = { path: string; line?: number }
|
|
20
|
+
|
|
21
|
+
export type PathMatch = PathHit & {
|
|
22
|
+
// How much of the inspected text the path accounted for, the "mostly a path" test the hover makes.
|
|
23
|
+
length: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function matchPath(text: string | null | undefined, inCode = false): PathMatch | undefined {
|
|
27
|
+
const source = text ?? ''
|
|
28
|
+
const match = PATH_PATTERN.exec(source)
|
|
29
|
+
if (!match) {
|
|
30
|
+
return inCode ? matchFilename(source) : undefined
|
|
31
|
+
}
|
|
32
|
+
const path = match[1]!.replace(TRAILING, '')
|
|
33
|
+
if (!path.includes('/') || path.endsWith('/')) {
|
|
34
|
+
return undefined
|
|
35
|
+
}
|
|
36
|
+
const rooted = path.startsWith('/') || path.startsWith('./') || path.startsWith('../')
|
|
37
|
+
if (!rooted && !FILENAME.test(path)) {
|
|
38
|
+
return undefined
|
|
39
|
+
}
|
|
40
|
+
const line = match[2] ? Number(match[2]) : undefined
|
|
41
|
+
return { path, line, length: path.length + (match[2] ? match[2].length + 1 : 0) }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function matchFilename(text: string): PathMatch | undefined {
|
|
45
|
+
const hit = FILE_PATTERN.exec(text.trim())
|
|
46
|
+
if (!hit) {
|
|
47
|
+
return undefined
|
|
48
|
+
}
|
|
49
|
+
const line = hit[2] ? Number(hit[2]) : undefined
|
|
50
|
+
return { path: hit[1]!, line, length: hit[0].length }
|
|
51
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ByModel,
|
|
3
|
+
type CostBreakdown,
|
|
4
|
+
type TokenUsage,
|
|
5
|
+
UNPRICED_WARN_SHARE,
|
|
6
|
+
canonicalModel,
|
|
7
|
+
costOf,
|
|
8
|
+
costOfByModel,
|
|
9
|
+
rateFor,
|
|
10
|
+
totalTokens,
|
|
11
|
+
} from '@workerdeck/protocol'
|
|
12
|
+
|
|
13
|
+
export type SessionCostRow = {
|
|
14
|
+
model: string
|
|
15
|
+
usage: TokenUsage
|
|
16
|
+
tokens: number
|
|
17
|
+
costUsd: number | undefined
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type SessionCost = {
|
|
21
|
+
displayUsd: number | undefined
|
|
22
|
+
computedUsd: number | undefined
|
|
23
|
+
reportedUsd: number | undefined
|
|
24
|
+
breakdown: CostBreakdown
|
|
25
|
+
rows: SessionCostRow[]
|
|
26
|
+
unpricedShare: number
|
|
27
|
+
showUnpricedWarning: boolean
|
|
28
|
+
// Both figures price the same tokens, one by the engine and one by us, so a gap is the thing worth reading.
|
|
29
|
+
gapUsd: number | undefined
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type SessionCostInput = {
|
|
33
|
+
costUsd?: number
|
|
34
|
+
totalCostUsd?: number
|
|
35
|
+
usageByModel?: ByModel
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const GAP_SHARE = 0.02
|
|
39
|
+
|
|
40
|
+
export function sessionCost(input: SessionCostInput): SessionCost {
|
|
41
|
+
const byModel = input.usageByModel ?? {}
|
|
42
|
+
const breakdown = costOfByModel(byModel)
|
|
43
|
+
const computedUsd = input.costUsd ?? (breakdown.unpriced || Object.keys(byModel).length === 0 ? undefined : breakdown.total)
|
|
44
|
+
const reportedUsd = input.totalCostUsd
|
|
45
|
+
const rows = Object.entries(byModel)
|
|
46
|
+
.map(([model, usage]) => ({
|
|
47
|
+
model: canonicalModel(model),
|
|
48
|
+
usage,
|
|
49
|
+
tokens: totalTokens(usage),
|
|
50
|
+
costUsd: rateFor(model) ? costOf(usage, model).total : undefined,
|
|
51
|
+
}))
|
|
52
|
+
.sort((a, b) => b.tokens - a.tokens)
|
|
53
|
+
const gap = computedUsd !== undefined && reportedUsd !== undefined ? reportedUsd - computedUsd : undefined
|
|
54
|
+
const worthShowing = gap !== undefined && Math.abs(gap) > Math.max(0.01, GAP_SHARE * Math.max(computedUsd ?? 0, reportedUsd ?? 0))
|
|
55
|
+
return {
|
|
56
|
+
displayUsd: computedUsd ?? reportedUsd,
|
|
57
|
+
computedUsd,
|
|
58
|
+
reportedUsd,
|
|
59
|
+
breakdown,
|
|
60
|
+
rows,
|
|
61
|
+
unpricedShare: breakdown.unpricedShare,
|
|
62
|
+
showUnpricedWarning: breakdown.unpricedShare > UNPRICED_WARN_SHARE,
|
|
63
|
+
gapUsd: worthShowing ? gap : undefined,
|
|
64
|
+
}
|
|
65
|
+
}
|
package/src/styles/terminal.css
CHANGED
|
@@ -416,6 +416,21 @@
|
|
|
416
416
|
text-underline-offset: 2px;
|
|
417
417
|
}
|
|
418
418
|
|
|
419
|
+
/* A link to a file opens through the host rather than the address bar, so it is a button.
|
|
420
|
+
* Outside `[data-terminal]` too: a cards transcript renders the same markdown. */
|
|
421
|
+
button.term-link {
|
|
422
|
+
appearance: none;
|
|
423
|
+
background: none;
|
|
424
|
+
border: 0;
|
|
425
|
+
padding: 0;
|
|
426
|
+
font: inherit;
|
|
427
|
+
color: inherit;
|
|
428
|
+
cursor: pointer;
|
|
429
|
+
text-align: inherit;
|
|
430
|
+
text-decoration: underline;
|
|
431
|
+
text-underline-offset: 2px;
|
|
432
|
+
}
|
|
433
|
+
|
|
419
434
|
/* A file path, while the modifier that would open it is held.
|
|
420
435
|
*
|
|
421
436
|
* Only then, and that is the point: a path *is* clickable but only under
|