@workerdeck/ui 2.8.0 → 2.9.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.
@@ -1,5 +1,5 @@
1
1
  import { useEffect, useState } from 'react'
2
- import type { UsageWindowRow } from '@workerdeck/protocol'
2
+ import { usageIsStale, type UsageWindowRow } from '@workerdeck/protocol'
3
3
  import { RotateCcw } from 'lucide-react'
4
4
  import { cn } from '../../lib/utils.ts'
5
5
  import { formatAgoPrecise, formatCountdown, formatRateLimitWindowLong, rateLimitWindowSeconds } from '../../lib/format.ts'
@@ -33,6 +33,9 @@ export function UsageMeters({ windows, now, className }: { windows: UsageWindowR
33
33
  function UsageMeter({ window, now }: { window: UsageWindowRow; now: number }) {
34
34
  const { key: windowKey, info, updatedAt, inferredReset } = window
35
35
  const utilization = info.utilization ?? 0
36
+ // Drawn differently rather than hidden: this is still the best answer anyone has, it is just not a claim about
37
+ // right now, and a stale reading asserted as current is how a number from two days ago passes for the truth.
38
+ const stale = usageIsStale(window, now)
36
39
  const resetsAtMs = info.resetsAt !== undefined ? info.resetsAt * 1000 : undefined
37
40
  const duration = rateLimitWindowSeconds(windowKey)
38
41
  const remaining = resetsAtMs !== undefined ? (resetsAtMs - now) / 1000 : undefined
@@ -44,13 +47,18 @@ function UsageMeter({ window, now }: { window: UsageWindowRow; now: number }) {
44
47
  <div>
45
48
  <div className="flex items-baseline justify-between gap-3">
46
49
  <span className="truncate text-body-sm text-fg-1">{formatRateLimitWindowLong(windowKey)}</span>
47
- <span className={cn('shrink-0 font-mono text-body-sm font-medium', info.status === 'rejected' ? 'text-danger' : 'text-fg-1')}>
50
+ <span
51
+ className={cn(
52
+ 'shrink-0 font-mono text-body-sm font-medium',
53
+ info.status === 'rejected' ? 'text-danger' : stale ? 'text-fg-4' : 'text-fg-1',
54
+ )}
55
+ >
48
56
  {utilization.toFixed(0)}% used
49
57
  </span>
50
58
  </div>
51
59
  <div className="relative mt-2 h-2 rounded-full bg-border">
52
60
  <div
53
- className={cn('h-full rounded-full', meterTintClass(utilization))}
61
+ className={cn('h-full rounded-full', meterTintClass(utilization), stale && 'opacity-40')}
54
62
  style={{ width: `${Math.min(100, Math.max(2, utilization))}%` }}
55
63
  />
56
64
  {pace !== undefined ? (
@@ -71,7 +79,12 @@ function UsageMeter({ window, now }: { window: UsageWindowRow; now: number }) {
71
79
  {info.isUsingOverage ? <span className="text-warning">overage</span> : null}
72
80
  {info.status === 'rejected' ? <span className="text-danger">limit reached</span> : null}
73
81
  {inferredReset ? <span>window reset · nothing reported since</span> : null}
74
- {updatedAt && !inferredReset ? <span>{formatAgoPrecise(updatedAt, now)}</span> : null}
82
+ {updatedAt && !inferredReset ? (
83
+ <span className={cn(stale && 'text-warning')}>
84
+ {stale ? 'last reported ' : ''}
85
+ {formatAgoPrecise(updatedAt, now)}
86
+ </span>
87
+ ) : null}
75
88
  </div>
76
89
  </div>
77
90
  )
@@ -0,0 +1,142 @@
1
+ import type { SkillInfo, SlashCommandInfo } from '@workerdeck/protocol'
2
+
3
+ export type ClientCommand = {
4
+ name: string
5
+ description: string
6
+ argumentHint?: string
7
+ // Selecting the row populates the composer with `/name ` instead of resolving to a chip, so the
8
+ // argument is typed before anything is submitted.
9
+ requiresArgs?: boolean
10
+ // True when the command consumed the input. False hands the text back to the engine, which is how
11
+ // `/model` with no argument still reaches a CLI that implements it natively.
12
+ run: (args: string) => boolean
13
+ }
14
+
15
+ export type ComposerRow =
16
+ | { kind: 'command'; name: string; description?: string; argumentHint?: string; aliases?: string[] }
17
+ | { kind: 'client'; name: string; description?: string; argumentHint?: string; command: ClientCommand }
18
+ | { kind: 'skill'; name: string; label: string; description?: string; enabled: boolean; scope?: string; skill: SkillInfo }
19
+
20
+ const KIND_ORDER: Record<ComposerRow['kind'], number> = { command: 0, client: 1, skill: 2 }
21
+
22
+ export function cleanCommandName(name: string): string {
23
+ return name.replace(/\s*\(MCP\)$/i, '')
24
+ }
25
+
26
+ 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 + ' '
29
+ }
30
+
31
+ export function mergeComposerRows(sources: {
32
+ commands?: readonly SlashCommandInfo[]
33
+ clientCommands?: readonly ClientCommand[]
34
+ skills?: readonly SkillInfo[]
35
+ }): ComposerRow[] {
36
+ const rows: ComposerRow[] = []
37
+ const engineNames = new Set<string>()
38
+ for (const command of sources.commands ?? []) {
39
+ const name = cleanCommandName(command.name)
40
+ if (engineNames.has(name)) {
41
+ continue
42
+ }
43
+ engineNames.add(name)
44
+ for (const alias of command.aliases ?? []) {
45
+ engineNames.add(cleanCommandName(alias))
46
+ }
47
+ rows.push({ kind: 'command', name, description: command.description, argumentHint: command.argumentHint, aliases: command.aliases })
48
+ }
49
+ for (const command of sources.clientCommands ?? []) {
50
+ if (engineNames.has(command.name)) {
51
+ continue
52
+ }
53
+ rows.push({ kind: 'client', name: command.name, description: command.description, argumentHint: command.argumentHint, command })
54
+ }
55
+ for (const skill of sources.skills ?? []) {
56
+ rows.push({
57
+ kind: 'skill',
58
+ name: skill.name,
59
+ label: skill.displayName ?? skill.name,
60
+ description: skill.shortDescription ?? skill.description,
61
+ enabled: skill.enabled,
62
+ scope: skill.scope,
63
+ skill,
64
+ })
65
+ }
66
+ return rows
67
+ }
68
+
69
+ function haystacks(row: ComposerRow): string[] {
70
+ if (row.kind === 'skill') {
71
+ return [row.name, row.label, ...row.name.split(/[-:_]/)]
72
+ }
73
+ return [row.name, ...(row.kind === 'command' ? (row.aliases ?? []) : []), ...row.name.split(':')]
74
+ }
75
+
76
+ export function matchScore(query: string, candidates: readonly string[]): number {
77
+ const needle = query.toLowerCase()
78
+ const lowered = candidates.map((s) => s.toLowerCase())
79
+ if (lowered.some((h) => h.startsWith(needle))) {
80
+ return 2
81
+ }
82
+ return lowered.some((h) => h.includes(needle)) ? 1 : 0
83
+ }
84
+
85
+ export function rankComposerRows(query: string, rows: readonly ComposerRow[]): ComposerRow[] {
86
+ const scored: Array<{ score: number; index: number; row: ComposerRow }> = []
87
+ rows.forEach((row, index) => {
88
+ if (row.kind === 'skill' && !row.enabled) {
89
+ return
90
+ }
91
+ const score = matchScore(query, haystacks(row))
92
+ if (score > 0) {
93
+ scored.push({ score, index, row })
94
+ }
95
+ })
96
+ scored.sort((a, b) => b.score - a.score || KIND_ORDER[a.row.kind] - KIND_ORDER[b.row.kind] || a.index - b.index)
97
+ return scored.map(({ row }) => row)
98
+ }
99
+
100
+ export function matchClientCommand(text: string, commands: readonly ClientCommand[]): { command: ClientCommand; args: string } | undefined {
101
+ const match = /^\/([\w:-]+)[ \t]*([\s\S]*)$/.exec(text.trim())
102
+ if (!match) {
103
+ return undefined
104
+ }
105
+ const command = commands.find((c) => c.name === match[1])
106
+ return command ? { command, args: match[2]!.trim() } : undefined
107
+ }
108
+
109
+ // The serializable projection a native host (the VS Code QuickPick) renders. `insertText` is resolved here
110
+ // so no second surface reimplements skillPrompt or the leading slash.
111
+ export type ComposerCommandRow = {
112
+ kind: ComposerRow['kind']
113
+ name: string
114
+ label: string
115
+ description?: string
116
+ scope?: string
117
+ enabled: boolean
118
+ insertText: string
119
+ }
120
+
121
+ export function composerCommandRows(rows: readonly ComposerRow[]): ComposerCommandRow[] {
122
+ return rows.map((row) =>
123
+ row.kind === 'skill'
124
+ ? {
125
+ kind: row.kind,
126
+ name: row.name,
127
+ label: row.label,
128
+ description: row.description,
129
+ scope: row.scope,
130
+ enabled: row.enabled,
131
+ insertText: skillPrompt(row.skill),
132
+ }
133
+ : {
134
+ kind: row.kind,
135
+ name: row.name,
136
+ label: `/${row.name}`,
137
+ description: row.description,
138
+ enabled: true,
139
+ insertText: `/${row.name} `,
140
+ },
141
+ )
142
+ }
@@ -1,4 +1,5 @@
1
1
  import { useEffect, type RefObject } from 'react'
2
+ import { parseFileLink } from '../../lib/file-link.ts'
2
3
 
3
4
  export type PathHit = { path: string; line?: number }
4
5
 
@@ -119,11 +120,5 @@ export function usePathLinks({
119
120
  }
120
121
 
121
122
  export function resolveAgainstCwd(path: string, cwd: string | undefined): string {
122
- if (path.startsWith('/')) {
123
- return path
124
- }
125
- if (!cwd) {
126
- return path
127
- }
128
- return `${cwd.replace(/\/$/, '')}/${path.replace(/^\.\//, '')}`
123
+ return parseFileLink(path, cwd)?.path ?? path
129
124
  }
@@ -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
+ }
@@ -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,8 @@ 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'
64
66
  export { Conversation, ConversationContent, ConversationScrollButton, type ConversationProps } from './components/agent/Conversation.tsx'
65
67
  export { Message, MessageContent, type MessageProps } from './components/agent/Message.tsx'
66
68
  export {
@@ -86,7 +88,15 @@ export {
86
88
  type QuestionPromptProps,
87
89
  type QuestionBehaviorMeta,
88
90
  } from './components/agent/QuestionPrompt.tsx'
89
- export { Composer, skillPrompt, type ComposerFileMatch, type ComposerHandle, type ComposerProps } from './components/agent/Composer.tsx'
91
+ export { Composer, type ComposerFileMatch, type ComposerHandle, type ComposerProps } from './components/agent/Composer.tsx'
92
+ export {
93
+ mergeComposerRows,
94
+ rankComposerRows,
95
+ matchClientCommand,
96
+ skillPrompt,
97
+ type ClientCommand,
98
+ type ComposerRow,
99
+ } from './components/agent/composer-commands.ts'
90
100
  export { ModelSelect, type ModelSelectProps } from './components/agent/ModelSelect.tsx'
91
101
  export {
92
102
  PERMISSION_MODES,
@@ -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
+ }
@@ -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