@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/README.md CHANGED
@@ -432,14 +432,14 @@ completes, `Up` / `Down` moves the selection, `Esc` closes it.
432
432
 
433
433
  | Key | Action |
434
434
  | --- | --- |
435
- | `Enter` | Submit (idle) / queue a steering message (running) |
436
- | `Alt+Enter` / `Shift+Enter` | Insert a newline |
435
+ | `Enter` | Submit (idle) / queue a steering message (running) — shown as `Queue: …` until the agent picks it up |
436
+ | `Ctrl+J` / `Alt+Enter` / `Shift+Enter` | Insert a newline. `Ctrl+J` and `Alt+Enter` work everywhere; a distinct `Shift+Enter` needs a one-line terminal binding — see [`/terminal-setup`](#terminal-setup) |
437
437
  | `Shift+Tab` | Toggle **plan mode** (`BUILD` <-> `PLAN`). Equivalent: `/plan`. On Windows this needs a console flag Node < 22.17 does not set; aragon sets it at startup — see [Requirements](#requirements) |
438
438
  | `Ctrl+P` | The same toggle, over a channel no Windows console can swallow. Use it if `Shift+Tab` does nothing |
439
439
  | `Esc` | Close an overlay or popup; otherwise ask for interruption confirmation. While idle, cancel pending auto-continuation |
440
440
  | `Esc` ×2 | Interrupt the run when pressed within 1.5 seconds. Other keys or closing a menu cancel the first press |
441
441
  | `Esc` again after interruption | **Force-stop** a run that is still stopping, kill foreground shell commands and return to `idle`; background services remain running |
442
- | `Ctrl+C` | Stop the background **services** the agent started, when there are any. It does not arm exit |
442
+ | `Ctrl+C` | **Copy the selection** when a drag-selection is waiting (release no longer copies by itself). Otherwise: stop the background **services** the agent started, when there are any. It does not arm exit |
443
443
  | `Ctrl+C` ×2 | Exit (first press warns), once no services are running |
444
444
  | `Ctrl+L` | Redraw the frame |
445
445
  | `Ctrl+T` | Show / hide thinking (**off by default**) for the entire session |
@@ -474,10 +474,12 @@ open. It never touches your draft, and it never recalls prompt history: that is
474
474
  Two things follow from the app owning the mouse, and they are worth knowing
475
475
  before you meet them:
476
476
 
477
- - **Dragging text selects and copies it when mouse selection is enabled.**
478
- Dragging the last-column scrollbar scrolls instead. `--no-mouse-select` disables
479
- text selection while preserving scrollbar dragging. `Shift`+drag uses the
480
- terminal's native selection on hosts that support that convention.
477
+ - **Dragging selects; `Ctrl+C` copies.** Releasing the mouse keeps the
478
+ highlight (and freezes the rows under it) until you commit: `Ctrl+C` over a
479
+ pending selection copies it and never arms exit; any other key drops it.
480
+ Dragging the last-column scrollbar scrolls instead. `--no-mouse-select`
481
+ disables text selection while preserving scrollbar dragging. `Shift`+drag
482
+ uses the terminal's native selection on hosts that support that convention.
481
483
  - **`tmux` with `mouse on` keeps the wheel for itself**, so the app never sees
482
484
  it. `set -g mouse off` hands it back.
483
485
  - **A Windows console on Node below 22.17.0 cannot report the wheel at all**,
@@ -489,6 +491,48 @@ Turn the whole thing off with `--no-mouse`, `ARAGON_MOUSE=0`, or
489
491
  nothing in full-screen mode — it does not fall back to editing your draft, which
490
492
  is what it used to do. Inline mode is unaffected either way.
491
493
 
494
+ ## Terminal setup for Shift+Enter
495
+
496
+ `Ctrl+J` and `Alt+Enter` insert a newline in every terminal with no setup. A
497
+ dedicated `Shift+Enter` key needs the terminal to send the CSI-u sequence
498
+ `\u001b[13;2u` instead of a plain `Enter` — run `/terminal-setup` inside aragon
499
+ for copy-paste bindings per terminal:
500
+
501
+ - **Windows Terminal** — `settings.json`, `actions`:
502
+ `{ "keys": "shift+enter", "command": { "action": "sendInput", "input": "\u001b[13;2u" } }`
503
+ - **VS Code integrated terminal** — `keybindings.json`:
504
+ `{ "key": "shift+enter", "command": "workbench.action.terminal.sendSequence", "args": { "text": "\u001b[13;2u" } }`
505
+ - **iTerm2** — Settings > Profiles > Keys > Key Mappings, `Shift+Enter` ->
506
+ *Send Escape Sequence* `[13;2u`
507
+ - **kitty / WezTerm / foot** — native once keyboard-enhancement mode is on
508
+ (kitty: `map shift+enter send_text all \u001b[13;2u`)
509
+ - **Alacritty** — `[keyboard]` bindings in `alacritty.toml`:
510
+ `{ key = "Enter", mods = "Shift", chars = "\u001b[13;2u" }`
511
+ - **macOS Terminal.app / legacy conhost** — cannot rebind `Enter`; use
512
+ `Ctrl+J` or `Alt+Enter`
513
+
514
+ aragon deliberately does not push kitty keyboard mode or `modifyOtherKeys=2`
515
+ onto the terminal itself: mode 2 re-encodes *every* modified key as CSI-u and
516
+ would blind this app's (and Ink's) key parsing for the whole session. One
517
+ user-side binding reaches the same result for the one key that needs it.
518
+
519
+ ## Queued messages while the agent runs
520
+
521
+ Sending a message while a run is in flight **queues** it for the agent's next
522
+ turn. The transcript shows the queue honestly:
523
+
524
+ ```
525
+ ◷ Queue: also check the tests
526
+ ```
527
+
528
+ The row stays until Core actually accepts the message into the conversation —
529
+ the next `turn_start` — at which point it is rewritten **in place** into the
530
+ user message it became, followed by the answer. Interrupting the run does not
531
+ clear it: the message really is still queued, and it will be picked up when
532
+ you continue the session. Messages still queued when the session is saved are
533
+ restored as `Queued but never sent: …` warnings, because the in-memory queue
534
+ does not survive a restart.
535
+
492
536
  ## Themes
493
537
 
494
538
  Three palettes plus `auto`:
@@ -540,12 +584,12 @@ beside it (it takes the place of the idle hint row below the box, so starting a
540
584
  run never moves the layout):
541
585
 
542
586
  ```
543
- ⠋ Percolating… · ⏎ steer · esc×2 interrupt · ctrl+c×2 exit PLAN
587
+ ⠋ Percolating… · ⏎ queue · esc×2 interrupt · ctrl+c×2 exit PLAN
544
588
  ```
545
589
 
546
590
  The row scrolls with the input box. On a narrow terminal whole clauses are dropped
547
591
  from the end (`exit` first) and only the phrase is shortened with an ellipsis, so
548
- `steer` and `interrupt` are never cut mid-word. When you scroll history until the
592
+ `queue` and `interrupt` are never cut mid-word. When you scroll history until the
549
593
  input box is out of view, the animation moves to the fixed row at the bottom, so
550
594
  there is always exactly one spinner on screen. Terminals shorter than 20 rows, and
551
595
  `--no-hints`, keep the animation on that fixed row instead.
@@ -36,6 +36,21 @@ export type Entry = {
36
36
  id: string;
37
37
  kind: 'user';
38
38
  text: string;
39
+ }
40
+ /**
41
+ * A message submitted while a run was in flight (tui-shift-enter-copy-queue
42
+ * 5.2). It renders as `Queue: <first line>` and is rewritten IN PLACE, same
43
+ * id, into the `user` entry above by the `turnStart` that proves Core
44
+ * accepted it -- so the id-based height cache re-measures rather than
45
+ * freezes, and the settled boundary (positional, monotonic) never moves.
46
+ *
47
+ * NOT A LIVE TARGET: no id pointer in `ViewState` names it, so the trim
48
+ * ring and the `appendEntry` assertions need no new member.
49
+ */
50
+ | {
51
+ id: string;
52
+ kind: 'queued';
53
+ text: string;
39
54
  } | {
40
55
  id: string;
41
56
  kind: 'assistant';
@@ -379,6 +394,15 @@ export declare function initialViewState(seed?: ViewStateSeed): ViewState;
379
394
  export type ViewAction = {
380
395
  type: 'submit';
381
396
  text: string;
397
+ }
398
+ /**
399
+ * `controller.steer` was accepted; append the persistent queue marker
400
+ * (5.2.2). Dispatched from exactly one place -- `App.submitMessage`'s
401
+ * running branch -- beside the `controller.steer` call it mirrors.
402
+ */
403
+ | {
404
+ type: 'steerQueued';
405
+ text: string;
382
406
  } | {
383
407
  type: 'runStart';
384
408
  } | {
@@ -289,6 +289,29 @@ function nextId(state) {
289
289
  function mapEntry(entries, id, fn) {
290
290
  return entries.map((e) => (e.id === id ? fn(e) : e));
291
291
  }
292
+ /**
293
+ * Rewrite every `queued` entry in place as the user message it became
294
+ * (tui-shift-enter-copy-queue 5.2.3).
295
+ *
296
+ * SAME ID, NEW OBJECT -- the `mapEntry` discipline for a non-append change:
297
+ * the position (and therefore the settled boundary, which advances by
298
+ * POSITION and never by identity) is untouched, while the new object
299
+ * reference is what lets `React.memo` and the height cache see that this
300
+ * one row changed. ALL of them at once, because `drainSteering` is a full
301
+ * drain: converting only some would show a partial acceptance that never
302
+ * happened. Identity-preserving when nothing is queued -- `turnStart` fires
303
+ * every turn, and this must stay free for the common case.
304
+ */
305
+ function convertQueuedEntries(entries) {
306
+ let touched = false;
307
+ const next = entries.map((e) => {
308
+ if (e.kind !== 'queued')
309
+ return e;
310
+ touched = true;
311
+ return { id: e.id, kind: 'user', text: e.text };
312
+ });
313
+ return touched ? next : entries;
314
+ }
292
315
  /**
293
316
  * Seal `thinkingMs`, once, as a spreadable patch (§3.1.2).
294
317
  *
@@ -425,6 +448,15 @@ export function viewReducer(state, action) {
425
448
  aborted: false,
426
449
  };
427
450
  }
451
+ case 'steerQueued': {
452
+ const { id, seq } = nextId(state);
453
+ const entry = { id, kind: 'queued', text: action.text };
454
+ // NOTHING ELSE RESETS. This is not a new turn -- `turnProduced`,
455
+ // `errorNoticed` and `aborted` describe the run that is still in
456
+ // flight, and clearing them here would let the next `runEnd` failure
457
+ // guard misfire on a turn that produced plenty.
458
+ return { ...state, seq, ...appendEntry(state, state.entries, entry) };
459
+ }
428
460
  case 'runStart':
429
461
  return {
430
462
  ...state,
@@ -435,6 +467,14 @@ export function viewReducer(state, action) {
435
467
  };
436
468
  case 'turnStart': {
437
469
  const { id, seq } = nextId(state);
470
+ // CONVERT, THEN APPEND, IN THAT ORDER AND IN THIS ONE ACTION
471
+ // (5.2.3): `turn_start` is the authoritative "the steering queue was
472
+ // drained into the conversation" signal (the loop drains at its top,
473
+ // immediately before emitting it), so the user message and the answer
474
+ // that follows it must land in the same commit, in render order, or a
475
+ // frame can exist where the message has vanished and the answer has
476
+ // not appeared.
477
+ const entries = convertQueuedEntries(state.entries);
438
478
  const entry = {
439
479
  id,
440
480
  kind: 'assistant',
@@ -443,7 +483,7 @@ export function viewReducer(state, action) {
443
483
  thinkingOpen: false,
444
484
  streaming: true,
445
485
  };
446
- return { ...state, seq, ...appendEntry(state, state.entries, entry), streamingId: id };
486
+ return { ...state, seq, ...appendEntry(state, entries, entry), streamingId: id };
447
487
  }
448
488
  case 'thinkingStart': {
449
489
  if (!state.streamingId)
package/dist/cli.js CHANGED
@@ -56,7 +56,6 @@ import { renderTranscriptText } from './ui/transcript-text.js';
56
56
  import { detectCapabilities } from './ui/capabilities.js';
57
57
  import { pickGlyphs } from './ui/glyphs.js';
58
58
  import { getTheme } from './ui/theme.js';
59
- import { copyText } from './ui/clipboard.js';
60
59
  import { createSelectionController, } from './ui/selection/selection-controller.js';
61
60
  import { DENY_ALL_HUMAN_INPUT } from './tools/human-input.js';
62
61
  // A VALUE import, and the one deliberate exception to the paragraph above
@@ -630,10 +629,10 @@ function runInteractive(flags, extras = {}) {
630
629
  // correct. That is rung 2 of the fail-safe ladder doing its job.
631
630
  repaint: () => frameWriter?.repaint() ?? false,
632
631
  requestRedraw: () => selectionBridge.requestRedraw?.(),
633
- // `?? undefined` and not a wrapper: `copyText` skips OSC 52 entirely when
634
- // it has no door, and reports `'native'`/`'none'` accordingly.
635
- copy: (text) => copyText(text, writeForeign ?? undefined),
636
- onCopied: (via, lines, chars) => selectionBridge.onCopied?.(via, lines, chars),
632
+ // The copy decision moved UP to `App` (tui-shift-enter-copy-queue
633
+ // 4.2.4): Ctrl+C with a settled selection is routed there, through
634
+ // the SAME `copyText` door and the SAME bridge `onCopied` toast
635
+ // funnel -- the controller keeps only the selection state machine.
637
636
  // Read at PAINT time, not captured: `/theme` rebuilds the theme mid-session.
638
637
  theme: () => getTheme(config.theme, detectCapabilities(process.env, process.stdout)),
639
638
  caps: detectCapabilities(process.env, process.stdout),
@@ -1412,7 +1411,7 @@ function buildProgram() {
1412
1411
  .option('--no-live-tool-output', 'Keep a running tool card to one line')
1413
1412
  .option('--mouse', 'Wheel scrolls the transcript (full-screen mode)')
1414
1413
  .option('--no-mouse', 'Leave the mouse to the terminal (the wheel does nothing)')
1415
- .option('--mouse-select', 'Drag with the mouse to select text; releasing copies it')
1414
+ .option('--mouse-select', 'Drag with the mouse to select text; Ctrl+C copies it')
1416
1415
  .option('--no-mouse-select', 'Keep wheel scrolling, but leave drag-select off')
1417
1416
  // Positive form FIRST, same tri-state reason as `--mouse-select` above:
1418
1417
  // `paste` is persisted and defaults to `true`, so a lone `--no-paste` would
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Built-in slash commands (spec §5.2):
3
3
  * /help /model /settings /thinking /max-tokens /tools /clear /reset /cwd /save
4
- * /resume /copy /exit (/quit), plus the /team, /fast and /todo subsystems.
4
+ * /resume /copy /terminal-setup /exit (/quit), plus the /team, /fast and
5
+ * /todo subsystems.
5
6
  */
6
7
  import { CommandRegistry } from './registry.js';
7
8
  export declare function registerBuiltinCommands(registry: CommandRegistry): void;
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Built-in slash commands (spec §5.2):
3
3
  * /help /model /settings /thinking /max-tokens /tools /clear /reset /cwd /save
4
- * /resume /copy /exit (/quit), plus the /team, /fast and /todo subsystems.
4
+ * /resume /copy /terminal-setup /exit (/quit), plus the /team, /fast and
5
+ * /todo subsystems.
5
6
  */
6
7
  import { existsSync, statSync } from 'node:fs';
7
8
  import { isAbsolute, resolve } from 'node:path';
@@ -1013,7 +1014,10 @@ const COMMANDS = [
1013
1014
  const captured = port.captured();
1014
1015
  const lines = [
1015
1016
  `Mouse capture: ${captured ? 'on' : 'off'}`,
1016
- ` drag-select ${port.selectEnabled() ? 'on' : 'off (mouseSelect is false)'}`,
1017
+ // The wording follows tui-shift-enter-copy-queue 4.4: releasing no
1018
+ // longer copies, so this row must name the commit key -- leaving the
1019
+ // bare `on` would keep teaching the gesture that was removed.
1020
+ ` drag-select ${port.selectEnabled() ? 'on (drag to select, ctrl+c to copy)' : 'off (mouseSelect is false)'}`,
1017
1021
  ' /mouse off hand the mouse to your terminal for this session',
1018
1022
  ' /mouse on take it back',
1019
1023
  ' --no-mouse never capture it (also ARAGON_MOUSE=0)',
@@ -1022,6 +1026,47 @@ const COMMANDS = [
1022
1026
  ctx.notify('info', lines.join('\n'));
1023
1027
  },
1024
1028
  },
1029
+ {
1030
+ name: 'terminal-setup',
1031
+ description: 'How to make Shift+Enter send a newline in your terminal',
1032
+ run: (ctx) => {
1033
+ // READ-ONLY BY DESIGN (3.6): pushing kitty keyboard mode or
1034
+ // modifyOtherKeys=2 would re-encode EVERY modified key as CSI-u and
1035
+ // blind Ink's parser for the whole session. One user-side binding
1036
+ // reaches the same result for the one key that needs it.
1037
+ //
1038
+ // Ctrl+J and Alt+Enter work with no setup at all; CSI-u terminals
1039
+ // (kitty / WezTerm / foot with keyboard enhancement on) need none
1040
+ // either. The lines below are for the terminals that send a plain
1041
+ // CR for Shift+Enter and CAN be configured.
1042
+ const lines = [
1043
+ 'Shift+Enter newline - terminal setup',
1044
+ '',
1045
+ 'Ctrl+J and Alt+Enter insert a newline in every terminal, with no',
1046
+ 'setup. To get a dedicated Shift+Enter key, make the terminal send',
1047
+ 'the CSI-u sequence \\u001b[13;2u for it:',
1048
+ '',
1049
+ ' Windows Terminal settings.json, actions array:',
1050
+ ' { "keys": "shift+enter", "command": { "action": "sendInput",',
1051
+ ' "input": "\\u001b[13;2u" } }',
1052
+ ' VS Code terminal keybindings.json:',
1053
+ ' { "key": "shift+enter",',
1054
+ ' "command": "workbench.action.terminal.sendSequence",',
1055
+ ' "args": { "text": "\\u001b[13;2u" } }',
1056
+ ' iTerm2 Settings > Profiles > Keys > Key Mappings:',
1057
+ ' Shift+Enter -> Send Escape Sequence: [13;2u',
1058
+ ' kitty/WezTerm/foot native once keyboard enhancement is on',
1059
+ ' (kitty: map shift+enter send_text all \\u001b[13;2u)',
1060
+ ' Alacritty alacritty.toml, [keyboard] bindings:',
1061
+ ' { key = "Enter", mods = "Shift", chars = "\\u001b[13;2u" }',
1062
+ ' macOS Terminal.app / conhost cannot rebind Enter; use Ctrl+J',
1063
+ ' or Alt+Enter',
1064
+ '',
1065
+ 'This command only prints advice; it changes no terminal modes.',
1066
+ ];
1067
+ ctx.notify('info', lines.join('\n'));
1068
+ },
1069
+ },
1025
1070
  {
1026
1071
  name: 'logs',
1027
1072
  description: 'Show where this session is being logged',
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Enter-family sequence recognition (tui-shift-enter-copy-queue §3.3) -- pure,
3
+ * stream-free, no node/React imports, callable from any test environment.
4
+ *
5
+ * WHY THIS LIVES BELOW INK. Ink 5's `parse-keypress.js` calls `parseKeypress`
6
+ * ONCE per stdin chunk and hard-codes `shift: false` for the exact string
7
+ * `'\r'`, so a component can never see `key.return && key.shift` -- the
8
+ * `PromptInput` branch for it is dead code. Worse, terminals that DO encode a
9
+ * distinct Shift+Enter send CSI-u (`\x1b[13;2u`), which Ink's `fnKeyRe` does
10
+ * not match: the ESC is stripped by `use-input.js` and `'[13;2u'` falls through
11
+ * to the PRINTABLE branch, typing garbage into the draft. The only layer that
12
+ * sees the bytes before Ink mangles them is the stdin filter, which is where
13
+ * the mouse-report and paste-marker dialects are already translated.
14
+ *
15
+ * THE CONTRACT: rewrite every recognized sequence to ONE normalized token --
16
+ * `ENTER_NEWLINE_FRAME` for "this was a modified Enter, the user wants a
17
+ * newline" and `'\r'` for the unmodified CSI-u Enter some other program left
18
+ * behind. No state, no partial-output: `rewriteEnterSequences` is idempotent
19
+ * because its output contains none of its inputs.
20
+ */
21
+ /** What one recognized sequence means, for tests and `/terminal-setup` copy. */
22
+ export interface EnterSequenceSpec {
23
+ /** The exact byte sequence a terminal sends for this key. */
24
+ readonly seq: string;
25
+ /** The chord it names, e.g. `'shift+enter'`, `'ctrl+alt+enter'`, `'enter'`. */
26
+ readonly meaning: string;
27
+ /** What `rewriteEnterSequences` replaces it with. */
28
+ readonly to: string;
29
+ }
30
+ /**
31
+ * The recognition table (§3.3). CSI-u modifiers 2-8 cover Shift(2), Alt(3),
32
+ * Shift+Alt(4), Ctrl(5), Ctrl+Shift(6), Ctrl+Alt(7), Ctrl+Alt+Shift(8) as
33
+ * emitted by kitty/WezTerm keyboard-enhancement mode and by
34
+ * `modifyOtherKeys=2`; every one of them means "Enter with a modifier", and a
35
+ * modified Enter must NEVER submit the draft. `\x1b\r` / `\x1b\n` are the
36
+ * classic Alt+Enter encodings. `\x1b[13u` (no modifier field) is what a
37
+ * leftover kitty mode sends for a PLAIN Enter: it must submit, so it maps to
38
+ * `'\r'` rather than to a newline.
39
+ */
40
+ export declare const ENTER_SEQUENCES: readonly EnterSequenceSpec[];
41
+ /**
42
+ * Rewrite every recognized Enter-family sequence to its normalized token.
43
+ *
44
+ * ONE ANCHORED REGEX, not a per-sequence scan and not a state machine: the
45
+ * CSI-u family shares one shape and the two literal Alt+Enter encodings cannot
46
+ * nest inside it, so a single left-to-right pass is unambiguous. `\x1b[13;9u`
47
+ * and other unknown modifiers are deliberately left untouched -- rewriting an
48
+ * unrecognized sequence would be guessing at a terminal dialect we have not
49
+ * seen.
50
+ */
51
+ export declare function rewriteEnterSequences(text: string): string;
52
+ /**
53
+ * Length of the trailing run of `text` that is a proper prefix of an
54
+ * Enter-family sequence, or 0.
55
+ *
56
+ * LONGEST MATCH, not shortest: unlike the mouse and paste families, a torn
57
+ * CSI-u sequence diverges from its siblings only at the LAST byte
58
+ * (`\x1b[13;` + `2u`), so every byte before the divergence point must be held
59
+ * back for the next chunk or the sequence is destroyed a byte early. Feeding
60
+ * the result into the filter's `keep = max(...)` alongside the other two
61
+ * families keeps every family's hold intact.
62
+ */
63
+ export declare function trailingEnterPrefixLength(text: string): number;
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Enter-family sequence recognition (tui-shift-enter-copy-queue §3.3) -- pure,
3
+ * stream-free, no node/React imports, callable from any test environment.
4
+ *
5
+ * WHY THIS LIVES BELOW INK. Ink 5's `parse-keypress.js` calls `parseKeypress`
6
+ * ONCE per stdin chunk and hard-codes `shift: false` for the exact string
7
+ * `'\r'`, so a component can never see `key.return && key.shift` -- the
8
+ * `PromptInput` branch for it is dead code. Worse, terminals that DO encode a
9
+ * distinct Shift+Enter send CSI-u (`\x1b[13;2u`), which Ink's `fnKeyRe` does
10
+ * not match: the ESC is stripped by `use-input.js` and `'[13;2u'` falls through
11
+ * to the PRINTABLE branch, typing garbage into the draft. The only layer that
12
+ * sees the bytes before Ink mangles them is the stdin filter, which is where
13
+ * the mouse-report and paste-marker dialects are already translated.
14
+ *
15
+ * THE CONTRACT: rewrite every recognized sequence to ONE normalized token --
16
+ * `ENTER_NEWLINE_FRAME` for "this was a modified Enter, the user wants a
17
+ * newline" and `'\r'` for the unmodified CSI-u Enter some other program left
18
+ * behind. No state, no partial-output: `rewriteEnterSequences` is idempotent
19
+ * because its output contains none of its inputs.
20
+ */
21
+ import { ENTER_NEWLINE_FRAME } from './limits.js';
22
+ /**
23
+ * The recognition table (§3.3). CSI-u modifiers 2-8 cover Shift(2), Alt(3),
24
+ * Shift+Alt(4), Ctrl(5), Ctrl+Shift(6), Ctrl+Alt(7), Ctrl+Alt+Shift(8) as
25
+ * emitted by kitty/WezTerm keyboard-enhancement mode and by
26
+ * `modifyOtherKeys=2`; every one of them means "Enter with a modifier", and a
27
+ * modified Enter must NEVER submit the draft. `\x1b\r` / `\x1b\n` are the
28
+ * classic Alt+Enter encodings. `\x1b[13u` (no modifier field) is what a
29
+ * leftover kitty mode sends for a PLAIN Enter: it must submit, so it maps to
30
+ * `'\r'` rather than to a newline.
31
+ */
32
+ export const ENTER_SEQUENCES = [
33
+ {
34
+ seq: '\x1b[13;2u',
35
+ meaning: 'shift+enter',
36
+ to: ENTER_NEWLINE_FRAME,
37
+ },
38
+ {
39
+ seq: '\x1b[13;3u',
40
+ meaning: 'alt+enter',
41
+ to: ENTER_NEWLINE_FRAME,
42
+ },
43
+ {
44
+ seq: '\x1b[13;4u',
45
+ meaning: 'shift+alt+enter',
46
+ to: ENTER_NEWLINE_FRAME,
47
+ },
48
+ {
49
+ seq: '\x1b[13;5u',
50
+ meaning: 'ctrl+enter',
51
+ to: ENTER_NEWLINE_FRAME,
52
+ },
53
+ {
54
+ seq: '\x1b[13;6u',
55
+ meaning: 'ctrl+shift+enter',
56
+ to: ENTER_NEWLINE_FRAME,
57
+ },
58
+ {
59
+ seq: '\x1b[13;7u',
60
+ meaning: 'ctrl+alt+enter',
61
+ to: ENTER_NEWLINE_FRAME,
62
+ },
63
+ {
64
+ seq: '\x1b[13;8u',
65
+ meaning: 'ctrl+alt+shift+enter',
66
+ to: ENTER_NEWLINE_FRAME,
67
+ },
68
+ {
69
+ seq: '\x1b\r',
70
+ meaning: 'alt+enter',
71
+ to: ENTER_NEWLINE_FRAME,
72
+ },
73
+ {
74
+ seq: '\x1b\n',
75
+ meaning: 'alt+enter',
76
+ to: ENTER_NEWLINE_FRAME,
77
+ },
78
+ {
79
+ seq: '\x1b[13u',
80
+ meaning: 'enter',
81
+ to: '\r',
82
+ },
83
+ ];
84
+ /**
85
+ * Longest strict prefix any recognized sequence can have. Derived from the
86
+ * table rather than hard-coded so adding a sequence can never leave the hold
87
+ * ceiling behind: the longest sequence is 7 bytes, so a torn sequence can hold
88
+ * up to 6 of them.
89
+ */
90
+ const MAX_ENTER_PREFIX = Math.max(...ENTER_SEQUENCES.map((s) => s.seq.length)) - 1;
91
+ /** `\x1b[13;2u` .. `\x1b[13;8u`, or `\x1b[13u` when the modifier is absent. */
92
+ const CSI_U_ENTER = /\x1b\[13(?:;[2-8])?u/g;
93
+ /**
94
+ * Rewrite every recognized Enter-family sequence to its normalized token.
95
+ *
96
+ * ONE ANCHORED REGEX, not a per-sequence scan and not a state machine: the
97
+ * CSI-u family shares one shape and the two literal Alt+Enter encodings cannot
98
+ * nest inside it, so a single left-to-right pass is unambiguous. `\x1b[13;9u`
99
+ * and other unknown modifiers are deliberately left untouched -- rewriting an
100
+ * unrecognized sequence would be guessing at a terminal dialect we have not
101
+ * seen.
102
+ */
103
+ export function rewriteEnterSequences(text) {
104
+ if (!text.includes('\x1b'))
105
+ return text;
106
+ // ONE PASS, TWO TARGETS: a MODIFIED Enter is a newline intent, while the
107
+ // modifier-less CSI-u Enter a leftover kitty mode emits is a PLAIN Enter
108
+ // and must still submit (`\r`). The `;` is the discriminator the table
109
+ // above records in each entry's `to` field.
110
+ let out = text.replace(CSI_U_ENTER, (seq) => (seq.includes(';') ? ENTER_NEWLINE_FRAME : '\r'));
111
+ // Only the two literal Alt+Enter encodings remain. `\x1b\n` is checked
112
+ // before `\x1b\r` is replaced; they share no bytes, so order is free, but
113
+ // splitting the escapes keeps each replacement self-evident.
114
+ if (out.includes('\x1b\n'))
115
+ out = out.split('\x1b\n').join(ENTER_NEWLINE_FRAME);
116
+ if (out.includes('\x1b\r'))
117
+ out = out.split('\x1b\r').join(ENTER_NEWLINE_FRAME);
118
+ return out;
119
+ }
120
+ /** True when `s` is a PROPER prefix of some recognized sequence (never whole). */
121
+ function isEnterPrefix(s) {
122
+ if (s.length === 0 || s.length > MAX_ENTER_PREFIX)
123
+ return false;
124
+ for (const spec of ENTER_SEQUENCES) {
125
+ // `length >`, not `startsWith` alone: `startsWith` accepts the WHOLE
126
+ // sequence too, and holding a complete sequence back would add 12 ms
127
+ // of latency to a key that is ready to be rewritten right now.
128
+ if (spec.seq.length > s.length && spec.seq.startsWith(s))
129
+ return true;
130
+ }
131
+ return false;
132
+ }
133
+ /**
134
+ * Length of the trailing run of `text` that is a proper prefix of an
135
+ * Enter-family sequence, or 0.
136
+ *
137
+ * LONGEST MATCH, not shortest: unlike the mouse and paste families, a torn
138
+ * CSI-u sequence diverges from its siblings only at the LAST byte
139
+ * (`\x1b[13;` + `2u`), so every byte before the divergence point must be held
140
+ * back for the next chunk or the sequence is destroyed a byte early. Feeding
141
+ * the result into the filter's `keep = max(...)` alongside the other two
142
+ * families keeps every family's hold intact.
143
+ */
144
+ export function trailingEnterPrefixLength(text) {
145
+ const max = Math.min(text.length, MAX_ENTER_PREFIX);
146
+ for (let k = max; k >= 1; k -= 1) {
147
+ if (isEnterPrefix(text.slice(text.length - k)))
148
+ return k;
149
+ }
150
+ return 0;
151
+ }
@@ -53,6 +53,17 @@ export declare const PASTE_ASSEMBLY_MAX_MS = 2000;
53
53
  */
54
54
  export declare const PASTE_OPEN = "\0[";
55
55
  export declare const PASTE_CLOSE = "\0]";
56
+ /**
57
+ * The inline frame that carries ONE newline intent from the stdin filter to the
58
+ * composer (tui-shift-enter-copy-queue 3.2).
59
+ *
60
+ * SAME FAMILY AS `PASTE_OPEN`, with the same guarantee and for the same reason:
61
+ * NUL is the delimiter because no terminal delivers it for any key, and because
62
+ * `sanitisePaste` strips every NUL out of every payload -- so pasted text can
63
+ * never forge this frame either. A newline intent is single, atomic and carries
64
+ * no payload, so one constant replaces the OPEN/CLOSE pair.
65
+ */
66
+ export declare const ENTER_NEWLINE_FRAME = "\0n";
56
67
  /**
57
68
  * The one channel from the stdin filter to `App`'s notice dispatch (P1-3 / I-15).
58
69
  *
@@ -53,6 +53,17 @@ export const PASTE_ASSEMBLY_MAX_MS = 2_000;
53
53
  */
54
54
  export const PASTE_OPEN = '\u0000[';
55
55
  export const PASTE_CLOSE = '\u0000]';
56
+ /**
57
+ * The inline frame that carries ONE newline intent from the stdin filter to the
58
+ * composer (tui-shift-enter-copy-queue 3.2).
59
+ *
60
+ * SAME FAMILY AS `PASTE_OPEN`, with the same guarantee and for the same reason:
61
+ * NUL is the delimiter because no terminal delivers it for any key, and because
62
+ * `sanitisePaste` strips every NUL out of every payload -- so pasted text can
63
+ * never forge this frame either. A newline intent is single, atomic and carries
64
+ * no payload, so one constant replaces the OPEN/CLOSE pair.
65
+ */
66
+ export const ENTER_NEWLINE_FRAME = '\u0000n';
56
67
  /** Human-readable byte size for the refusal toast (`4.1 MB`, `812.0 KB`, `7 B`). */
57
68
  export function formatPasteSize(bytes) {
58
69
  if (bytes >= 1024 * 1024)
@@ -35,6 +35,7 @@ import { PassThrough } from 'node:stream';
35
35
  import { MAX_PENDING_MOUSE_CHARS, splitMouseEvents, isMousePrefix, } from './mouse-events.js';
36
36
  import { PASTE_ASSEMBLY_MAX_MS, PASTE_BURST_MS, PASTE_CLOSE, PASTE_MAX_BYTES, PASTE_OPEN, formatPasteSize, } from './limits.js';
37
37
  import { PASTE_BEGIN_MARK, PASTE_END_MARK, classifyChunk, sanitisePaste, trailingPastePrefixLength, } from './paste-parse.js';
38
+ import { rewriteEnterSequences, trailingEnterPrefixLength } from './enter-sequences.js';
38
39
  /**
39
40
  * How long a possible-prefix tail is held before being flushed as text.
40
41
  *
@@ -192,6 +193,17 @@ export function createStdinFilter(real, features, bridge, onWarn) {
192
193
  const handleOutsidePaste = (text, holdTail) => {
193
194
  if (text.length === 0)
194
195
  return;
196
+ // ENTER-FAMILY REWRITE, AND IT IS FIRST FOR A REASON
197
+ // (tui-shift-enter-copy-queue 3.4). The Alt+Enter encoding contains a CR,
198
+ // and `classifyChunk`'s primary rule -- "longer than one character AND
199
+ // contains a line break" -- would buffer that two-byte key as a Tier 2
200
+ // paste body whose sanitiser then strips the ESC, silently swallowing the
201
+ // keystroke. After the rewrite the frame is NUL-led and classifies as
202
+ // `keys` under both rules. The cost is that an UNBRACKETED paste whose
203
+ // body happens to contain the literal Alt+Enter bytes loses that one
204
+ // pair's newline; a bracketed body never reaches this function at all
205
+ // (I-3), and losing two bytes of a heuristic tier beats eating the key.
206
+ text = rewriteEnterSequences(text);
195
207
  if (paste.kind === 'burst') {
196
208
  paste = { kind: 'burst', body: paste.body + text, bytes: paste.bytes + byteLength(text) };
197
209
  burstTimer = clearTimer(burstTimer);
@@ -266,7 +278,13 @@ export function createStdinFilter(real, features, bridge, onWarn) {
266
278
  const strayEnd = rest.indexOf(PASTE_END_MARK);
267
279
  const first = begin === -1 ? strayEnd : strayEnd === -1 ? begin : Math.min(begin, strayEnd);
268
280
  if (first === -1) {
269
- const keep = Math.max(trailingPastePrefixLength(rest), features.mouse ? trailingMousePrefixLength(rest) : 0);
281
+ // THIRD PREFIX FAMILY: a torn CSI-u Enter sequence
282
+ // (tui-shift-enter-copy-queue 3.4). `keep = max(...)` holds the longest
283
+ // tail any family might still complete; the three families share only
284
+ // the ESC / ESC-[ head bytes, which the max keeps for whoever needs
285
+ // them. The existing 12 ms flush and the 32-char ceiling bound the
286
+ // hold, so no new timer is needed.
287
+ const keep = Math.max(trailingPastePrefixLength(rest), features.mouse ? trailingMousePrefixLength(rest) : 0, trailingEnterPrefixLength(rest));
270
288
  const tail = rest.slice(rest.length - keep);
271
289
  handleOutsidePaste(rest.slice(0, rest.length - keep), true);
272
290
  pending += tail;
@@ -193,6 +193,26 @@ export function normalizeLoadedEntries(entries) {
193
193
  (entry.status === 'starting' || entry.status === 'ready' || entry.status === 'running')) {
194
194
  return { ...entry, status: 'stopped', rows: [] };
195
195
  }
196
+ // THE SEVENTH CLAUSE (tui-shift-enter-copy-queue 5.2.5). Core's steering
197
+ // queue is memory-only, so a `queued` entry on disk is a promise this
198
+ // process can no longer keep: the message was never accepted into any
199
+ // conversation. Downgrade to a WARN NOTICE carrying the first line
200
+ // rather than to a `user` entry -- pretending it was sent is the lie
201
+ // that loses the user's words with a straight face.
202
+ //
203
+ // THE ID IS KEPT: `restoreEntries` splices these positions into the
204
+ // live transcript, and a fresh id here would collide with the next
205
+ // `e<n>` the reducer allocates.
206
+ if (entry.kind === 'queued') {
207
+ const firstLine = entry.text.split('\n', 1)[0] ?? '';
208
+ const downgraded = {
209
+ id: entry.id,
210
+ kind: 'notice',
211
+ level: 'warn',
212
+ text: `Queued but never sent: ${firstLine}`,
213
+ };
214
+ return downgraded;
215
+ }
196
216
  return entry;
197
217
  });
198
218
  }