@workerdeck/ui 2.7.2 → 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.
Files changed (71) hide show
  1. package/README.md +30 -30
  2. package/build/{SessionPanel-B5FfiBxE.mjs → SessionPanel-BClL7zUS.mjs} +694 -413
  3. package/build/SessionPanel-BClL7zUS.mjs.map +1 -0
  4. package/build/{SessionPanel-_lQLhWOw.d.mts → SessionPanel-BVbau2eR.d.mts} +68 -4
  5. package/build/format.mjs +1 -1
  6. package/build/index.d.mts +22 -15
  7. package/build/index.mjs +6 -6
  8. package/build/index.mjs.map +1 -1
  9. package/build/scoped.css +26 -2
  10. package/build/{status-BO9wJloK.mjs → status-kmsBhuFG.mjs} +3 -3
  11. package/build/status-kmsBhuFG.mjs.map +1 -0
  12. package/build/workspace.d.mts +3 -2
  13. package/build/workspace.mjs +7 -8
  14. package/build/workspace.mjs.map +1 -1
  15. package/package.json +18 -18
  16. package/src/components/agent/Composer.tsx +86 -84
  17. package/src/components/agent/ContextDialog.tsx +1 -1
  18. package/src/components/agent/EntryIcon.tsx +1 -1
  19. package/src/components/agent/FileTree.tsx +1 -1
  20. package/src/components/agent/FileViewer.tsx +1 -1
  21. package/src/components/agent/HostFilesDialog.tsx +2 -2
  22. package/src/components/agent/PermissionModeSelect.tsx +2 -2
  23. package/src/components/agent/PermissionPrompt.tsx +2 -2
  24. package/src/components/agent/SessionBrowser.tsx +1 -1
  25. package/src/components/agent/SessionItem.tsx +4 -2
  26. package/src/components/agent/SessionPanel.tsx +306 -226
  27. package/src/components/agent/SessionWorkspace.tsx +3 -0
  28. package/src/components/agent/SkillsDialog.tsx +3 -3
  29. package/src/components/agent/StatusBar.tsx +1 -1
  30. package/src/components/agent/TaskList.tsx +2 -2
  31. package/src/components/agent/TranscriptRows.tsx +1 -1
  32. package/src/components/agent/UsageDialog.tsx +1 -1
  33. package/src/components/agent/UsageMeters.tsx +17 -4
  34. package/src/components/agent/composer-commands.ts +142 -0
  35. package/src/components/agent/use-path-links.ts +2 -7
  36. package/src/components/agent/use-subagent-frame.ts +2 -2
  37. package/src/components/agent/use-transcript-jumps.ts +2 -2
  38. package/src/components/prompt-area/clipboard-helpers.ts +2 -2
  39. package/src/components/prompt-area/cursor-helpers.ts +7 -7
  40. package/src/components/prompt-area/dom-helpers.ts +8 -8
  41. package/src/components/prompt-area/html-to-markdown.ts +2 -2
  42. package/src/components/prompt-area/prompt-area-engine.ts +4 -4
  43. package/src/components/prompt-area/prompt-area-list-ops.ts +10 -10
  44. package/src/components/prompt-area/prompt-area.tsx +2 -2
  45. package/src/components/prompt-area/trigger-presets.ts +6 -6
  46. package/src/components/prompt-area/types.ts +6 -6
  47. package/src/components/prompt-area/use-chip-editing.ts +8 -8
  48. package/src/components/prompt-area/use-markdown-mode.ts +3 -3
  49. package/src/components/prompt-area/use-prompt-area-events.ts +3 -3
  50. package/src/components/prompt-area/use-prompt-area-keydown.ts +3 -3
  51. package/src/components/prompt-area/use-prompt-area-state.ts +4 -4
  52. package/src/components/prompt-area/use-prompt-area.ts +5 -5
  53. package/src/components/prompt-area/use-trigger-search.ts +1 -1
  54. package/src/components/terminal/PermissionPrompt.tsx +4 -4
  55. package/src/components/terminal/affordances.tsx +1 -1
  56. package/src/components/terminal/file-link.tsx +20 -0
  57. package/src/components/terminal/height.ts +2 -2
  58. package/src/components/terminal/items.tsx +2 -2
  59. package/src/components/terminal/markdown.tsx +31 -5
  60. package/src/components/terminal/scrubber.tsx +1 -1
  61. package/src/components/terminal/todos.ts +1 -1
  62. package/src/components/ui/Splitter.tsx +1 -1
  63. package/src/index.ts +11 -1
  64. package/src/lib/context-note.ts +3 -3
  65. package/src/lib/file-link.ts +70 -0
  66. package/src/lib/format.ts +2 -2
  67. package/src/styles/scoped.entry.css +2 -2
  68. package/src/styles/terminal.css +94 -79
  69. package/src/styles/theme.css +39 -39
  70. package/build/SessionPanel-B5FfiBxE.mjs.map +0 -1
  71. package/build/status-BO9wJloK.mjs.map +0 -1
