@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.
- package/README.md +30 -30
- package/build/{SessionPanel-B5FfiBxE.mjs → SessionPanel-BClL7zUS.mjs} +694 -413
- package/build/SessionPanel-BClL7zUS.mjs.map +1 -0
- package/build/{SessionPanel-_lQLhWOw.d.mts → SessionPanel-BVbau2eR.d.mts} +68 -4
- package/build/format.mjs +1 -1
- package/build/index.d.mts +22 -15
- package/build/index.mjs +6 -6
- package/build/index.mjs.map +1 -1
- package/build/scoped.css +26 -2
- package/build/{status-BO9wJloK.mjs → status-kmsBhuFG.mjs} +3 -3
- package/build/status-kmsBhuFG.mjs.map +1 -0
- package/build/workspace.d.mts +3 -2
- package/build/workspace.mjs +7 -8
- package/build/workspace.mjs.map +1 -1
- package/package.json +18 -18
- package/src/components/agent/Composer.tsx +86 -84
- package/src/components/agent/ContextDialog.tsx +1 -1
- package/src/components/agent/EntryIcon.tsx +1 -1
- package/src/components/agent/FileTree.tsx +1 -1
- package/src/components/agent/FileViewer.tsx +1 -1
- package/src/components/agent/HostFilesDialog.tsx +2 -2
- package/src/components/agent/PermissionModeSelect.tsx +2 -2
- package/src/components/agent/PermissionPrompt.tsx +2 -2
- package/src/components/agent/SessionBrowser.tsx +1 -1
- package/src/components/agent/SessionItem.tsx +4 -2
- package/src/components/agent/SessionPanel.tsx +306 -226
- package/src/components/agent/SessionWorkspace.tsx +3 -0
- package/src/components/agent/SkillsDialog.tsx +3 -3
- package/src/components/agent/StatusBar.tsx +1 -1
- package/src/components/agent/TaskList.tsx +2 -2
- package/src/components/agent/TranscriptRows.tsx +1 -1
- package/src/components/agent/UsageDialog.tsx +1 -1
- package/src/components/agent/UsageMeters.tsx +17 -4
- package/src/components/agent/composer-commands.ts +142 -0
- package/src/components/agent/use-path-links.ts +2 -7
- package/src/components/agent/use-subagent-frame.ts +2 -2
- package/src/components/agent/use-transcript-jumps.ts +2 -2
- package/src/components/prompt-area/clipboard-helpers.ts +2 -2
- package/src/components/prompt-area/cursor-helpers.ts +7 -7
- package/src/components/prompt-area/dom-helpers.ts +8 -8
- package/src/components/prompt-area/html-to-markdown.ts +2 -2
- package/src/components/prompt-area/prompt-area-engine.ts +4 -4
- package/src/components/prompt-area/prompt-area-list-ops.ts +10 -10
- package/src/components/prompt-area/prompt-area.tsx +2 -2
- package/src/components/prompt-area/trigger-presets.ts +6 -6
- package/src/components/prompt-area/types.ts +6 -6
- package/src/components/prompt-area/use-chip-editing.ts +8 -8
- package/src/components/prompt-area/use-markdown-mode.ts +3 -3
- package/src/components/prompt-area/use-prompt-area-events.ts +3 -3
- package/src/components/prompt-area/use-prompt-area-keydown.ts +3 -3
- package/src/components/prompt-area/use-prompt-area-state.ts +4 -4
- package/src/components/prompt-area/use-prompt-area.ts +5 -5
- package/src/components/prompt-area/use-trigger-search.ts +1 -1
- package/src/components/terminal/PermissionPrompt.tsx +4 -4
- package/src/components/terminal/affordances.tsx +1 -1
- package/src/components/terminal/file-link.tsx +20 -0
- package/src/components/terminal/height.ts +2 -2
- package/src/components/terminal/items.tsx +2 -2
- package/src/components/terminal/markdown.tsx +31 -5
- package/src/components/terminal/scrubber.tsx +1 -1
- package/src/components/terminal/todos.ts +1 -1
- package/src/components/ui/Splitter.tsx +1 -1
- package/src/index.ts +11 -1
- package/src/lib/context-note.ts +3 -3
- package/src/lib/file-link.ts +70 -0
- package/src/lib/format.ts +2 -2
- package/src/styles/scoped.entry.css +2 -2
- package/src/styles/terminal.css +94 -79
- package/src/styles/theme.css +39 -39
- package/build/SessionPanel-B5FfiBxE.mjs.map +0 -1
- 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
|
|
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
|
|
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
|
|
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)}%` : '
|
|
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
|
|
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
|
|
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`
|
|
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
|
|
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
|
|
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
|
|
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 ?
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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')`
|
|
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
|
|
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
|
|
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
|
|
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`
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
190
|
-
* parent
|
|
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
|
|
263
|
-
* following lines still normalize
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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"
|