@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
@@ -11,13 +11,14 @@ import {
11
11
  } from 'react'
12
12
  import type { SkillInfo, SlashCommandInfo } from '@workerdeck/protocol'
13
13
  import type { StagedAttachment, UseAttachmentsResult } from '@workerdeck/react'
14
- import { ArrowUp, FileText, Paperclip, RotateCw, Sparkles, Square, TriangleAlert, X } from 'lucide-react'
14
+ import { ArrowUp, FileText, Paperclip, RotateCw, SlidersHorizontal, Sparkles, Square, TriangleAlert, X } from 'lucide-react'
15
15
  import { Button } from '../ui/Button.tsx'
16
16
  import { Spinner } from '../ui/Spinner.tsx'
17
17
  import { PromptArea } from '../prompt-area/prompt-area.tsx'
18
18
  import { usePromptAreaState } from '../prompt-area/use-prompt-area-state.ts'
19
19
  import { plainTextToSegments } from '../prompt-area/prompt-area-engine.ts'
20
20
  import { commandTrigger, launchTrigger, mentionTrigger } from '../prompt-area/trigger-presets.ts'
21
+ import { mergeComposerRows, rankComposerRows, skillTrailingText, type ClientCommand } from './composer-commands.ts'
21
22
  import { useTranscriptVariant } from './transcript-variant.tsx'
22
23
  import type { TerminalAffordances } from '../terminal/affordances.tsx'
23
24
  import { PROMPT_GLYPH } from '../terminal/items.tsx'
