@aragon-agent/cli 0.6.9 → 0.6.11

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/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
- toast('info', 'Steering queued.');
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 + steer/interrupt hint) replaces the idle
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.
@@ -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 `steer / interrupt / exit` clauses on ONE row above the input box.
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
  */
@@ -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
- const clauses = [`${glyphs.enterKey} steer`, `esc${glyphs.times}2 interrupt`];
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}`);
@@ -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
- if (hasPasteFrame(input)) {
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 steer the run, Esc twice to interrupt${glyphs.ellipsis}`
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}`;
@@ -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);
@@ -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 steer / interrupt keys, above the input during a run'],
31
- ['Drag text (left button)', 'Select text on screen; releasing copies it'],
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
- setFeedback(feedback + stripPasteFrames(input));
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
- if (input && !key.ctrl && !key.meta && !key.tab)
129
- setDraft(draft + stripPasteFrames(input));
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
- const text = stripPasteFrames(input);
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 the
4
- * copy-on-release, and it reaches the React tree only through the two callbacks
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`. */