@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 +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/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` |
|
|
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
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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… · ⏎
|
|
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
|
-
`
|
|
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.
|
package/dist/agent/reducer.d.ts
CHANGED
|
@@ -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
|
} | {
|
package/dist/agent/reducer.js
CHANGED
|
@@ -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,
|
|
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
|
-
//
|
|
634
|
-
//
|
|
635
|
-
|
|
636
|
-
|
|
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;
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/input/limits.d.ts
CHANGED
|
@@ -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
|
*
|
package/dist/input/limits.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/session/persist.js
CHANGED
|
@@ -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
|
}
|