@@ -44,6 +45,9 @@ export interface ComposerProps {
44
45
  placeholder?: string
45
46
  commands?: SlashCommandInfo[]
46
47
  skills?: SkillInfo[]
48
+ // Session controls the host offers as `/` rows. Merged into the same ranked list as the engine's own
49
+ // commands, and suppressed name-for-name by an engine command so the real one always wins.
50
+ clientCommands?: ClientCommand[]
47
51
  onSearchFiles?: (query: string, options: { signal: AbortSignal }) => Promise<ComposerFileMatch[]>
48
52
  // Shell mode: `!` as the first character turns the composer into a host shell prompt. Omit to leave the
49
53
  // mode off entirely - the gateway only offers it to an operator on a session whose engine reaches a host cwd.
@@ -61,24 +65,6 @@ export interface ComposerProps {
61
65
  ref?: Ref<ComposerHandle>
62
66
  }
63
67
 
64
- function cleanName(name: string) {
65
- return name.replace(/\s*\(MCP\)$/i, '')
66
- }
67
-
68
- export function skillPrompt(skill: SkillInfo): string {
69
- const base = skill.defaultPrompt?.trim() || `$${skill.name}`
70
- return /\s$/.test(base) ? base : base + ' '
71
- }
72
-
73
- function matchScore(query: string, haystacks: string[]): number {
74
- const needle = query.toLowerCase()
75
- const lowered = haystacks.map((s) => s.toLowerCase())
76
- if (lowered.some((h) => h.startsWith(needle))) {
77
- return 2
78
- }
79
- return lowered.some((h) => h.includes(needle)) ? 1 : 0
80
- }
81
-
82
68
  export function Composer({
83
69
  onSend,
84
70
  onInterrupt,
@@ -87,6 +73,7 @@ export function Composer({
87
73
  placeholder = 'Message the agent…',
88
74
  commands,
89
75
  skills,
76
+ clientCommands,
90
77
  onSearchFiles,
91
78
  onShellCommand,
92
79
  attachments,
@@ -109,6 +96,7 @@ export function Composer({
109
96
  const fileInput = useRef<HTMLInputElement>(null)
110
97
  const [dragging, setDragging] = useState(false)
111
98
  const [shellMode, setShellMode] = useState(false)
99
+ const [helpOpen, setHelpOpen] = useState(false)
112
100
 
113
101
  useImperativeHandle(
114
102
  ref,
@@ -125,76 +113,46 @@ export function Composer({
125
113
 
126
114
  const triggers = useMemo(() => {
127
115
  const configured = []
128
- const usableSkills = (skills ?? []).filter((s) => s.enabled)
129
- if (commands && commands.length > 0) {
130
- const seen = new Set<string>()
131
- const unique = commands.flatMap((c) => {
132
- const name = cleanName(c.name)
133
- if (seen.has(name)) {
134
- return []
135
- }
136
- seen.add(name)
137
- return [{ ...c, name }]
138
- })
116
+ const rows = mergeComposerRows({ commands, clientCommands, skills })
117
+ if (rows.length > 0) {
118
+ // Keyed by kind as well as name: an engine command and a skill may share one, and a chip's `data`
119
+ // is serialized into the DOM, so the row itself never travels there.
120
+ const byTag = new Map(rows.map((row) => [`${row.kind}:${row.name}`, row]))
139
121
  configured.push(
140
122
  commandTrigger({
141
- onSearch: (query: string): TriggerSuggestion[] => {
142
- const scored: Array<{ score: number; suggestion: TriggerSuggestion }> = []
143
- for (const c of unique) {
144
- const score = matchScore(query, [c.name, ...(c.aliases ?? []), ...c.name.split(':')])
145
- if (score === 0) {
146
- continue
147
- }
148
- scored.push({
149
- score,
150
- suggestion: {
151
- value: c.name,
152
- label: `/${c.name}${c.argumentHint ? ` ${c.argumentHint}` : ''}`,
153
- description: c.description,
154
- },
155
- })
156
- }
157
- scored.sort((a, b) => b.score - a.score)
158
- return scored.map(({ suggestion }) => suggestion)
123
+ onSearch: (query: string): TriggerSuggestion[] =>
124
+ rankComposerRows(query, rows).map((row) =>
125
+ row.kind === 'skill'
126
+ ? {
127
+ value: row.name,
128
+ label: row.label,
129
+ description: row.description ? `Skill · ${row.description}` : 'Skill · inserts a message you can edit',
130
+ icon: <Sparkles className="size-3.5 text-fg-3" />,
131
+ data: `${row.kind}:${row.name}`,
132
+ }
133
+ : {
134
+ value: row.name,
135
+ label: `/${row.name}${row.argumentHint ? ` ${row.argumentHint}` : ''}`,
136
+ description: row.description,
137
+ icon: row.kind === 'client' ? <SlidersHorizontal className="size-3.5 text-fg-3" /> : undefined,
138
+ data: `${row.kind}:${row.name}`,
139
+ },
140
+ ),
141
+ insertAsText: (suggestion) => {
142
+ const row = byTag.get(String(suggestion.data))
143
+ return row?.kind === 'client' && row.command.requiresArgs ? `/${row.name} ` : undefined
144
+ },
145
+ // A skill row came off the `/` menu but is codex's `$name` token, so the chip serialises with
146
+ // that sigil and the default prompt's remainder lands after it as editable text.
147
+ chipOptions: (suggestion) => {
148
+ const row = byTag.get(String(suggestion.data))
149
+ return row?.kind === 'skill' ? { sigil: '$', trailingText: skillTrailingText(row.skill) } : undefined
159
150
  },
160
151
  onSelect: (suggestion) => suggestion.value,
161
152
  chipClassName: 'font-mono',
162
153
  }),
163
154
  )
164
155
  }
165
- if (usableSkills.length > 0) {
166
- configured.push(
167
- commandTrigger({
168
- char: '$',
169
- accessibilityLabel: 'skill',
170
- onSearch: (query: string): TriggerSuggestion[] => {
171
- const scored: Array<{ score: number; suggestion: TriggerSuggestion }> = []
172
- for (const skill of usableSkills) {
173
- const score = matchScore(query, [skill.name, ...skill.name.split(/[-:_]/)])
174
- if (score === 0) {
175
- continue
176
- }
177
- const summary = skill.shortDescription ?? skill.description
178
- scored.push({
179
- score,
180
- suggestion: {
181
- value: skill.name,
182
- label: skill.displayName ?? skill.name,
183
- description: summary ? `Skill · ${summary}` : 'Skill · inserts a message you can edit',
184
- icon: <Sparkles className="size-3.5 text-fg-3" />,
185
- },
186
- })
187
- }
188
- scored.sort((a, b) => b.score - a.score)
189
- return scored.map(({ suggestion }) => suggestion)
190
- },
191
- insertAsText: (suggestion) => {
192
- const skill = usableSkills.find((s) => s.name === suggestion.value)
193
- return skill ? skillPrompt(skill) : `$${suggestion.value} `
194
- },
195
- }),
196
- )
197
- }
198
156
  if (onSearchFiles) {
199
157
  configured.push(
200
158
  mentionTrigger({
@@ -224,7 +182,7 @@ export function Composer({
224
182
  )
225
183
  }
226
184
  return configured.length > 0 ? configured : undefined
227
- }, [commands, skills, onSearchFiles, onShellCommand])
185
+ }, [commands, skills, clientCommands, onSearchFiles, onShellCommand])
228
186
 
229
187
  const saveDraft = draft?.save
230
188
  useEffect(() => {
@@ -261,6 +219,13 @@ export function Composer({
261
219
  setShellMode(false)
262
220
  focus()
263
221
  }
222
+
223
+ const hints = [
224
+ triggers?.some((t) => t.char === '/') ? { key: '/', what: 'commands and skills' } : undefined,
225
+ onSearchFiles ? { key: '@', what: 'mention a file' } : undefined,
226
+ onShellCommand ? { key: '!', what: 'run a shell command' } : undefined,
227
+ { key: '?', what: 'this list, on an empty composer' },
228
+ ].filter((h) => h !== undefined)
264
229
  // Escape and backspace-on-empty both leave, because both are what a person reaches for when the
265
230
  // pink frame was not what they meant. Backspace only when there is nothing left to delete.
266
231
  const shellKeys = shellMode
@@ -273,7 +238,40 @@ export function Composer({
273
238
  }
274
239
  },
275
240
  }
276
- : {}
241
+ : {
242
+ onEscape: helpOpen ? () => setHelpOpen(false) : undefined,
243
+ // Only on a genuinely empty composer, so a message that opens with a question mark still types.
244
+ onKeyDown: (e: KeyboardEvent<HTMLDivElement>) => {
245
+ if (e.key === '?' && isEmpty && !e.metaKey && !e.ctrlKey) {
246
+ e.preventDefault()
247
+ setHelpOpen(true)
248
+ return
249
+ }
250
+ if (helpOpen) {
251
+ setHelpOpen(false)
252
+ }
253
+ },
254
+ }
255
+
256
+ const helpHints = helpOpen ? (
257
+ <>
258
+ {hints.map((hint) => (
259
+ <span key={hint.key} className="flex items-center gap-1">
260
+ <kbd className="font-mono text-text">{hint.key}</kbd>
261
+ {hint.what}
262
+ </span>
263
+ ))}
264
+ </>
265
+ ) : null
266
+ const helpRow = helpOpen ? (
267
+ <div
268
+ role="note"
269
+ aria-label="Composer shortcuts"
270
+ className="mx-auto mb-1 flex w-full max-w-[var(--wd-transcript-max-width)] flex-wrap items-center gap-x-3 gap-y-1 text-label text-fg-3"
271
+ >
272
+ {helpHints}
273
+ </div>
274
+ ) : null
277
275
 
278
276
  const pick = (files: FileList | null) => {
279
277
  if (files && files.length > 0) {
@@ -405,6 +403,12 @@ export function Composer({
405
403
  {submitButton}
406
404
  </div>
407
405
  </div>
406
+ {helpOpen ? (
407
+ <div role="note" aria-label="Composer shortcuts" className="term-row">
408
+ <span aria-hidden className="term-gutter" />
409
+ <div className="flex min-w-0 flex-wrap items-center gap-x-[2ch] text-fg-3">{helpHints}</div>
410
+ </div>
411
+ ) : null}
408
412
  {shellMode ? (
409
413
  <div className="term-row">
410
414
  <span aria-hidden className="term-gutter" />
@@ -425,6 +429,7 @@ export function Composer({
425
429
 
426
430
  return (
427
431
  <div data-slot="composer" className={cn('px-[var(--wd-composer-padding)] pb-[var(--wd-composer-padding)]', className)}>
432
+ {helpRow}
428
433
  <div
429
434
  {...dropHandlers}
430
435
  className={cn(
@@ -1,13 +1,21 @@
1
- import { Fragment } from 'react'
1
+ import { createContext, Fragment, useContext, type ReactNode } from 'react'
2
2
  import { scanPromptTokens } from '@workerdeck/react'
3
3
  import { cn } from '../../lib/utils.ts'
4
4
 
5
+ const SkillNamesContext = createContext<readonly string[] | undefined>(undefined)
6
+
7
+ // The skill names a `$name` in a sent message may resolve to; unset, nothing is a skill token.
8
+ export function SkillNamesProvider({ names, children }: { names: readonly string[] | undefined; children: ReactNode }) {
9
+ return <SkillNamesContext.Provider value={names}>{children}</SkillNamesContext.Provider>
10
+ }
11
+
5
12
  export function PromptTokenText({ text, className }: { text: string; className?: string }) {
6
- const tokens = scanPromptTokens(text)
13
+ const skills = useContext(SkillNamesContext)
14
+ const tokens = scanPromptTokens(text, { skills })
7
15
  if (tokens.length === 0) {
8
16
  return <span className={className}>{text}</span>
9
17
  }
10
- const parts: React.ReactNode[] = []
18
+ const parts: ReactNode[] = []
11
19
  let cursor = 0
12
20
  for (const [index, token] of tokens.entries()) {
13
21
  if (token.start > cursor) {
@@ -46,7 +46,7 @@ export function SessionInfoDialog({ state, client, sessionId, open, onOpenChange
46
46
  {session?.createdAt ? <DialogRow label="Started">{formatRelativeTime(session.createdAt)}</DialogRow> : null}
47
47
  {session?.numTurns !== undefined ? <DialogRow label="Turns">{session.numTurns}</DialogRow> : null}
48
48
  <DialogRow label="Cost" mono>
49
- {formatCost(state.totalCostUsd)}
49
+ {formatCost(state.costUsd ?? (state.totalCostUsd || undefined))}
50
50
  </DialogRow>
51
51
  </div>
52
52
  {state.capabilities.vfs ? <SessionFiles client={client} sessionId={sessionId} open={open} /> : null}
@@ -11,6 +11,8 @@ import type { Step } from './SessionSteps.tsx'
11
11
  import { cn } from '../../lib/utils.ts'
12
12
  import { formatCost, formatRelativeTime, friendlyModel } from '../../lib/format.ts'
13
13
 
14
+ export type SelectModifiers = { meta: boolean; ctrl: boolean; alt: boolean }
15
+
14
16
  export interface SessionItemProps {
15
17
  row: SessionRow
16
18
  active?: boolean
@@ -20,7 +22,7 @@ export interface SessionItemProps {
20
22
  projectIcons?: Record<string, string>
21
23
  expanded?: boolean
22
24
  onExpandedChange?: (expanded: boolean) => void
23
- onSelect?: () => void
25
+ onSelect?: (modifiers: SelectModifiers) => void
24
26
  onSelectSubagent?: (toolUseId: string) => void
25
27
  onRename?: (title: string) => void
26
28
  renameOn?: 'doubleClick' | 'external'
@@ -30,6 +32,8 @@ export interface SessionItemProps {
30
32
  className?: string
31
33
  }
32
34
 
35
+ const NO_MODIFIERS: SelectModifiers = { meta: false, ctrl: false, alt: false }
36
+
33
37
  export function SessionItem({
34
38
  row,
35
39
  active = false,
@@ -66,7 +70,7 @@ export function SessionItem({
66
70
  const project = showProject ? projectLabel(row) : projectSubpath(row)
67
71
  const projectIcon = showProject ? info.project?.icon : undefined
68
72
  const iconSrc = projectIcon?.type === 'image' ? projectIcons?.[projectIcon.hash] : undefined
69
- const cost = formatCost(info.totalCostUsd)
73
+ const cost = formatCost(info.costUsd ?? info.totalCostUsd)
70
74
  const extras = [
71
75
  showGateway ? row.hostName : undefined,
72
76
  info.profile ? `@${info.profile}` : undefined,
@@ -93,7 +97,7 @@ export function SessionItem({
93
97
  for (const extra of extras) {
94
98
  parts.push(<span key={extra}>{extra}</span>)
95
99
  }
96
- const steps = sessionSteps(info, (toolUseId) => (onSelectSubagent ? onSelectSubagent(toolUseId) : onSelect?.()))
100
+ const steps = sessionSteps(info, (toolUseId) => (onSelectSubagent ? onSelectSubagent(toolUseId) : onSelect?.(NO_MODIFIERS)))
97
101
  const holdsOpenAgent = steps.some((s) => s.key === activeStepKey)
98
102
 
99
103
  return (
@@ -106,7 +110,7 @@ export function SessionItem({
106
110
  if (e.detail > 1 || isEditing) {
107
111
  return
108
112
  }
109
- onSelect?.()
113
+ onSelect?.({ meta: e.metaKey, ctrl: e.ctrlKey, alt: e.altKey })
110
114
  }}
111
115
  onKeyDown={(e) => {
112
116
  if (e.target !== e.currentTarget) {
@@ -114,7 +118,7 @@ export function SessionItem({
114
118
  }
115
119
  if (e.key === 'Enter' || e.key === ' ') {
116
120
  e.preventDefault()
117
- onSelect?.()
121
+ onSelect?.({ meta: e.metaKey, ctrl: e.ctrlKey, alt: e.altKey })
118
122
  }
119
123
  }}
120
124
  className={cn(
@@ -162,7 +166,9 @@ export function SessionItem({
162
166
  className={cn(
163
167
  'flex h-4 min-w-6 shrink-0 items-center justify-center rounded-full px-2',
164
168
  'text-[0.75rem] leading-none tracking-[-0.005em] tabular-nums',
165
- row.state === 'working' || row.state === 'attention' ? 'bg-accent text-accent-fg' : 'bg-badge text-badge-fg',
169
+ // Grey while the turn is still producing, accent once it has stopped: the badge's colour
170
+ // answers "is this waiting for me", and a working session is not yet.
171
+ row.state === 'working' ? 'bg-badge text-badge-fg' : 'bg-accent text-accent-fg',
166
172
  )}
167
173
  >
168
174
  {row.unseen}
@@ -36,7 +36,7 @@ export function SessionListItem({ session, active, onSelect, onDelete, className
36
36
  <div className="mt-0.5 flex items-center gap-2 font-mono text-label text-fg-4">
37
37
  <span className="truncate">{session.cwd}</span>
38
38
  {session.profile ? <span className="shrink-0">@{session.profile}</span> : null}
39
- <span className="shrink-0">{formatCost(session.totalCostUsd)}</span>
39
+ <span className="shrink-0">{formatCost(session.costUsd ?? session.totalCostUsd)}</span>
40
40
  <span className="shrink-0">{formatRelativeTime(session.lastActivityAt ?? session.createdAt)}</span>
41
41
  </div>
42
42
  </button>