@workerdeck/ui 2.8.0 → 2.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/build/{SessionPanel-_lQLhWOw.d.mts → SessionPanel-BVbau2eR.d.mts} +68 -4
  2. package/build/index.d.mts +62 -9
  3. package/build/index.mjs +24 -10
  4. package/build/index.mjs.map +1 -1
  5. package/build/scoped.css +44 -0
  6. package/build/{SessionPanel-Dg4xf-ql.mjs → use-path-links-CvcxdUXL.mjs} +1080 -497
  7. package/build/use-path-links-CvcxdUXL.mjs.map +1 -0
  8. package/build/workspace.d.mts +3 -2
  9. package/build/workspace.mjs +5 -86
  10. package/build/workspace.mjs.map +1 -1
  11. package/package.json +4 -4
  12. package/src/components/agent/Composer.tsx +88 -83
  13. package/src/components/agent/PromptTokenText.tsx +11 -3
  14. package/src/components/agent/SessionInfoDialog.tsx +1 -1
  15. package/src/components/agent/SessionItem.tsx +12 -6
  16. package/src/components/agent/SessionList.tsx +1 -1
  17. package/src/components/agent/SessionPanel.tsx +308 -225
  18. package/src/components/agent/SessionWorkspace.tsx +3 -0
  19. package/src/components/agent/StatusBar.tsx +1 -1
  20. package/src/components/agent/Transcript.tsx +83 -79
  21. package/src/components/agent/TranscriptItemView.tsx +1 -1
  22. package/src/components/agent/UsageDialog.tsx +87 -6
  23. package/src/components/agent/UsageMeters.tsx +17 -4
  24. package/src/components/agent/composer-commands.ts +154 -0
  25. package/src/components/agent/use-path-links.ts +7 -26
  26. package/src/components/prompt-area/clipboard-helpers.ts +13 -4
  27. package/src/components/prompt-area/dom-helpers.ts +10 -1
  28. package/src/components/prompt-area/index.ts +10 -1
  29. package/src/components/prompt-area/prompt-area-engine.ts +23 -14
  30. package/src/components/prompt-area/types.ts +20 -0
  31. package/src/components/prompt-area/use-prompt-area-keydown.ts +2 -1
  32. package/src/components/prompt-area/use-prompt-area.ts +8 -2
  33. package/src/components/terminal/StatusLine.tsx +3 -2
  34. package/src/components/terminal/file-link.tsx +20 -0
  35. package/src/components/terminal/items.tsx +3 -2
  36. package/src/components/terminal/markdown.tsx +31 -5
  37. package/src/index.ts +14 -2
  38. package/src/lib/file-link.ts +70 -0
  39. package/src/lib/path-match.ts +51 -0
  40. package/src/lib/session-cost.ts +65 -0
  41. package/src/styles/terminal.css +15 -0
  42. package/build/SessionPanel-Dg4xf-ql.mjs.map +0 -1
@@ -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
@@ -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.trigger.length + s.displayText.length
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 = `${seg.trigger}${seg.displayText}`
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
- if (state.totalCostUsd > 0) {
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(state.totalCostUsd)}
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, skillPrompt, type ComposerFileMatch, type ComposerHandle, type ComposerProps } from './components/agent/Composer.tsx'
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
+ }
@@ -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