@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 {
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
  }
@@ -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">
package/src/index.ts CHANGED
@@ -63,6 +63,8 @@ export { Band, Blank, Ink, Row, type RowProps, type Tone } from './components/te
63
63
  export { CopyAction, WithActions, useAffordances, type TerminalAffordances } from './components/terminal/affordances.tsx'
64
64
  export { FileLinkProvider, useFileLinks, type FileLinkHandle, type FileLinkOpener } from './components/terminal/file-link.tsx'
65
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'
66
68
  export { Conversation, ConversationContent, ConversationScrollButton, type ConversationProps } from './components/agent/Conversation.tsx'
67
69
  export { Message, MessageContent, type MessageProps } from './components/agent/Message.tsx'
68
70
  export {
@@ -119,7 +121,7 @@ export { SkillsDialog, type SkillsDialogProps } from './components/agent/SkillsD
119
121
  export { HostFilesDialog, type HostFilesDialogProps } from './components/agent/HostFilesDialog.tsx'
120
122
  export { SessionList, SessionListItem, type SessionListItemProps, type SessionListProps } from './components/agent/SessionList.tsx'
121
123
  export { SessionBrowser, rowShapeClass, type SessionBrowserProps } from './components/agent/SessionBrowser.tsx'
122
- export { SessionItem, type SessionItemProps } from './components/agent/SessionItem.tsx'
124
+ export { SessionItem, type SelectModifiers, type SessionItemProps } from './components/agent/SessionItem.tsx'
123
125
  export { SessionStatusIcon } from './components/agent/SessionStatusIcon.tsx'
124
126
  export { EngineIcon, engineMark, vendorMarkClass, vendorTextClass } from './components/agent/EngineIcon.tsx'
125
127
  export { type Step, StepRow, StepToggle, runningSteps, sessionSteps } from './components/agent/SessionSteps.tsx'
@@ -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
+ }