@@ -42,7 +42,7 @@ export function SkillsDialog({ skills, open, onOpenChange, onUse, className }: S
42
42
  ? skill.name !== (skill.displayName ?? skill.name)
43
43
  ? skill.name
44
44
  : undefined
45
- : 'Capabilities the agent can choose to use. Not commands — the model picks them from their descriptions.'
45
+ : 'Capabilities the agent can choose to use. Not commands - the model picks them from their descriptions.'
46
46
  }
47
47
  actions={
48
48
  skill ? (
@@ -67,7 +67,7 @@ export function SkillsDialog({ skills, open, onOpenChange, onUse, className }: S
67
67
 
68
68
  function SkillList({ skills, onSelect }: { skills: SkillInfo[] | undefined; onSelect: (name: string) => void }) {
69
69
  if (!skills) {
70
- return <p className="py-6 text-center text-body-sm text-fg-4">Skills are listed once the session connects — send a message first.</p>
70
+ return <p className="py-6 text-center text-body-sm text-fg-4">Skills are listed once the session connects - send a message first.</p>
71
71
  }
72
72
  if (skills.length === 0) {
73
73
  return <p className="py-6 text-center text-body-sm text-fg-4">This session found no skills.</p>
@@ -142,7 +142,7 @@ function SkillView({ skill, onUse, onUsed }: { skill: SkillInfo; onUse?: (skill:
142
142
  Use this skill
143
143
  </Button>
144
144
  <p className="mt-1.5 text-label text-fg-4">
145
- Writes an opening message into the composer for you to edit — it isn’t sent, and there is no command that runs a skill directly.
145
+ Writes an opening message into the composer for you to edit - it isn’t sent, and there is no command that runs a skill directly.
146
146
  </p>
147
147
  </div>
148
148
  ) : null}
@@ -85,7 +85,7 @@ function RateLimitMeter({ label, info, now }: { label: string; info: RateLimitIn
85
85
  <div className="flex min-w-36 flex-col gap-1 py-0.5">
86
86
  <div className="flex items-center justify-between gap-2">
87
87
  <span>{label} usage</span>
88
- <span className="font-mono text-fg-3">{pct !== undefined ? `${pct.toFixed(1)}%` : '—'}</span>
88
+ <span className="font-mono text-fg-3">{pct !== undefined ? `${pct.toFixed(1)}%` : '-'}</span>
89
89
  </div>
90
90
  {resetsAtMs !== undefined ? (
91
91
  <div className="flex items-center justify-between gap-2">
@@ -7,7 +7,7 @@ import { cn } from '../../lib/utils.ts'
7
7
  export interface TaskListProps {
8
8
  tasks: readonly SessionTask[]
9
9
  showCompleted: boolean
10
- // Absent where the toggle lives outside this list — VS Code puts it in the view's title bar.
10
+ // Absent where the toggle lives outside this list - VS Code puts it in the view's title bar.
11
11
  onShowCompletedChange?: (showCompleted: boolean) => void
12
12
  onSelectTask?: (task: SessionTask) => void
13
13
  className?: string
@@ -36,7 +36,7 @@ export function TaskList({ tasks, showCompleted, onShowCompletedChange, onSelect
36
36
  </div>
37
37
  {shown.length === 0 ? (
38
38
  <p className="py-6 text-center text-body-sm text-fg-4">
39
- {tasks.length === 0 ? 'No tasks yet — a checklist appears once the agent plans one.' : `${hidden} completed, all hidden.`}
39
+ {tasks.length === 0 ? 'No tasks yet - a checklist appears once the agent plans one.' : `${hidden} completed, all hidden.`}
40
40
  </p>
41
41
  ) : (
42
42
  <div className="flex flex-col">
@@ -91,7 +91,7 @@ function StickyPromptLane({
91
91
  }
92
92
 
93
93
  // The virtualized row lane and its scroll-ownership regime. Two things want to write
94
- // `scrollTop` — the follow spring and the virtualizer's size-change corrections — and they are
94
+ // `scrollTop` - the follow spring and the virtualizer's size-change corrections - and they are
95
95
  // split by regime here: pinned, corrections are suppressed outright; escaped, the virtualizer
96
96
  // corrects so the scrollback holds still (GOTCHAS "The transcript is virtualized").
97
97
  export function TranscriptRows({
@@ -44,7 +44,7 @@ export function UsageDialog({
44
44
  {rateLimits.length === 0 ? (
45
45
  <p className="py-6 text-center text-body-sm text-fg-4">
46
46
  {engine === 'claude'
47
- ? 'This session reports no plan windows — API-key sessions have none, and a subscription session reports them once a turn has run.'
47
+ ? 'This session reports no plan windows - API-key sessions have none, and a subscription session reports them once a turn has run.'
48
48
  : `Plan windows are a claude.ai subscription thing; this session runs on the ${engine} engine.`}
49
49
  </p>
50
50
  ) : (
@@ -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
  }
@@ -6,7 +6,7 @@ import { subagentItems, type ToolCallItem } from '../terminal/blocks.ts'
6
6
  // The sub-agent frame machine: which agent frame is on screen, how it is entered (a host
7
7
  // `openSubagent` request or a Task row in the transcript), how it is left (Escape, the strip's
8
8
  // Back, or the host withdrawing), and what the transcript reveals on the way out. The frame
9
- // round-trips through the host's URL — the anti-loop rules live in GOTCHAS ("The sub-agent
9
+ // round-trips through the host's URL - the anti-loop rules live in GOTCHAS ("The sub-agent
10
10
  // frame round-trips through the URL"); here they mean: entry keys on the nonce alone, and the
11
11
  // report is deduped through a ref, so an echo of our own report is inert on arrival.
12
12
  export function useSubagentFrame(options: {
@@ -51,7 +51,7 @@ export function useSubagentFrame(options: {
51
51
  // eslint-disable-next-line react-hooks/exhaustive-deps
52
52
  }, [openSubagentNonce])
53
53
 
54
- // A reveal targets the root transcript, so it closes whatever frame is open — without the
54
+ // A reveal targets the root transcript, so it closes whatever frame is open - without the
55
55
  // return reveal, which would fight the requested one.
56
56
  const revealNonce = reveal?.nonce
57
57
  useEffect(() => {
@@ -7,12 +7,12 @@ import { gapBefore, type TranscriptRow } from './transcript-rows.ts'
7
7
  const AIM_PASSES = 4
8
8
  const AIM_SETTLE_MS = 50
9
9
 
10
- // How long a send's re-pin outlasts the click — long enough to eat a trackpad's momentum tail.
10
+ // How long a send's re-pin outlasts the click - long enough to eat a trackpad's momentum tail.
11
11
  export const REPIN_HOLD_MS = 750
12
12
 
13
13
  type RepinTarget = Pick<ReturnType<typeof useStickToBottomContext>, 'scrollToBottom' | 'state'>
14
14
 
15
- // The send re-pin: a held, escape-proof pin, not one `scrollToBottom('instant')` — a trackpad's
15
+ // The send re-pin: a held, escape-proof pin, not one `scrollToBottom('instant')` - a trackpad's
16
16
  // trailing momentum tick reads as escape intent and aborts the one-shot before its first frame
17
17
  // (GOTCHAS "The send re-pin is a held pin"). Every line is load-bearing: clear the stale escape
18
18
  // flag, hold through the momentum tail, seed the `ignoreEscapes` record the library only
@@ -19,7 +19,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
19
19
  * corresponding node kind encountered during a depth-first walk.
20
20
  */
21
21
  type FragmentVisitor = {
22
- /** A text node — receives its text content (may be empty). */
22
+ /** A text node - receives its text content (may be empty). */
23
23
  onText: (text: string) => void
24
24
  /** A chip element (has `data-chip-trigger`). */
25
25
  onChip: (node: HTMLElement) => void
@@ -191,7 +191,7 @@ export function insertSegmentsAtCursor(currentSegments: Segment[], pastedSegment
191
191
  insertOnce()
192
192
  result.push(seg)
193
193
  } else {
194
- // Cursor falls inside this text segment — split it.
194
+ // Cursor falls inside this text segment - split it.
195
195
  const splitAt = cursorOffset - offset
196
196
  const before = seg.text.slice(0, splitAt)
197
197
  const after = seg.text.slice(splitAt)
@@ -3,12 +3,12 @@
3
3
  *
4
4
  * Invariants:
5
5
  * - All functions are synchronous. Never return a promise or cross a microtask
6
- * boundary after a DOM mutation — that can cause the browser to fire
6
+ * boundary after a DOM mutation - that can cause the browser to fire
7
7
  * `selectionchange` and reset the caret.
8
8
  * - Never cache a `Selection` or `Range` across calls. Ranges become detached
9
9
  * after DOM mutations. Each function calls `window.getSelection()` or
10
10
  * `getSelectionRange()` fresh.
11
- * - Chip nodes are treated atomically via `isChipElement` — we never descend
11
+ * - Chip nodes are treated atomically via `isChipElement` - we never descend
12
12
  * into a contentEditable=false subtree when mapping offsets.
13
13
  */
14
14
  import {
@@ -120,11 +120,11 @@ export function createRangeAtOffset(editor: HTMLElement, targetOffset: number):
120
120
  * Where the caret is on screen, in viewport coordinates.
121
121
  *
122
122
  * A collapsed range usually measures fine, but not when it sits *between* child
123
- * nodes of an element — which is exactly where a freshly inserted newline puts
123
+ * nodes of an element - which is exactly where a freshly inserted newline puts
124
124
  * it, because this editor's content is a flat run of text nodes and `<br>`s
125
125
  * directly under the editor element. There `getBoundingClientRect()` is all
126
126
  * zeros, and the obvious fallback (measure `startContainer`'s element) resolves
127
- * to the editor itself, whose rect is the viewport box — so it reports the
127
+ * to the editor itself, whose rect is the viewport box - so it reports the
128
128
  * caret as trivially visible and scrolls nothing.
129
129
  *
130
130
  * So measure the neighbour instead: the child the caret sits before, or failing
@@ -163,14 +163,14 @@ function caretRect(range: Range): DOMRect | null {
163
163
  * The editor is its own scroll container once it hits `maxHeight`
164
164
  * (`prompt-area.tsx` sets `overflowY: auto` there). Typing scrolls the caret
165
165
  * into view for free, because that is the browser's own behaviour for a native
166
- * edit — but every path that calls `preventDefault()` and rebuilds the DOM
166
+ * edit - but every path that calls `preventDefault()` and rebuilds the DOM
167
167
  * itself (Shift+Enter, list continuation, undo/redo, the bold/italic wrap,
168
168
  * paste) places the caret with a Range instead, and setting a selection does
169
169
  * not scroll anything. The symptom is a prompt that has grown past its cap and
170
170
  * stops following what you are writing: type and it scrolls, press Shift+Enter
171
171
  * and the new line appears below the fold.
172
172
  *
173
- * A no-op when the caret is already visible, so it is safe on every call — it
173
+ * A no-op when the caret is already visible, so it is safe on every call - it
174
174
  * corrects an off-screen caret rather than scrolling to one.
175
175
  */
176
176
  export function scrollCaretIntoView(editor: HTMLElement): void {
@@ -334,7 +334,7 @@ export function findDOMPosition(container: HTMLElement, targetOffset: number): {
334
334
  }
335
335
  remaining -= 1
336
336
  } else if (isHTMLElement(child)) {
337
- // Decoration element (markdown span, URL anchor) — recurse
337
+ // Decoration element (markdown span, URL anchor) - recurse
338
338
  const textLen = (child.textContent ?? '').length
339
339
  if (remaining <= textLen) {
340
340
  const result = findDOMPosition(child, remaining)
@@ -73,7 +73,7 @@ export function isLinkElement(node: Node): node is HTMLAnchorElement {
73
73
  export function safeJsonParse(json: string): unknown {
74
74
  try {
75
75
  // JSON.parse returns `any` by default. We narrow it to `unknown`
76
- // which is the safest pattern — callers must validate before use.
76
+ // which is the safest pattern - callers must validate before use.
77
77
  const parsed: unknown = JSON.parse(json)
78
78
  return parsed
79
79
  } catch {
@@ -208,11 +208,11 @@ export function indexOfChildNode(parent: HTMLElement, child: Node): number {
208
208
  * `readSegmentsFromDOM` in use-prompt-area.ts. This is the single predicate
209
209
  * shared with `domChildIndexToSegmentIndex` below, so a DOM child index can
210
210
  * never map to a different segment index than the one the reader actually
211
- * produces — decoration elements (the URL `<a>` from `decorateURLsInEditor`,
211
+ * produces - decoration elements (the URL `<a>` from `decorateURLsInEditor`,
212
212
  * the markdown `<span data-md>` from `decorateMarkdownInEditor`) fall through
213
213
  * to the reader's "unknown element" branch and DO produce a text segment, so
214
214
  * they must count here too, not just chips/text/`<br>`. A chip element only
215
- * counts if `chipNodeToSegment` would actually accept it — the reader skips a
215
+ * counts if `chipNodeToSegment` would actually accept it - the reader skips a
216
216
  * chip missing a required attribute (trigger/value/display), so this must too.
217
217
  */
218
218
  export function childProducesSegment(child: Node): boolean {
@@ -348,7 +348,7 @@ export function normalizeEditorDOM(editor: HTMLElement): boolean {
348
348
  * Walks direct-child text nodes in the editor and wraps URL text in
349
349
  * `<a>` elements for visual styling and clickability.
350
350
  *
351
- * This is a DOM-only decoration — it does NOT modify the segment model.
351
+ * This is a DOM-only decoration - it does NOT modify the segment model.
352
352
  * The `<a>` elements are stripped by `normalizeEditorDOM` on every input cycle,
353
353
  * so they are re-applied fresh each time.
354
354
  *
@@ -393,7 +393,7 @@ export function decorateURLsInEditor(editor: HTMLElement): boolean {
393
393
  continue
394
394
  }
395
395
 
396
- // Validate URLs upfront – only keep those with safe protocols (CWE-79)
396
+ // Validate URLs upfront - only keep those with safe protocols (CWE-79)
397
397
  const safeMatches: Array<{ url: string; href: string; index: number }> = []
398
398
  for (const { url, index } of matches) {
399
399
  try {
@@ -447,7 +447,7 @@ export function decorateURLsInEditor(editor: HTMLElement): boolean {
447
447
  * Walks direct-child text nodes in the editor and wraps markdown-formatted
448
448
  * text (`**bold**`, `*italic*`, `***bold-italic***`) in styled `<span>` elements.
449
449
  *
450
- * This is a DOM-only decoration — it does NOT modify the segment model.
450
+ * This is a DOM-only decoration - it does NOT modify the segment model.
451
451
  * The `<span>` elements are stripped by `normalizeEditorDOM` on every input cycle,
452
452
  * so they are re-applied fresh each time.
453
453
  *
@@ -529,7 +529,7 @@ export function decorateMarkdownInEditor(editor: HTMLElement): boolean {
529
529
  fragment.appendChild(document.createTextNode(text.slice(lastIndex, index)))
530
530
  }
531
531
 
532
- // Parent span — textContent still returns full match (e.g. "**world**")
532
+ // Parent span - textContent still returns full match (e.g. "**world**")
533
533
  const span = document.createElement('span')
534
534
  span.dataset.md = 'true'
535
535
 
@@ -642,7 +642,7 @@ export function decorateBulletsInEditor(editor: HTMLElement): boolean {
642
642
  * unchanged) and is stripped by {@link normalizeEditorDOM} each input cycle.
643
643
  * Must run BEFORE the node-splitting passes ({@link decorateURLsInEditor},
644
644
  * {@link decorateMarkdownInEditor}, {@link decorateBulletsInEditor}) so every
645
- * direct-child text node is still a whole line — otherwise a mid-line split
645
+ * direct-child text node is still a whole line - otherwise a mid-line split
646
646
  * fragment beginning with whitespace would let the `^` anchor false-match
647
647
  * non-line-leading whitespace.
648
648
  *
@@ -240,7 +240,7 @@ function serializeNode(node: Node, depth: number): string {
240
240
  return `\n\n${serializeList(node, depth)}\n\n`
241
241
  }
242
242
  case 'LI': {
243
- // A stray <li> outside a list wrapper — emit its content as a line.
243
+ // A stray <li> outside a list wrapper - emit its content as a line.
244
244
  return `${serializeChildren(node, depth).trim()}\n`
245
245
  }
246
246
  case 'PRE': {
@@ -284,7 +284,7 @@ function normalizeOutput(markdown: string): string {
284
284
  /**
285
285
  * Converts an HTML string to markdown source text. Returns '' for empty or
286
286
  * body-less input. Block markdown (headings, lists, quotes, fences, tables,
287
- * links) is emitted as literal markdown text — that is the editor's intended
287
+ * links) is emitted as literal markdown text - that is the editor's intended
288
288
  * display; only `*`/`**`/`***` and bare URLs get visually decorated inline.
289
289
  */
290
290
  export function htmlToMarkdown(html: string): string {
@@ -77,7 +77,7 @@ export function truncateSegmentsToLength(segments: Segment[], maxLength: number)
77
77
  * in the editor model: a space, newline, or tab.
78
78
  *
79
79
  * Trigger detection, paste auto-resolution, and position validation all rely
80
- * on the *same* notion of a boundary — keeping it here prevents the three
80
+ * on the *same* notion of a boundary - keeping it here prevents the three
81
81
  * call sites from silently drifting apart (e.g. one handling tabs and the
82
82
  * others not).
83
83
  */
@@ -284,7 +284,7 @@ export function resolveChip(
284
284
  * The sibling of {@link resolveChip}, for suggestions that are a typing aid
285
285
  * rather than a token: what lands in the document is ordinary editable text the
286
286
  * user is expected to finish and change, and it must not look or behave like a
287
- * resolved chip — no immutability, no trigger character, nothing for a consumer
287
+ * resolved chip - no immutability, no trigger character, nothing for a consumer
288
288
  * to parse back out. The caret is left at the end of the inserted text so typing
289
289
  * continues from there.
290
290
  */
@@ -303,7 +303,7 @@ export function resolveText(
303
303
  for (const seg of segments) {
304
304
  if (seg.type === 'chip') {
305
305
  const chipEnd = offset + `${seg.trigger}${seg.displayText}`.length
306
- // A trigger range can never overlap a chip — chips are atomic — so a chip
306
+ // A trigger range can never overlap a chip - chips are atomic - so a chip
307
307
  // is either wholly before or wholly after, and is kept either way.
308
308
  if (chipEnd <= triggerStart || offset >= triggerEnd) {
309
309
  newSegments.push(seg)
@@ -444,7 +444,7 @@ function splitTextByTriggerPatterns(text: string, triggerByChar: Map<string, Tri
444
444
  const query = text.slice(i + 1, end)
445
445
  if (query.length > 0) {
446
446
  // Treat both undefined and '' from onSelect as "no custom label"
447
- // and fall back to the query — an empty displayText would render
447
+ // and fall back to the query - an empty displayText would render
448
448
  // a blank chip.
449
449
  const displayText = trigger.onSelect?.({ value: query, label: query }) || query
450
450
  segments.push({
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * List auto-formatting logic for the PromptArea component.
3
- * Pure — no DOM dependencies, fully testable in Node.
3
+ * Pure - no DOM dependencies, fully testable in Node.
4
4
  */
5
5
  import type { Segment } from './types.ts'
6
6
  import { replaceTextRange, segmentsToPlainText } from './prompt-area-engine.ts'
@@ -26,7 +26,7 @@ export type ListContext = {
26
26
  }
27
27
 
28
28
  /**
29
- * Parsed shape of a single list line — the SINGLE source of truth for what
29
+ * Parsed shape of a single list line - the SINGLE source of truth for what
30
30
  * counts as a list line (both `getListContext` and the renumber engine derive
31
31
  * from this, so the bullet/number regexes live in exactly one place).
32
32
  *
@@ -127,7 +127,7 @@ export function autoFormatListPrefix(segments: Segment[], cursorPos: number): {
127
127
  }
128
128
 
129
129
  /**
130
- * Handles Enter key in a list line — continues the list or exits.
130
+ * Handles Enter key in a list line - continues the list or exits.
131
131
  */
132
132
  export function insertListContinuation(segments: Segment[], cursorPos: number): { segments: Segment[]; cursorOffset: number } | null {
133
133
  const plainText = segmentsToPlainText(segments)
@@ -186,8 +186,8 @@ function getPrevListLineLevel(text: string, lineStart: number): number | null {
186
186
  /**
187
187
  * Indents a list item by one level (adds 2 spaces before the prefix), capped at
188
188
  * one level deeper than the line above. An item can only nest under a preceding
189
- * sibling, so the first item of a list — or an item already one level below its
190
- * parent — cannot indent further (returns null). This keeps sub-items visually
189
+ * sibling, so the first item of a list - or an item already one level below its
190
+ * parent - cannot indent further (returns null). This keeps sub-items visually
191
191
  * connected to a parent instead of drifting arbitrarily deep.
192
192
  */
193
193
  export function indentListItem(segments: Segment[], cursorPos: number): { segments: Segment[]; cursorOffset: number } | null {
@@ -259,8 +259,8 @@ function swapListPrefixLine(line: string, markdownEnabled: boolean): string {
259
259
  /**
260
260
  * Returns the set of line indices that sit inside a *balanced* fenced code
261
261
  * block (a ```…``` pair), so their leading "- "/"• " markers are preserved
262
- * verbatim. An unterminated (unpaired) fence marker is NOT protective — its
263
- * following lines still normalize — so a stray "```" in prose does not silently
262
+ * verbatim. An unterminated (unpaired) fence marker is NOT protective - its
263
+ * following lines still normalize - so a stray "```" in prose does not silently
264
264
  * suppress bullet normalization for the rest of the text.
265
265
  */
266
266
  function fenceProtectedLineIndices(lines: string[]): Set<number> {
@@ -351,12 +351,12 @@ export function normalizeListPrefixes(segments: Segment[], markdownEnabled: bool
351
351
  export type NumberEdit = { oldStart: number; oldEnd: number; newText: string }
352
352
 
353
353
  /**
354
- * Whether the text holds a genuine ordered-list run worth renumbering — a run
354
+ * Whether the text holds a genuine ordered-list run worth renumbering - a run
355
355
  * of 2+ consecutive same-level numbered lines that either starts at 1 or is
356
356
  * already a contiguous `n, n+1, …` sequence. Used to gate the paste path so a
357
357
  * copied list fragment (`3. 4. 5.` → renumber, or a broken `1. 1. 1.`) is
358
358
  * rebuilt, while incidental numeric-leading prose that `parseListLine` would
359
- * otherwise treat as a list — `1985. Born / 2020. Died`, `5. / 10. / 15.` — is
359
+ * otherwise treat as a list - `1985. Born / 2020. Died`, `5. / 10. / 15.` - is
360
360
  * left untouched.
361
361
  */
362
362
  export function hasOrderedListRun(text: string): boolean {
@@ -395,7 +395,7 @@ export function hasOrderedListRun(text: string): boolean {
395
395
  * Recomputes ordered-list numbering across the whole text. Returns the new text
396
396
  * plus the list of changed digit runs (ascending by `oldStart`) for cursor
397
397
  * remapping. When nothing changes, returns the SAME text reference and an empty
398
- * `edits` array — the no-op guard that keeps this off the typing hot path.
398
+ * `edits` array - the no-op guard that keeps this off the typing hot path.
399
399
  */
400
400
  export function renumberOrderedListLines(text: string): { text: string; edits: NumberEdit[] } {
401
401
  // Cheap pre-gate: with no ordered-list line there is nothing to renumber, so
@@ -277,7 +277,7 @@ export function PromptArea({
277
277
  ) : null
278
278
 
279
279
  // Typography (font-size/line-height) lives on the container, not the editor, so
280
- // it cascades to the editor AND the placeholder overlays — and a consumer can
280
+ // it cascades to the editor AND the placeholder overlays - and a consumer can
281
281
  // override all three at once via `className` (e.g. `text-base leading-6`).
282
282
  return (
283
283
  <div className={cn('prompt-area-container relative text-sm leading-relaxed', className)}>
@@ -316,7 +316,7 @@ export function PromptArea({
316
316
  onBlur={handleBlurCombined}
317
317
  />
318
318
 
319
- {/* Overflow gradient indicator – visible when auto-grow is collapsed and content is clipped */}
319
+ {/* Overflow gradient indicator - visible when auto-grow is collapsed and content is clipped */}
320
320
  {autoGrow && hasOverflow && !isFocused && (
321
321
  <div
322
322
  aria-hidden="true"