@workerdeck/ui 2.7.1 → 2.8.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-Dg4xf-ql.mjs} +46 -46
- package/build/SessionPanel-Dg4xf-ql.mjs.map +1 -0
- package/build/format.mjs +1 -1
- package/build/index.d.mts +11 -11
- package/build/index.mjs +4 -4
- package/build/index.mjs.map +1 -1
- package/build/scoped.css +2 -2
- package/build/{status-BO9wJloK.mjs → status-kmsBhuFG.mjs} +3 -3
- package/build/status-kmsBhuFG.mjs.map +1 -0
- package/build/workspace.mjs +4 -4
- package/build/workspace.mjs.map +1 -1
- package/package.json +18 -18
- package/src/components/agent/Composer.tsx +2 -2
- 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 +1 -1
- package/src/components/agent/SessionPanel.tsx +2 -2
- 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/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/height.ts +2 -2
- package/src/components/terminal/items.tsx +2 -2
- 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/lib/context-note.ts +3 -3
- package/src/lib/format.ts +2 -2
- package/src/styles/scoped.entry.css +2 -2
- package/src/styles/terminal.css +79 -79
- package/src/styles/theme.css +39 -39
- package/build/SessionPanel-B5FfiBxE.mjs.map +0 -1
- package/build/status-BO9wJloK.mjs.map +0 -1
|
@@ -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
|
) : (
|
|
@@ -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"
|
|
@@ -18,12 +18,12 @@
|
|
|
18
18
|
|
|
19
19
|
import type { TriggerConfig, TriggerPosition } from './types.ts'
|
|
20
20
|
|
|
21
|
-
// Shared option type
|
|
21
|
+
// Shared option type - everything in TriggerConfig except the keys each
|
|
22
22
|
// factory sets by default.
|
|
23
23
|
|
|
24
24
|
type TriggerPresetOptions = Omit<Partial<TriggerConfig>, 'char' | 'position' | 'mode'>
|
|
25
25
|
|
|
26
|
-
// @mention
|
|
26
|
+
// @mention - dropdown at any position
|
|
27
27
|
|
|
28
28
|
export type MentionTriggerOptions = TriggerPresetOptions & {
|
|
29
29
|
/** Override the trigger character. Defaults to `'@'`. */
|
|
@@ -48,14 +48,14 @@ export function mentionTrigger(opts: MentionTriggerOptions = {}): TriggerConfig
|
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
// /command
|
|
51
|
+
// /command - dropdown anywhere (opt into line-start-only with `position`)
|
|
52
52
|
|
|
53
53
|
export type CommandTriggerOptions = TriggerPresetOptions & {
|
|
54
54
|
/** Override the trigger character. Defaults to `'/'`. */
|
|
55
55
|
char?: string
|
|
56
56
|
/**
|
|
57
57
|
* Where the command trigger is valid. Defaults to `'any'`, so commands fire
|
|
58
|
-
* anywhere a `/` follows whitespace
|
|
58
|
+
* anywhere a `/` follows whitespace - not just at the start of a line.
|
|
59
59
|
* Set to `'start'` to restrict the dropdown to the very start of the input
|
|
60
60
|
* or immediately after a newline (the classic slash-command behavior).
|
|
61
61
|
*/
|
|
@@ -83,7 +83,7 @@ export function commandTrigger(opts: CommandTriggerOptions = {}): TriggerConfig
|
|
|
83
83
|
}
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
-
// #hashtag
|
|
86
|
+
// #hashtag - dropdown at any position, auto-resolve on space
|
|
87
87
|
|
|
88
88
|
export type HashtagTriggerOptions = TriggerPresetOptions & {
|
|
89
89
|
/** Override the trigger character. Defaults to `'#'`. */
|
|
@@ -132,7 +132,7 @@ export function callbackTrigger(opts: CallbackTriggerOptions): TriggerConfig {
|
|
|
132
132
|
}
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
-
// Launch trigger
|
|
135
|
+
// Launch trigger - fires onActivate and swallows the character
|
|
136
136
|
|
|
137
137
|
export type LaunchTriggerOptions = Omit<Partial<TriggerConfig>, 'mode'> & {
|
|
138
138
|
/** The trigger character. Required. */
|
|
@@ -46,7 +46,7 @@ export type TriggerPosition = 'start' | 'any'
|
|
|
46
46
|
* - 'dropdown': Shows a popover with suggestions from `onSearch`
|
|
47
47
|
* - 'callback': Inserts the char, then fires `onActivate` with the typed query
|
|
48
48
|
* - 'launch': Fires `onActivate` on keydown and SUPPRESSES the char (it never
|
|
49
|
-
* enters the editor)
|
|
49
|
+
* enters the editor) - for opening an external surface (dialog, palette) where
|
|
50
50
|
* no in-editor text should appear. Honors `position` like the other modes.
|
|
51
51
|
*/
|
|
52
52
|
export type TriggerMode = 'dropdown' | 'callback' | 'launch'
|
|
@@ -102,7 +102,7 @@ export type TriggerConfig = {
|
|
|
102
102
|
* For 'dropdown' mode: opt a suggestion out of becoming a chip.
|
|
103
103
|
*
|
|
104
104
|
* Return a string and the trigger's range is replaced with that **plain,
|
|
105
|
-
* editable text**
|
|
105
|
+
* editable text** - the trigger character included - with the caret left at
|
|
106
106
|
* its end. Return undefined and the suggestion resolves to a chip as usual,
|
|
107
107
|
* so one dropdown can mix both kinds.
|
|
108
108
|
*
|
|
@@ -239,7 +239,7 @@ export type PromptAreaProps = {
|
|
|
239
239
|
/**
|
|
240
240
|
* When markdown is on, the editor rewrites typed list markers (`- ` / `* `)
|
|
241
241
|
* to a `•` bullet glyph in the model. Set to `false` to keep the original
|
|
242
|
-
* marker in the value/`onChange` text
|
|
242
|
+
* marker in the value/`onChange` text - needed when a host renders the output
|
|
243
243
|
* as real markdown, where `•` is not a valid list marker. Default `true`.
|
|
244
244
|
*/
|
|
245
245
|
normalizeBullets?: boolean
|
|
@@ -271,8 +271,8 @@ export type PromptAreaProps = {
|
|
|
271
271
|
* the caret kept where the edit happened. Chips count as their
|
|
272
272
|
* `trigger + displayText` length.
|
|
273
273
|
*
|
|
274
|
-
* The cap applies to typing only. Paste is not capped
|
|
275
|
-
* `onRawPaste` if needed
|
|
274
|
+
* The cap applies to typing only. Paste is not capped - divert it via
|
|
275
|
+
* `onRawPaste` if needed - and the imperative `setText` / `appendText` also
|
|
276
276
|
* bypass it, so a programmatic write can exceed the cap until the next
|
|
277
277
|
* keystroke truncates.
|
|
278
278
|
*/
|
|
@@ -316,7 +316,7 @@ export type PromptAreaProps = {
|
|
|
316
316
|
onBlur?: (e: React.FocusEvent<HTMLDivElement>) => void
|
|
317
317
|
/**
|
|
318
318
|
* Called at the start of a paste, before PromptArea reads the clipboard. Call
|
|
319
|
-
* `preventDefault()` to take over the paste completely
|
|
319
|
+
* `preventDefault()` to take over the paste completely - e.g. to divert large
|
|
320
320
|
* text or non-image files to an upload pipeline. The built-in segment/image
|
|
321
321
|
* paste handling is skipped when the event's default is prevented.
|
|
322
322
|
*/
|
|
@@ -80,7 +80,7 @@ export function useChipEditing({
|
|
|
80
80
|
|
|
81
81
|
// The chip node currently edited via `reopenOnChipClick`, kept in lockstep with
|
|
82
82
|
// `editingChip`/`activeTrigger`. Answers "is THIS exact element the open one" by
|
|
83
|
-
// reference identity
|
|
83
|
+
// reference identity - trigger+value cannot distinguish two chips sharing a value.
|
|
84
84
|
const openChipNode = useRef<HTMLElement | null>(null)
|
|
85
85
|
|
|
86
86
|
// Set by `handleMouseDown` when the mousedown landed on `openChipNode.current`;
|
|
@@ -88,7 +88,7 @@ export function useChipEditing({
|
|
|
88
88
|
// "toggle closed". A real mousedown on the editor root rather than a
|
|
89
89
|
// `dismissTrigger` flag: bubbling reaches the root before `document`, where
|
|
90
90
|
// TriggerPopover's outside-click dismiss listens and would clear `openChipNode`
|
|
91
|
-
// first
|
|
91
|
+
// first - and it stays scoped to this node, so an unrelated dismiss cannot poison
|
|
92
92
|
// a later click on the same chip.
|
|
93
93
|
const suppressReopenChip = useRef<HTMLElement | null>(null)
|
|
94
94
|
|
|
@@ -106,7 +106,7 @@ export function useChipEditing({
|
|
|
106
106
|
|
|
107
107
|
let node: Node | null = target
|
|
108
108
|
while (node && node !== editor) {
|
|
109
|
-
// Check for URL link click
|
|
109
|
+
// Check for URL link click - only navigate on Cmd/Ctrl+Click;
|
|
110
110
|
// plain click just positions the cursor for editing.
|
|
111
111
|
if (isLinkElement(node)) {
|
|
112
112
|
if (e.metaKey || e.ctrlKey) {
|
|
@@ -137,12 +137,12 @@ export function useChipEditing({
|
|
|
137
137
|
if (chip) {
|
|
138
138
|
// Native chip-click dropdown: reopen this trigger's suggestions
|
|
139
139
|
// anchored to the chip so the selection can replace it in place.
|
|
140
|
-
// Gated on `!disabled`
|
|
140
|
+
// Gated on `!disabled` - a disabled composer must not accept edits
|
|
141
141
|
// through any path, including this one.
|
|
142
142
|
const config = triggers.find((t) => t.char === chip.trigger)
|
|
143
143
|
// A click on THIS exact chip element while its own dropdown was
|
|
144
144
|
// open just closed it (see `suppressReopenChip` and
|
|
145
|
-
// `handleMouseDown`)
|
|
145
|
+
// `handleMouseDown`) - treat that as a toggle-close, not a reopen.
|
|
146
146
|
const wasOpenForThisChip = suppressReopenChip.current === node
|
|
147
147
|
suppressReopenChip.current = null
|
|
148
148
|
if (!disabled && !wasOpenForThisChip && config?.reopenOnChipClick && config.mode === 'dropdown' && config.onSearch) {
|
|
@@ -212,7 +212,7 @@ export function useChipEditing({
|
|
|
212
212
|
}
|
|
213
213
|
const editor = editorRef.current
|
|
214
214
|
if (editor && !disabled) {
|
|
215
|
-
// Re-verify the click-time index still holds the same chip
|
|
215
|
+
// Re-verify the click-time index still holds the same chip - the
|
|
216
216
|
// model may have shifted (external value update, undo/redo) while
|
|
217
217
|
// the dropdown was open. If it moved, recover ONLY when exactly one
|
|
218
218
|
// chip in the document now matches trigger+value: with duplicates,
|
|
@@ -258,7 +258,7 @@ export function useChipEditing({
|
|
|
258
258
|
renderSegmentsToDOM(newSegments)
|
|
259
259
|
|
|
260
260
|
// Same value + display text + data: treat as a no-op confirmation
|
|
261
|
-
// rather than a destructive delete+add
|
|
261
|
+
// rather than a destructive delete+add - onChipDelete is
|
|
262
262
|
// documented as firing on backspace/forward-delete, not on
|
|
263
263
|
// re-confirming the already-selected suggestion.
|
|
264
264
|
const unchanged =
|
|
@@ -271,7 +271,7 @@ export function useChipEditing({
|
|
|
271
271
|
}
|
|
272
272
|
|
|
273
273
|
// +1 when a space was inserted, matching resolveChip's own
|
|
274
|
-
// "+1 accounts for the trailing space after the chip" placement
|
|
274
|
+
// "+1 accounts for the trailing space after the chip" placement -
|
|
275
275
|
// landing exactly at the chip's end would put the caret at the
|
|
276
276
|
// same bare element boundary the inserted space exists to avoid.
|
|
277
277
|
const caretOffset = segmentsToPlainText(newSegments.slice(0, segIdx + 1)).length + (insertedSpace ? 1 : 0)
|
|
@@ -37,7 +37,7 @@ import { useCallback, useMemo, useState } from 'react'
|
|
|
37
37
|
*/
|
|
38
38
|
export type PromptAreaMode = 'markdown' | 'plain'
|
|
39
39
|
|
|
40
|
-
/** Returns the other mode. Pure
|
|
40
|
+
/** Returns the other mode. Pure - handy for building custom toggles. */
|
|
41
41
|
export function oppositeMode(mode: PromptAreaMode): PromptAreaMode {
|
|
42
42
|
return mode === 'markdown' ? 'plain' : 'markdown'
|
|
43
43
|
}
|
|
@@ -47,7 +47,7 @@ export type UseMarkdownModeOptions = {
|
|
|
47
47
|
initialMode?: PromptAreaMode
|
|
48
48
|
/**
|
|
49
49
|
* Controlled mode. When provided, the hook mirrors this value and never owns
|
|
50
|
-
* its own state
|
|
50
|
+
* its own state - drive changes through `onModeChange`.
|
|
51
51
|
*/
|
|
52
52
|
mode?: PromptAreaMode
|
|
53
53
|
/** Called with the next mode whenever `toggle`/`setMode` change it. */
|
|
@@ -57,7 +57,7 @@ export type UseMarkdownModeOptions = {
|
|
|
57
57
|
export type MarkdownModeState = {
|
|
58
58
|
/** The active mode. */
|
|
59
59
|
mode: PromptAreaMode
|
|
60
|
-
/** `true` in markdown mode
|
|
60
|
+
/** `true` in markdown mode - spread onto `<PromptArea markdown={markdown} />`. */
|
|
61
61
|
markdown: boolean
|
|
62
62
|
/** `true` in plain-text mode (the inverse of `markdown`). */
|
|
63
63
|
isPlainText: boolean
|
|
@@ -165,9 +165,9 @@ export function usePromptAreaEvents(deps: EventHandlerDeps): PromptAreaEventHand
|
|
|
165
165
|
}
|
|
166
166
|
|
|
167
167
|
// When markdown mode is on, prefer the richest clipboard flavor:
|
|
168
|
-
// 1. text/markdown
|
|
168
|
+
// 1. text/markdown - some apps (e.g. Slack) hand out markdown directly,
|
|
169
169
|
// preserving nested lists that their text/plain flattens.
|
|
170
|
-
// 2. text/html
|
|
170
|
+
// 2. text/html - convert web/Notion/Docs/GitHub HTML to markdown.
|
|
171
171
|
// Otherwise (markdown off, or neither present) fall back to plain text.
|
|
172
172
|
let text = ''
|
|
173
173
|
if (markdownEnabled) {
|
|
@@ -180,7 +180,7 @@ export function usePromptAreaEvents(deps: EventHandlerDeps): PromptAreaEventHand
|
|
|
180
180
|
} else {
|
|
181
181
|
const html = e.clipboardData.getData('text/html')
|
|
182
182
|
// A converter failure (e.g. stack overflow on pathologically deep
|
|
183
|
-
// nesting) must not drop the paste
|
|
183
|
+
// nesting) must not drop the paste - leave text empty so the
|
|
184
184
|
// text/plain fallback below still runs.
|
|
185
185
|
if (html) {
|
|
186
186
|
try {
|
|
@@ -373,7 +373,7 @@ export function usePromptAreaKeydown({
|
|
|
373
373
|
}
|
|
374
374
|
|
|
375
375
|
// 1.75 Launch triggers: a trigger with mode 'launch' fires onActivate on
|
|
376
|
-
// keydown and suppresses the char so it never enters the editor
|
|
376
|
+
// keydown and suppresses the char so it never enters the editor - for
|
|
377
377
|
// opening an external surface (dialog, palette). The DOM read is gated on
|
|
378
378
|
// the typed key actually matching a launch char, so it stays off the hot
|
|
379
379
|
// path. insertChip still inserts a chip at the cursor if the consumer
|
|
@@ -406,7 +406,7 @@ export function usePromptAreaKeydown({
|
|
|
406
406
|
// 2. Trigger dropdown navigation. Gated on the dropdown actually being
|
|
407
407
|
// ON SCREEN, which matches TriggerPopover's own render condition
|
|
408
408
|
// (non-empty suggestions, OR loading/error/emptyMessage) rather than
|
|
409
|
-
// just `suggestions.length > 0`
|
|
409
|
+
// just `suggestions.length > 0` - otherwise a popover left open in a
|
|
410
410
|
// loading/empty state (e.g. right after a chip-click reopen, before its
|
|
411
411
|
// empty-query search resolves) lets Enter fall through to onSubmit and
|
|
412
412
|
// Escape fall through to onEscape while still visibly on screen.
|
|
@@ -499,7 +499,7 @@ export function usePromptAreaKeydown({
|
|
|
499
499
|
}
|
|
500
500
|
|
|
501
501
|
// 3. Enter without Shift (skipping IME): under `submitOnEnter` it submits
|
|
502
|
-
// *unconditionally*
|
|
502
|
+
// *unconditionally* - the send key must not turn into "another bullet" because
|
|
503
503
|
// of what the line above starts with; list continuation lives on Shift+Enter
|
|
504
504
|
// (branch 2.8). Without `submitOnEnter`, Enter is the newline key and continues.
|
|
505
505
|
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
|
|
@@ -35,11 +35,11 @@ export type UsePromptAreaStateOptions = {
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
export type PromptAreaBind = {
|
|
38
|
-
/** Ref to attach to PromptArea
|
|
38
|
+
/** Ref to attach to PromptArea - gives access to imperative methods. */
|
|
39
39
|
ref: React.RefObject<PromptAreaHandle | null>
|
|
40
|
-
/** Current segment array
|
|
40
|
+
/** Current segment array - pass as `value` prop. */
|
|
41
41
|
value: Segment[]
|
|
42
|
-
/** Setter
|
|
42
|
+
/** Setter - pass as `onChange` prop. */
|
|
43
43
|
onChange: (segments: Segment[]) => void
|
|
44
44
|
}
|
|
45
45
|
|
|
@@ -83,7 +83,7 @@ export function usePromptAreaState(options: UsePromptAreaStateOptions = {}): Pro
|
|
|
83
83
|
|
|
84
84
|
const chips = useMemo(() => value.filter((seg): seg is ChipSegment => seg.type === 'chip'), [value])
|
|
85
85
|
|
|
86
|
-
// Bind object
|
|
86
|
+
// Bind object - safe to spread onto <PromptArea>
|
|
87
87
|
const bind = useMemo<PromptAreaBind>(() => ({ ref, value, onChange: setValue }), [value])
|
|
88
88
|
|
|
89
89
|
const clear = useCallback(() => {
|