@aragon-agent/cli 0.6.9 → 0.6.10
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 +53 -9
- package/dist/agent/reducer.d.ts +24 -0
- package/dist/agent/reducer.js +41 -1
- package/dist/cli.js +5 -6
- package/dist/commands/builtins.d.ts +2 -1
- package/dist/commands/builtins.js +47 -2
- package/dist/input/enter-sequences.d.ts +63 -0
- package/dist/input/enter-sequences.js +151 -0
- package/dist/input/limits.d.ts +11 -0
- package/dist/input/limits.js +11 -0
- package/dist/input/stdin-filter.js +19 -1
- package/dist/session/persist.js +20 -0
- package/dist/ui/App.js +29 -2
- package/dist/ui/Composer.d.ts +1 -1
- package/dist/ui/Composer.js +4 -1
- package/dist/ui/PromptInput.js +34 -2
- package/dist/ui/Transcript.js +6 -0
- package/dist/ui/enter-frames.d.ts +63 -0
- package/dist/ui/enter-frames.js +136 -0
- package/dist/ui/entries/QueuedEntry.d.ts +27 -0
- package/dist/ui/entries/QueuedEntry.js +30 -0
- package/dist/ui/glyphs.d.ts +9 -0
- package/dist/ui/glyphs.js +2 -0
- package/dist/ui/overlays/HelpOverlay.js +3 -2
- package/dist/ui/overlays/PlanReviewOverlay.js +5 -1
- package/dist/ui/overlays/QuestionOverlay.js +7 -2
- package/dist/ui/overlays/SettingsScreen.js +8 -1
- package/dist/ui/selection/selection-controller.d.ts +23 -7
- package/dist/ui/selection/selection-controller.js +68 -16
- package/dist/ui/use-startup-notices.d.ts +5 -1
- package/dist/ui/use-startup-notices.js +7 -3
- package/package.json +2 -2
package/dist/ui/App.js
CHANGED
|
@@ -68,6 +68,7 @@ import { CommandRegistry, runSlashInput } from '../commands/registry.js';
|
|
|
68
68
|
import { registerBuiltinCommands } from '../commands/builtins.js';
|
|
69
69
|
import { makeSkillsCommand, registerSkillCommands } from '../commands/skills.js';
|
|
70
70
|
import { MODE_LABEL, MODE_TOGGLE_KEYS, nextMode } from '../agent/agent-mode.js';
|
|
71
|
+
import { copyText } from './clipboard.js';
|
|
71
72
|
import { emptyTailState } from './layout/follow-state.js';
|
|
72
73
|
/**
|
|
73
74
|
* Decide what the `Max tokens` field means, WITHOUT letting a bad value in it
|
|
@@ -1367,7 +1368,12 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
|
|
|
1367
1368
|
cancelConfirmation();
|
|
1368
1369
|
if (interactionPhase.current === 'running') {
|
|
1369
1370
|
controller.steer(message);
|
|
1370
|
-
|
|
1371
|
+
// THE TRANSCRIPT ENTRY IS THE FEEDBACK (tui-shift-enter-copy-queue
|
|
1372
|
+
// 5.3). A 2.5 s toast cannot satisfy "until the message is picked
|
|
1373
|
+
// up": the `queued` entry stays visible and is rewritten in place
|
|
1374
|
+
// as the user message it became on the next `turn_start`. Keeping
|
|
1375
|
+
// the toast too would confirm one event twice.
|
|
1376
|
+
dispatch({ type: 'steerQueued', text: message });
|
|
1371
1377
|
return;
|
|
1372
1378
|
}
|
|
1373
1379
|
if (interactionPhase.current === 'starting') {
|
|
@@ -1602,6 +1608,27 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
|
|
|
1602
1608
|
// `Esc` IS DELIBERATELY NOT GIVEN A SECOND MEANING (§4.4.6). It aborts a
|
|
1603
1609
|
// run, and an emergency exit is the last key that should silently absorb a
|
|
1604
1610
|
// press; it clears the selection here like every other key and nothing more.
|
|
1611
|
+
//
|
|
1612
|
+
// THE ONE EXCEPTION, AND IT SITS AHEAD OF THE UNCONDITIONAL CLEAR
|
|
1613
|
+
// (tui-shift-enter-copy-queue 4.3): Ctrl+C over a RELEASED, non-empty
|
|
1614
|
+
// selection means COPY. Selecting is browsing; copying is committing,
|
|
1615
|
+
// and the commit key is the one a terminal user already reaches for.
|
|
1616
|
+
// `takeSelection` runs BEFORE `copyText` because the copy writes OSC
|
|
1617
|
+
// 52 through `writeForeign`, which invalidates the differ and would
|
|
1618
|
+
// otherwise re-clear this controller a tick later (R7). A failed copy
|
|
1619
|
+
// (`via === 'none'`) still reports through the shared toast funnel and
|
|
1620
|
+
// returns: the selection is already consumed, and the NEXT Ctrl+C
|
|
1621
|
+
// walks the original ladder.
|
|
1622
|
+
if (key.ctrl &&
|
|
1623
|
+
(input === 'c' || input === 'C') &&
|
|
1624
|
+
(selectionController?.hasPendingSelection() ?? false)) {
|
|
1625
|
+
const payload = selectionController.takeSelection();
|
|
1626
|
+
if (payload) {
|
|
1627
|
+
const via = copyText(payload.text, terminal?.writeForeign);
|
|
1628
|
+
selectionBridge?.onCopied?.(via, payload.lines, payload.text.length);
|
|
1629
|
+
return; // copied -- no service stop, no arm, no clear
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1605
1632
|
selectionController?.clear();
|
|
1606
1633
|
if (key.ctrl && (input === 'c' || input === 'C')) {
|
|
1607
1634
|
// RUNG ONE: Ctrl+C acquires the meaning a terminal user already expects
|
|
@@ -1961,7 +1988,7 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
|
|
|
1961
1988
|
// threading the raw flag into a fifth consumer is the one silent way back to
|
|
1962
1989
|
// two spinners (R-2).
|
|
1963
1990
|
const viewReducedMotion = reducedMotion || activityVisible;
|
|
1964
|
-
// The run status row (spinner + phrase +
|
|
1991
|
+
// The run status row (spinner + phrase + queue/interrupt hint) replaces the idle
|
|
1965
1992
|
// hint row ABOVE the input while running; it exists only when that hint row
|
|
1966
1993
|
// would, so the footer height is identical with and without it. `running`, not
|
|
1967
1994
|
// `activityVisible`: an overlay must not flip the Composer between two layouts.
|
package/dist/ui/Composer.d.ts
CHANGED
|
@@ -22,7 +22,7 @@ import { type RunActivity } from './run-status-row.js';
|
|
|
22
22
|
import { type AgentMode } from '../agent/agent-mode.js';
|
|
23
23
|
/**
|
|
24
24
|
* The run status row (tui-scrollbar-edge-and-run-row §3.3): the spinner, the phrase
|
|
25
|
-
* and the `
|
|
25
|
+
* and the `queue / interrupt / exit` clauses on ONE row above the input box.
|
|
26
26
|
* `App` passes it only while a run is in flight AND the idle hint row would have
|
|
27
27
|
* been shown, so enabling it never changes the footer's height.
|
|
28
28
|
*/
|
package/dist/ui/Composer.js
CHANGED
|
@@ -32,7 +32,10 @@ import { MODE_LABEL, MODE_TOGGLE_KEYS, nextMode } from '../agent/agent-mode.js';
|
|
|
32
32
|
*/
|
|
33
33
|
export function runningHintClauses(opts) {
|
|
34
34
|
const { glyphs, services } = opts;
|
|
35
|
-
|
|
35
|
+
// `queue`, NOT `steer` (tui-shift-enter-copy-queue 5.5): the gesture's
|
|
36
|
+
// consequence now has a name in the transcript (`Queue: ...`), and one
|
|
37
|
+
// behaviour with two names is undiscoverable.
|
|
38
|
+
const clauses = [`${glyphs.enterKey} queue`, `esc${glyphs.times}2 interrupt`];
|
|
36
39
|
// Confirmation is named even when service controls also need space.
|
|
37
40
|
if (services > 0)
|
|
38
41
|
clauses.push(`ctrl+c stop ${services}`);
|
package/dist/ui/PromptInput.js
CHANGED
|
@@ -29,6 +29,7 @@ import { AutocompletePopup } from './AutocompletePopup.js';
|
|
|
29
29
|
import { buildAutocompleteLayout } from './layout/autocomplete.js';
|
|
30
30
|
import { editorReducer, INITIAL_EDITOR_STATE, } from './editor-reducer.js';
|
|
31
31
|
import { hasPasteFrame, splitPasteFrames } from './paste-frames.js';
|
|
32
|
+
import { hasEnterFrame, mergeWithPasteRuns, splitEnterFrames, } from './enter-frames.js';
|
|
32
33
|
import { allocatePasteId, expandPastes, expandRangeOverTokens } from './paste-tokens.js';
|
|
33
34
|
import { draftMaxRows } from './composer-limits.js';
|
|
34
35
|
import { layoutComposer, splitRowAtColumn } from './composer-rows.js';
|
|
@@ -543,7 +544,16 @@ export function PromptInput({ cols: colsProp, cursorVisible = true, onInteractio
|
|
|
543
544
|
// The text runs inside `segments` are already sanitised (I-14): Ink drains
|
|
544
545
|
// its whole buffer in one `read()`, so this same string can carry the `\r`
|
|
545
546
|
// of a user who pasted and pressed Enter inside the burst window.
|
|
546
|
-
|
|
547
|
+
//
|
|
548
|
+
// NO NEWLINE INTENT IN THE CHUNK (tui-shift-enter-copy-queue 3.5):
|
|
549
|
+
// that same one-`read()` coalescing means a paste frame can share an
|
|
550
|
+
// `input` with an enter frame or a bare Ctrl+J, and those chunks
|
|
551
|
+
// belong to the newline branch below, whose `mergeWithPasteRuns`
|
|
552
|
+
// interleaves both families in byte order. This branch's
|
|
553
|
+
// `splitPasteFrames` would strip the enter frame's NUL and leak its
|
|
554
|
+
// letter as text. A multi-line payload (a `\n` of its own) routes
|
|
555
|
+
// there too and comes back byte-identical inside its paste segment.
|
|
556
|
+
if (hasPasteFrame(input) && !hasEnterFrame(input) && !input.includes('\n')) {
|
|
547
557
|
const runs = splitPasteFrames(input);
|
|
548
558
|
const segments = runs.map((run) => run.kind === 'paste' ? { kind: 'paste', text: run.text, id: allocatePasteId() } : run);
|
|
549
559
|
if (segments.length === 0)
|
|
@@ -559,6 +569,28 @@ export function PromptInput({ cols: colsProp, cursorVisible = true, onInteractio
|
|
|
559
569
|
dispatch({ type: 'input', segments });
|
|
560
570
|
return;
|
|
561
571
|
}
|
|
572
|
+
// A NEWLINE INTENT, from either source (tui-shift-enter-copy-queue
|
|
573
|
+
// 3.5): the NUL frame the filter wrote for Shift+Enter / Alt+Enter /
|
|
574
|
+
// CSI-u Enter, or a bare `\n` (Ctrl+J), which Ink delivers WITHOUT
|
|
575
|
+
// `key.return`. Sits beside the paste branch -- after every key test so
|
|
576
|
+
// it can never shadow one, before `isControlSeq` because both sources
|
|
577
|
+
// begin with a control byte and would otherwise be dropped. ONE
|
|
578
|
+
// dispatch carries the whole ordered chunk, paste runs included when
|
|
579
|
+
// Ink coalesced the filter's two writes into one input string.
|
|
580
|
+
if (hasEnterFrame(input) || input.includes('\n')) {
|
|
581
|
+
const runs = mergeWithPasteRuns(input, splitEnterFrames(input));
|
|
582
|
+
const segments = runs.map((run) => run.kind === 'paste' ? { kind: 'paste', text: run.text, id: allocatePasteId() } : run);
|
|
583
|
+
if (segments.length === 0)
|
|
584
|
+
return;
|
|
585
|
+
const refusal = draftLimitRefusal(segments, editor);
|
|
586
|
+
if (refusal) {
|
|
587
|
+
onNotice?.('warn', refusal);
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
onInteraction?.();
|
|
591
|
+
dispatch({ type: 'input', segments });
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
562
594
|
// Printable input — drop any unrecognized control/escape byte.
|
|
563
595
|
if (input && !key.tab && !isControlSeq(input))
|
|
564
596
|
insert(input);
|
|
@@ -566,7 +598,7 @@ export function PromptInput({ cols: colsProp, cursorVisible = true, onInteractio
|
|
|
566
598
|
const marker = running ? glyphs.steer : glyphs.caret;
|
|
567
599
|
const markerColor = running ? theme.toolRunning : theme.primary;
|
|
568
600
|
const placeholder = running
|
|
569
|
-
? `Type to
|
|
601
|
+
? `Type to queue a message, Esc twice to interrupt${glyphs.ellipsis}`
|
|
570
602
|
: agentMode === 'plan'
|
|
571
603
|
? `Describe what you want to build; I'll research and plan it first${glyphs.ellipsis}`
|
|
572
604
|
: `Send a message (/ for commands, @ for files)${glyphs.ellipsis}`;
|
package/dist/ui/Transcript.js
CHANGED
|
@@ -8,6 +8,7 @@ import { pickGlyphs, toolGlyph } from './glyphs.js';
|
|
|
8
8
|
import { separationRows } from './density.js';
|
|
9
9
|
import { EntryFrame } from './entries/EntryFrame.js';
|
|
10
10
|
import { UserEntry } from './entries/UserEntry.js';
|
|
11
|
+
import { QueuedEntry } from './entries/QueuedEntry.js';
|
|
11
12
|
import { AssistantEntry } from './entries/AssistantEntry.js';
|
|
12
13
|
import { ToolCard, statusColor, toolEntryGlyph } from './entries/ToolCard.js';
|
|
13
14
|
import { TeamCard, teamCardColor } from './entries/TeamCard.js';
|
|
@@ -58,6 +59,11 @@ function EntryViewImpl({ entry, prev, expanded, thinkingVisible, reducedMotion,
|
|
|
58
59
|
switch (entry.kind) {
|
|
59
60
|
case 'user':
|
|
60
61
|
return frame(glyphs.user, theme.user, _jsx(UserEntry, { text: entry.text, theme: theme, caps: caps }));
|
|
62
|
+
case 'queued':
|
|
63
|
+
// `theme.muted`: quieter than a user message, louder than a notice --
|
|
64
|
+
// the row exists to be noticed UNTIL it is accepted, then rewritten in
|
|
65
|
+
// place by `turnStart` (5.2.3).
|
|
66
|
+
return frame(glyphs.queued, theme.muted, _jsx(QueuedEntry, { text: entry.text, theme: theme, caps: caps }));
|
|
61
67
|
case 'assistant': {
|
|
62
68
|
const animate = entry.streaming && !reducedMotion && caps.unicode;
|
|
63
69
|
return frame(animate ? _jsx(Spinner, { type: "dots" }) : glyphs.assistant, theme.primary, _jsx(AssistantEntry, { text: entry.text, thinking: entry.thinking, thinkingOpen: entry.thinkingOpen, thinkingVisible: thinkingVisible, streaming: entry.streaming, aborted: entry.aborted,
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading Enter frames back out of an Ink `input` string
|
|
3
|
+
* (tui-shift-enter-copy-queue 3.5) -- pure.
|
|
4
|
+
*
|
|
5
|
+
* The stdin filter rewrites a modified Enter into `ENTER_NEWLINE_FRAME` inline,
|
|
6
|
+
* in stream order, exactly the way it frames a paste. This module is the only
|
|
7
|
+
* place that takes those frames apart again, and it handles BOTH newline
|
|
8
|
+
* sources the composer can see in one `input` string:
|
|
9
|
+
*
|
|
10
|
+
* - `ENTER_NEWLINE_FRAME` -- Shift+Enter / Alt+Enter / CSI-u Enter, translated
|
|
11
|
+
* by the filter; and
|
|
12
|
+
* - a bare `\n` -- Ctrl+J, which Ink parses to `name: 'enter'` with
|
|
13
|
+
* `input === '\n'` and NO `key.return`, so it reaches this branch as an
|
|
14
|
+
* ordinary (control) character that must still mean "newline".
|
|
15
|
+
*
|
|
16
|
+
* I-12, THE BROADCAST OBLIGATION, APPLIES TO THESE FRAMES TOO. `useInput`
|
|
17
|
+
* re-emits every chunk to every mounted handler, `stripPasteFrames` does not
|
|
18
|
+
* know this frame, and a NUL appended into `SettingsScreen`'s API-key field
|
|
19
|
+
* renders as nothing and silently breaks authentication -- the exact accident
|
|
20
|
+
* class that function's own doc records. `stripEnterFrames` is the equivalent
|
|
21
|
+
* guard for overlays with no paste model of their own.
|
|
22
|
+
*/
|
|
23
|
+
import { type FrameSegment } from './paste-frames.js';
|
|
24
|
+
/** True when this `input` carries at least one Enter frame -- the branch guard. */
|
|
25
|
+
export declare function hasEnterFrame(input: string): boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Split an `input` string into ordered text runs and newline runs.
|
|
28
|
+
*
|
|
29
|
+
* Text-only by construction (paste frames are `mergeWithPasteRuns`'s job), so
|
|
30
|
+
* the paste id never has to exist here -- `PromptInput` allocates ids for paste
|
|
31
|
+
* segments while building the reducer's action, exactly as the paste branch
|
|
32
|
+
* does (P1-5: the reducer stays pure and this stays a function of its input).
|
|
33
|
+
*/
|
|
34
|
+
export declare function splitEnterFrames(input: string): FrameSegment[];
|
|
35
|
+
/**
|
|
36
|
+
* Merge newline runs with the paste frames that share the same `input`.
|
|
37
|
+
*
|
|
38
|
+
* Ink drains its buffer in one size-less `read()`, so the filter's TWO writes
|
|
39
|
+
* (one per frame family) can arrive as ONE chunk carrying both a paste frame
|
|
40
|
+
* and an Enter frame. Neither splitter alone preserves that order --
|
|
41
|
+
* `splitPasteFrames` sanitises `\n` out of its text runs, and this module's
|
|
42
|
+
* spans treat a paste payload as ordinary text -- so the merge walks the
|
|
43
|
+
* ORIGINAL string once: paste frames become paste segments (payload through
|
|
44
|
+
* `sanitisePaste`, as `splitPasteFrames` does), and the spans between them are
|
|
45
|
+
* segmented with the SAME walker `splitEnterFrames` uses, which is what keeps
|
|
46
|
+
* the two views from disagreeing about where a newline sits.
|
|
47
|
+
*
|
|
48
|
+
* Paste segments carry NO id here (P1-5); the caller assigns one per segment
|
|
49
|
+
* only when it builds the reducer's action.
|
|
50
|
+
*/
|
|
51
|
+
export declare function mergeWithPasteRuns(input: string, runs: FrameSegment[]): FrameSegment[];
|
|
52
|
+
/**
|
|
53
|
+
* Remove every Enter frame for a consumer that has no newline model of its
|
|
54
|
+
* own, replacing each frame with `'\n'` (tui-shift-enter-copy-queue 3.5, the
|
|
55
|
+
* I-12 analogue for this frame family).
|
|
56
|
+
*
|
|
57
|
+
* NO OTHER SANITISATION, unlike `stripPasteFrames`: the overlay call sites wrap
|
|
58
|
+
* this INSIDE their existing `stripPasteFrames(...)` call, and the composite
|
|
59
|
+
* keeps that function's byte-level guarantees. Composed the other way round a
|
|
60
|
+
* lone NUL frame would survive as its payload letter; composed this way the
|
|
61
|
+
* frame's newline lands exactly where a bare Ctrl+J lands today.
|
|
62
|
+
*/
|
|
63
|
+
export declare function stripEnterFrames(input: string): string;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading Enter frames back out of an Ink `input` string
|
|
3
|
+
* (tui-shift-enter-copy-queue 3.5) -- pure.
|
|
4
|
+
*
|
|
5
|
+
* The stdin filter rewrites a modified Enter into `ENTER_NEWLINE_FRAME` inline,
|
|
6
|
+
* in stream order, exactly the way it frames a paste. This module is the only
|
|
7
|
+
* place that takes those frames apart again, and it handles BOTH newline
|
|
8
|
+
* sources the composer can see in one `input` string:
|
|
9
|
+
*
|
|
10
|
+
* - `ENTER_NEWLINE_FRAME` -- Shift+Enter / Alt+Enter / CSI-u Enter, translated
|
|
11
|
+
* by the filter; and
|
|
12
|
+
* - a bare `\n` -- Ctrl+J, which Ink parses to `name: 'enter'` with
|
|
13
|
+
* `input === '\n'` and NO `key.return`, so it reaches this branch as an
|
|
14
|
+
* ordinary (control) character that must still mean "newline".
|
|
15
|
+
*
|
|
16
|
+
* I-12, THE BROADCAST OBLIGATION, APPLIES TO THESE FRAMES TOO. `useInput`
|
|
17
|
+
* re-emits every chunk to every mounted handler, `stripPasteFrames` does not
|
|
18
|
+
* know this frame, and a NUL appended into `SettingsScreen`'s API-key field
|
|
19
|
+
* renders as nothing and silently breaks authentication -- the exact accident
|
|
20
|
+
* class that function's own doc records. `stripEnterFrames` is the equivalent
|
|
21
|
+
* guard for overlays with no paste model of their own.
|
|
22
|
+
*/
|
|
23
|
+
import { ENTER_NEWLINE_FRAME, PASTE_CLOSE, PASTE_OPEN } from '../input/limits.js';
|
|
24
|
+
import { sanitisePaste } from '../input/paste-parse.js';
|
|
25
|
+
import { sanitiseTyped } from './paste-frames.js';
|
|
26
|
+
/** True when this `input` carries at least one Enter frame -- the branch guard. */
|
|
27
|
+
export function hasEnterFrame(input) {
|
|
28
|
+
return input.includes(ENTER_NEWLINE_FRAME);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Split one Enter-frame-free span into ordered text / newline segments.
|
|
32
|
+
*
|
|
33
|
+
* Every newline source -- frame or bare `\n` -- yields ONE
|
|
34
|
+
* `{ kind: 'text', text: '\n' }` segment, so the editor's existing `input`
|
|
35
|
+
* action inserts it through the same path typing uses and the draft's row
|
|
36
|
+
* budget applies unchanged. Non-newline text is accumulated and passed through
|
|
37
|
+
* `sanitiseTyped` (I-14): a chunk that carries a frame can also carry the
|
|
38
|
+
* keystrokes that arrived beside it, and a coalesced CR must not be spliced
|
|
39
|
+
* back into the draft raw.
|
|
40
|
+
*/
|
|
41
|
+
function splitEnterSpan(span) {
|
|
42
|
+
const segments = [];
|
|
43
|
+
let text = '';
|
|
44
|
+
const pushText = () => {
|
|
45
|
+
const clean = sanitiseTyped(text);
|
|
46
|
+
if (clean.length > 0)
|
|
47
|
+
segments.push({ kind: 'text', text: clean });
|
|
48
|
+
text = '';
|
|
49
|
+
};
|
|
50
|
+
let i = 0;
|
|
51
|
+
while (i < span.length) {
|
|
52
|
+
if (span.startsWith(ENTER_NEWLINE_FRAME, i)) {
|
|
53
|
+
pushText();
|
|
54
|
+
segments.push({ kind: 'text', text: '\n' });
|
|
55
|
+
i += ENTER_NEWLINE_FRAME.length;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (span[i] === '\n') {
|
|
59
|
+
pushText();
|
|
60
|
+
segments.push({ kind: 'text', text: '\n' });
|
|
61
|
+
i += 1;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
text += span[i];
|
|
65
|
+
i += 1;
|
|
66
|
+
}
|
|
67
|
+
pushText();
|
|
68
|
+
return segments;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Split an `input` string into ordered text runs and newline runs.
|
|
72
|
+
*
|
|
73
|
+
* Text-only by construction (paste frames are `mergeWithPasteRuns`'s job), so
|
|
74
|
+
* the paste id never has to exist here -- `PromptInput` allocates ids for paste
|
|
75
|
+
* segments while building the reducer's action, exactly as the paste branch
|
|
76
|
+
* does (P1-5: the reducer stays pure and this stays a function of its input).
|
|
77
|
+
*/
|
|
78
|
+
export function splitEnterFrames(input) {
|
|
79
|
+
return splitEnterSpan(input);
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Merge newline runs with the paste frames that share the same `input`.
|
|
83
|
+
*
|
|
84
|
+
* Ink drains its buffer in one size-less `read()`, so the filter's TWO writes
|
|
85
|
+
* (one per frame family) can arrive as ONE chunk carrying both a paste frame
|
|
86
|
+
* and an Enter frame. Neither splitter alone preserves that order --
|
|
87
|
+
* `splitPasteFrames` sanitises `\n` out of its text runs, and this module's
|
|
88
|
+
* spans treat a paste payload as ordinary text -- so the merge walks the
|
|
89
|
+
* ORIGINAL string once: paste frames become paste segments (payload through
|
|
90
|
+
* `sanitisePaste`, as `splitPasteFrames` does), and the spans between them are
|
|
91
|
+
* segmented with the SAME walker `splitEnterFrames` uses, which is what keeps
|
|
92
|
+
* the two views from disagreeing about where a newline sits.
|
|
93
|
+
*
|
|
94
|
+
* Paste segments carry NO id here (P1-5); the caller assigns one per segment
|
|
95
|
+
* only when it builds the reducer's action.
|
|
96
|
+
*/
|
|
97
|
+
export function mergeWithPasteRuns(input, runs) {
|
|
98
|
+
if (!input.includes(PASTE_OPEN))
|
|
99
|
+
return runs;
|
|
100
|
+
const merged = [];
|
|
101
|
+
let i = 0;
|
|
102
|
+
while (i < input.length) {
|
|
103
|
+
const open = input.indexOf(PASTE_OPEN, i);
|
|
104
|
+
if (open === -1) {
|
|
105
|
+
merged.push(...splitEnterSpan(input.slice(i)));
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
merged.push(...splitEnterSpan(input.slice(i, open)));
|
|
109
|
+
const bodyStart = open + PASTE_OPEN.length;
|
|
110
|
+
const close = input.indexOf(PASTE_CLOSE, bodyStart);
|
|
111
|
+
const body = close === -1 ? input.slice(bodyStart) : input.slice(bodyStart, close);
|
|
112
|
+
const payload = sanitisePaste(body);
|
|
113
|
+
if (payload.length > 0)
|
|
114
|
+
merged.push({ kind: 'paste', text: payload });
|
|
115
|
+
if (close === -1)
|
|
116
|
+
break;
|
|
117
|
+
i = close + PASTE_CLOSE.length;
|
|
118
|
+
}
|
|
119
|
+
return merged;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Remove every Enter frame for a consumer that has no newline model of its
|
|
123
|
+
* own, replacing each frame with `'\n'` (tui-shift-enter-copy-queue 3.5, the
|
|
124
|
+
* I-12 analogue for this frame family).
|
|
125
|
+
*
|
|
126
|
+
* NO OTHER SANITISATION, unlike `stripPasteFrames`: the overlay call sites wrap
|
|
127
|
+
* this INSIDE their existing `stripPasteFrames(...)` call, and the composite
|
|
128
|
+
* keeps that function's byte-level guarantees. Composed the other way round a
|
|
129
|
+
* lone NUL frame would survive as its payload letter; composed this way the
|
|
130
|
+
* frame's newline lands exactly where a bare Ctrl+J lands today.
|
|
131
|
+
*/
|
|
132
|
+
export function stripEnterFrames(input) {
|
|
133
|
+
if (!hasEnterFrame(input))
|
|
134
|
+
return input;
|
|
135
|
+
return input.split(ENTER_NEWLINE_FRAME).join('\n');
|
|
136
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render a queued steering message (tui-shift-enter-copy-queue 5.4).
|
|
3
|
+
*
|
|
4
|
+
* ONE ROW, ALWAYS. `wrap="truncate"` -- the run-status-row width policy, for
|
|
5
|
+
* the height-estimate reason DiffView learned the hard way: a queued message
|
|
6
|
+
* can be arbitrarily long, and an entry whose wrapped height disagrees with its
|
|
7
|
+
* estimate is exactly the one the virtual window never re-measures.
|
|
8
|
+
*
|
|
9
|
+
* The glyph and the rail belong to `EntryFrame`, exactly as for every other
|
|
10
|
+
* entry; this component renders content only.
|
|
11
|
+
*/
|
|
12
|
+
import React from 'react';
|
|
13
|
+
import type { Theme } from '../theme.js';
|
|
14
|
+
import type { TermCapabilities } from '../capabilities.js';
|
|
15
|
+
declare function QueuedEntryImpl({ text, theme, caps, }: {
|
|
16
|
+
text: string;
|
|
17
|
+
theme: Theme;
|
|
18
|
+
caps: TermCapabilities;
|
|
19
|
+
}): React.ReactElement;
|
|
20
|
+
/**
|
|
21
|
+
* `React.memo` with the DEFAULT comparator, like every sibling entry card: the
|
|
22
|
+
* reducer rebuilds the entry object only when it changes, and a `queued` entry
|
|
23
|
+
* changes exactly twice -- once when appended, once when it is rewritten into a
|
|
24
|
+
* `user` entry and stops being this component's problem.
|
|
25
|
+
*/
|
|
26
|
+
export declare const QueuedEntry: React.MemoExoticComponent<typeof QueuedEntryImpl>;
|
|
27
|
+
export {};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Render a queued steering message (tui-shift-enter-copy-queue 5.4).
|
|
4
|
+
*
|
|
5
|
+
* ONE ROW, ALWAYS. `wrap="truncate"` -- the run-status-row width policy, for
|
|
6
|
+
* the height-estimate reason DiffView learned the hard way: a queued message
|
|
7
|
+
* can be arbitrarily long, and an entry whose wrapped height disagrees with its
|
|
8
|
+
* estimate is exactly the one the virtual window never re-measures.
|
|
9
|
+
*
|
|
10
|
+
* The glyph and the rail belong to `EntryFrame`, exactly as for every other
|
|
11
|
+
* entry; this component renders content only.
|
|
12
|
+
*/
|
|
13
|
+
import React from 'react';
|
|
14
|
+
import { Text } from 'ink';
|
|
15
|
+
import { pickGlyphs } from '../glyphs.js';
|
|
16
|
+
function QueuedEntryImpl({ text, theme, caps, }) {
|
|
17
|
+
const glyphs = pickGlyphs(caps);
|
|
18
|
+
const lines = text.split('\n');
|
|
19
|
+
const first = lines[0] ?? '';
|
|
20
|
+
const extra = lines.length - 1;
|
|
21
|
+
const suffix = extra > 0 ? ` ${glyphs.ellipsis}+${extra} line${extra === 1 ? '' : 's'}` : '';
|
|
22
|
+
return (_jsx(Text, { wrap: "truncate", color: theme.muted, children: `Queue: ${first}${suffix}` }));
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* `React.memo` with the DEFAULT comparator, like every sibling entry card: the
|
|
26
|
+
* reducer rebuilds the entry object only when it changes, and a `queued` entry
|
|
27
|
+
* changes exactly twice -- once when appended, once when it is rewritten into a
|
|
28
|
+
* `user` entry and stops being this component's problem.
|
|
29
|
+
*/
|
|
30
|
+
export const QueuedEntry = React.memo(QueuedEntryImpl);
|
package/dist/ui/glyphs.d.ts
CHANGED
|
@@ -117,6 +117,15 @@ export interface Glyphs {
|
|
|
117
117
|
* scan fails the build if either spelling appears anywhere else.
|
|
118
118
|
*/
|
|
119
119
|
retry: string;
|
|
120
|
+
/**
|
|
121
|
+
* A message queued behind a running turn (tui-shift-enter-copy-queue 5.4).
|
|
122
|
+
* The rail marker on the `Queue: ...` row.
|
|
123
|
+
*
|
|
124
|
+
* HERE rather than in `QueuedEntry`, for the reason this file's header
|
|
125
|
+
* gives: a literal in a component bypasses `pickGlyphs` entirely and
|
|
126
|
+
* shows mojibake on a legacy console whatever the capability probe said.
|
|
127
|
+
*/
|
|
128
|
+
queued: string;
|
|
120
129
|
/**
|
|
121
130
|
* Context compaction (context-auto-compaction §6.3). The rail marker on the
|
|
122
131
|
* compaction card.
|
package/dist/ui/glyphs.js
CHANGED
|
@@ -66,6 +66,7 @@ const UNICODE_GLYPHS = {
|
|
|
66
66
|
todoDone: '✔',
|
|
67
67
|
retry: '↻',
|
|
68
68
|
compaction: '⤓',
|
|
69
|
+
queued: '◷',
|
|
69
70
|
tool: {
|
|
70
71
|
read_file: '▤',
|
|
71
72
|
write_file: '✎',
|
|
@@ -129,6 +130,7 @@ const ASCII_GLYPHS = {
|
|
|
129
130
|
todoDone: '[x]',
|
|
130
131
|
retry: '[r]',
|
|
131
132
|
compaction: '[c]',
|
|
133
|
+
queued: '*',
|
|
132
134
|
tool: {
|
|
133
135
|
read_file: '=',
|
|
134
136
|
write_file: '+',
|
|
@@ -27,8 +27,9 @@ function keyRows(times) {
|
|
|
27
27
|
// a key, so it has to be named somewhere they can look it up. The one-shot
|
|
28
28
|
// startup notice is the other place, and it scrolls away.
|
|
29
29
|
['Drag right edge', 'Scroll messages and input together; type to return'],
|
|
30
|
-
['Run status row', 'Spinner and
|
|
31
|
-
['Drag text (left button)', 'Select text on screen;
|
|
30
|
+
['Run status row', 'Spinner and queue / interrupt keys, above the input during a run'],
|
|
31
|
+
['Drag text (left button)', 'Select text on screen; the highlight waits'],
|
|
32
|
+
['Ctrl+C (with a selection)', 'Copy the pending selection - does not arm exit'],
|
|
32
33
|
['Up / Down', 'Prompt history (empty input)'],
|
|
33
34
|
// Pasting is the one affordance here with no key of its own, so the only
|
|
34
35
|
// place a user can learn what happened to their 218 lines is this row.
|
|
@@ -28,6 +28,7 @@ import { pickGlyphs } from '../glyphs.js';
|
|
|
28
28
|
import { OverlayFrame } from '../layout/OverlayFrame.js';
|
|
29
29
|
import { wrapToRows } from '../layout/wrap-rows.js';
|
|
30
30
|
import { stripPasteFrames } from '../paste-frames.js';
|
|
31
|
+
import { stripEnterFrames } from '../enter-frames.js';
|
|
31
32
|
/** The frame's border plus its one-column padding on each side. */
|
|
32
33
|
const FRAME_CHROME_COLS = 4;
|
|
33
34
|
export function PlanReviewOverlay({ plan, maxRows, cols, scrollOffset, onScrollClamp, theme, caps, onVerdict, }) {
|
|
@@ -63,7 +64,10 @@ export function PlanReviewOverlay({ plan, maxRows, cols, scrollOffset, onScrollC
|
|
|
63
64
|
// pastes — a failing test, a stack trace — and a framed paste appended
|
|
64
65
|
// verbatim would put NULs into text the model then reads.
|
|
65
66
|
if (input && !key.ctrl && !key.meta && !key.tab) {
|
|
66
|
-
|
|
67
|
+
// `stripEnterFrames` rides inside it for the newline frame a
|
|
68
|
+
// Shift+Enter broadcasts (tui-shift-enter-copy-queue 3.5): same hazard,
|
|
69
|
+
// same fix, and this order is the one that leaves no NUL in the draft.
|
|
70
|
+
setFeedback(feedback + stripPasteFrames(stripEnterFrames(input)));
|
|
67
71
|
}
|
|
68
72
|
return;
|
|
69
73
|
}
|
|
@@ -20,6 +20,7 @@ import { Box, Text, useInput } from 'ink';
|
|
|
20
20
|
import { pickGlyphs } from '../glyphs.js';
|
|
21
21
|
import { OverlayFrame } from '../layout/OverlayFrame.js';
|
|
22
22
|
import { stripPasteFrames } from '../paste-frames.js';
|
|
23
|
+
import { stripEnterFrames } from '../enter-frames.js';
|
|
23
24
|
/** Which option index each question starts on: the recommended one. */
|
|
24
25
|
function initialCursors(questions) {
|
|
25
26
|
return questions.map((q) => Math.max(0, q.options.findIndex((o) => o.recommended)));
|
|
@@ -125,8 +126,12 @@ export function QuestionOverlay({ questions, maxRows, cols, theme, caps, onSubmi
|
|
|
125
126
|
// `stripPasteFrames`, NOT `input` (I-12 / P0-2) -- the same broadcast
|
|
126
127
|
// hazard `SettingsScreen` documents at length: a framed paste appended
|
|
127
128
|
// verbatim stores NULs that render as nothing.
|
|
128
|
-
|
|
129
|
-
|
|
129
|
+
// `stripEnterFrames` rides inside it for the newline frame a
|
|
130
|
+
// Shift+Enter broadcasts (tui-shift-enter-copy-queue 3.5): same hazard,
|
|
131
|
+
// same fix, and this order is the one that leaves no NUL in the draft.
|
|
132
|
+
if (input && !key.ctrl && !key.meta && !key.tab) {
|
|
133
|
+
setDraft(draft + stripPasteFrames(stripEnterFrames(input)));
|
|
134
|
+
}
|
|
130
135
|
return;
|
|
131
136
|
}
|
|
132
137
|
// phase === 'choosing'
|
|
@@ -16,6 +16,7 @@ import { LOG_LEVEL_NAMES } from '../../logging/levels.js';
|
|
|
16
16
|
import { pickGlyphs } from '../glyphs.js';
|
|
17
17
|
import { OverlayFrame } from '../layout/OverlayFrame.js';
|
|
18
18
|
import { stripPasteFrames } from '../paste-frames.js';
|
|
19
|
+
import { stripEnterFrames } from '../enter-frames.js';
|
|
19
20
|
const SHOW_THINKING_OPTIONS = ['off', 'on'];
|
|
20
21
|
const LIVE_OUTPUT_OPTIONS = ['off', 'on'];
|
|
21
22
|
const FAST_ENABLED_OPTIONS = ['off', 'on'];
|
|
@@ -302,7 +303,13 @@ export function SettingsScreen({ initial, apiKeys, maxRows, cols, scrollOffset,
|
|
|
302
303
|
// `controller.setApiKey()`. The user is told the settings were saved and
|
|
303
304
|
// every request afterwards fails to authenticate with nothing on screen to
|
|
304
305
|
// explain it. Pasting IS how a key enters this process.
|
|
305
|
-
|
|
306
|
+
// `stripEnterFrames` INSIDE (tui-shift-enter-copy-queue 3.5): the same
|
|
307
|
+
// broadcast carries the newline frame a Shift+Enter produced, and this
|
|
308
|
+
// composition order is the one that leaves no NUL in the field -- the
|
|
309
|
+
// outer paste strip removes the frame's newline for a single-line field
|
|
310
|
+
// exactly as it removes a bare Ctrl+J today, while the reverse order
|
|
311
|
+
// would leak the frame's letter as visible text.
|
|
312
|
+
const text = stripPasteFrames(stripEnterFrames(input));
|
|
306
313
|
if (text.length === 0)
|
|
307
314
|
return;
|
|
308
315
|
setValues((prev) => ({ ...prev, [field.key]: String(prev[field.key]) + text }));
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The selection controller (tui-selection-and-scroll-follow §4.4). NON-REACT: it
|
|
3
|
-
* owns the mouse lifecycle, the 16 ms drag coalescer, the `decorate` hook and
|
|
4
|
-
*
|
|
5
|
-
* `App` registers on mount.
|
|
3
|
+
* owns the mouse lifecycle, the 16 ms drag coalescer, the `decorate` hook and
|
|
4
|
+
* the released-selection state Ctrl+C consumes, and it reaches the React tree
|
|
5
|
+
* only through the callbacks `App` registers on mount.
|
|
6
6
|
*
|
|
7
7
|
* WHY IT IS NOT A COMPONENT (D-6). A React re-render per motion report goes
|
|
8
8
|
* through the render governor and the whole component tree; the frame pipeline
|
|
@@ -28,9 +28,9 @@
|
|
|
28
28
|
* than by a staleness check that can be wrong.
|
|
29
29
|
*/
|
|
30
30
|
import type { MouseSource } from '../../input/stdin-filter.js';
|
|
31
|
+
import type { CopyVia } from '../clipboard.js';
|
|
31
32
|
import type { Theme } from '../theme.js';
|
|
32
33
|
import type { TermCapabilities } from '../capabilities.js';
|
|
33
|
-
import type { CopyVia } from '../clipboard.js';
|
|
34
34
|
/**
|
|
35
35
|
* How long a nominally-held button may sit MOTIONLESS before the watchdog lets
|
|
36
36
|
* go of it (I-11). Reset by every motion report, so it only fires when the
|
|
@@ -49,9 +49,6 @@ export interface SelectionControllerOptions {
|
|
|
49
49
|
repaint: () => boolean;
|
|
50
50
|
/** `App`'s `redrawNonce`, for the invalidated case (§4.4.3). */
|
|
51
51
|
requestRedraw: () => void;
|
|
52
|
-
copy: (text: string) => CopyVia;
|
|
53
|
-
/** Toast sink, registered by `App` on mount (§6.3). */
|
|
54
|
-
onCopied: (via: CopyVia, lines: number, chars: number) => void;
|
|
55
52
|
theme: () => Theme;
|
|
56
53
|
caps: TermCapabilities;
|
|
57
54
|
/** Terminal width, for the right edge of a multi-row highlight. */
|
|
@@ -64,6 +61,25 @@ export interface SelectionControllerOptions {
|
|
|
64
61
|
export interface SelectionController {
|
|
65
62
|
/** Feed to `createFrameDiffer({ decorate })`. Mirrors, then paints. */
|
|
66
63
|
decorate(lines: string[]): string[];
|
|
64
|
+
/**
|
|
65
|
+
* Whether a RELEASED, non-empty selection is waiting for Ctrl+C
|
|
66
|
+
* (tui-shift-enter-copy-queue 4.2). Dragging does not count: the gesture
|
|
67
|
+
* is not finished, and a copy key pressed mid-drag must fall through to
|
|
68
|
+
* whatever it meant before this feature.
|
|
69
|
+
*/
|
|
70
|
+
hasPendingSelection(): boolean;
|
|
71
|
+
/**
|
|
72
|
+
* Take the pending selection: returns its text and line count, and clears
|
|
73
|
+
* the highlight, the settled state and the hold. `null` when nothing is
|
|
74
|
+
* pending. The caller owns the copy itself (`copyText` is the single
|
|
75
|
+
* clipboard path) and MUST take BEFORE copying: OSC 52 writes through
|
|
76
|
+
* `writeForeign`, the differ invalidates, and its `onInvalidate` clears
|
|
77
|
+
* this controller -- taking first makes that clear a no-op (4.3 / R7).
|
|
78
|
+
*/
|
|
79
|
+
takeSelection(): {
|
|
80
|
+
text: string;
|
|
81
|
+
lines: number;
|
|
82
|
+
} | null;
|
|
67
83
|
/** Subscribe to `hold` transitions; `App` mirrors this into React state. */
|
|
68
84
|
onHoldChange(listener: (hold: boolean) => void): () => void;
|
|
69
85
|
/** Called by `App` for key / resize / overlay / clear, and by `onInvalidate`. */
|