@aragon-agent/cli 0.6.15 → 0.6.16

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.
@@ -0,0 +1,91 @@
1
+ /**
2
+ * User-declared context windows, read from `~/.aragon-agent/model-windows.json`
3
+ * (path owned by `app-paths.ts`).
4
+ *
5
+ * WHY THIS FILE EXISTS. `ModelRegistry.getContextWindow` resolves a window from
6
+ * API discovery, the builtin model lists and the ten-entry catalog - and for
7
+ * every other id (a gateway alias, a self-hosted model, a release too new for
8
+ * the table) it returns the explicitly-untrusted 128k `fallback`. The gauge
9
+ * then renders `?` forever, by design: it refuses to divide by a guessed
10
+ * denominator. The one number that can make the denominator real is the user's
11
+ * own, and `config.json`'s `contextWindow` key can only hold ONE of it. This
12
+ * file is the per-model form of the same assertion.
13
+ *
14
+ * THE APPLICATION NEVER WRITES IT. A file the user owns by hand cannot be
15
+ * clobbered by an upgrade, and its absence is the ordinary case, not an error.
16
+ *
17
+ * ASCII ONLY: `src/config/**` is inside the glyph scanner's scope
18
+ * (`glyphs.test.ts::inScope`).
19
+ */
20
+ /** Structural bounds only; there is no policy surface to tune here. */
21
+ export interface ModelWindowsLimits {
22
+ /** Entries beyond this many are dropped, in file order. */
23
+ readonly maxEntries: number;
24
+ /** Keys longer than this are dropped rather than trusted as ids. */
25
+ readonly maxKeyLength: number;
26
+ /** A window below the smallest real context (1k) is a typo, not a limit. */
27
+ readonly minWindow: number;
28
+ /** A window above 1e9 tokens is also a typo; no model ships that today. */
29
+ readonly maxWindow: number;
30
+ }
31
+ export declare const MODEL_WINDOWS_LIMITS: ModelWindowsLimits;
32
+ /** Normalized model id -> declared window. Keys are stored ALREADY normalized. */
33
+ export type ModelWindowTable = ReadonlyMap<string, number>;
34
+ /**
35
+ * Same normalization core's `catalogModelId` applies, restated here because it
36
+ * is not part of Core's public surface and a second exporter would be a
37
+ * dependency on an internal. Keep the two in step: a date-stamped id
38
+ * (`kimi-k3-20260716`) and its bare form must resolve to one entry.
39
+ */
40
+ export declare function normalizeModelKey(modelId: string): string;
41
+ /** What a parse produced, and whether the FILE itself was at fault. */
42
+ export interface ModelWindowsParse {
43
+ table: ModelWindowTable;
44
+ /**
45
+ * True only when the text could not be understood at all (bad JSON, wrong
46
+ * top-level type). A valid file with zero USABLE entries is `false`: the
47
+ * user wrote JSON, the values were the problem, and every dead value was
48
+ * already dropped rather than trusted.
49
+ */
50
+ malformed: boolean;
51
+ }
52
+ /**
53
+ * Parse already-decoded text into a table. NEVER THROWS - a hand-edited file
54
+ * is the rule here, not the exception, and a typo must cost the user one dead
55
+ * entry, not the session. Two accepted shapes:
56
+ *
57
+ * { "version": 1, "windows": { "kimi-k3": 1048576 } }
58
+ * { "kimi-k3": 1048576 } (bare map, same effect)
59
+ *
60
+ * The wrapper exists so the format can grow fields without a migration; the
61
+ * bare map is accepted because the first shape a user types is the simple one.
62
+ * A leading UTF-8 BOM is tolerated: `readFileSync(..., 'utf8')` does not strip
63
+ * it, `JSON.parse` cannot parse it, and Notepad writes it by default.
64
+ */
65
+ export declare function parseModelWindowsResult(text: string): ModelWindowsParse;
66
+ /** The table alone, for callers that do not care why it might be empty. */
67
+ export declare function parseModelWindows(text: string): ModelWindowTable;
68
+ /**
69
+ * mtime-cached lookup over the file.
70
+ *
71
+ * WHY A STAT PER LOOKUP RATHER THAN A LOAD AT STARTUP: `getModelInfoFor` is
72
+ * called on every measurement, and the meter's `refreshValidity` re-reads the
73
+ * window precisely so a moved denominator (a `/model` switch, a live config
74
+ * edit) re-measures without a relaunch. A user saving this file mid-session
75
+ * gets the same treatment - `statSync` is microseconds, and the read itself
76
+ * only happens when the stamp actually changes.
77
+ *
78
+ * THE STAMP IS mtimeMs+size, so a rewrite that lands in the SAME millisecond
79
+ * with the SAME byte count serves the previous table once. Squeezing that
80
+ * window shut would need a content hash per lookup, which is exactly the cost
81
+ * the stamp exists to avoid; the next differing write clears it regardless.
82
+ */
83
+ export declare class ModelWindows {
84
+ private readonly path;
85
+ private table;
86
+ private stamp;
87
+ constructor(path?: string);
88
+ /** The declared window for `modelId`, or `undefined` when nobody declared one. */
89
+ lookup(modelId: string): number | undefined;
90
+ private refresh;
91
+ }
@@ -0,0 +1,181 @@
1
+ /**
2
+ * User-declared context windows, read from `~/.aragon-agent/model-windows.json`
3
+ * (path owned by `app-paths.ts`).
4
+ *
5
+ * WHY THIS FILE EXISTS. `ModelRegistry.getContextWindow` resolves a window from
6
+ * API discovery, the builtin model lists and the ten-entry catalog - and for
7
+ * every other id (a gateway alias, a self-hosted model, a release too new for
8
+ * the table) it returns the explicitly-untrusted 128k `fallback`. The gauge
9
+ * then renders `?` forever, by design: it refuses to divide by a guessed
10
+ * denominator. The one number that can make the denominator real is the user's
11
+ * own, and `config.json`'s `contextWindow` key can only hold ONE of it. This
12
+ * file is the per-model form of the same assertion.
13
+ *
14
+ * THE APPLICATION NEVER WRITES IT. A file the user owns by hand cannot be
15
+ * clobbered by an upgrade, and its absence is the ordinary case, not an error.
16
+ *
17
+ * ASCII ONLY: `src/config/**` is inside the glyph scanner's scope
18
+ * (`glyphs.test.ts::inScope`).
19
+ */
20
+ import { readFileSync, statSync } from 'node:fs';
21
+ import { getLogger } from '../logging/logger.js';
22
+ import { getModelWindowsPath } from './app-paths.js';
23
+ export const MODEL_WINDOWS_LIMITS = {
24
+ maxEntries: 512,
25
+ maxKeyLength: 200,
26
+ minWindow: 1_024,
27
+ maxWindow: 1_000_000_000,
28
+ };
29
+ /**
30
+ * Same normalization core's `catalogModelId` applies, restated here because it
31
+ * is not part of Core's public surface and a second exporter would be a
32
+ * dependency on an internal. Keep the two in step: a date-stamped id
33
+ * (`kimi-k3-20260716`) and its bare form must resolve to one entry.
34
+ */
35
+ export function normalizeModelKey(modelId) {
36
+ return modelId.trim().toLowerCase()
37
+ .replace(/^(models|openai|anthropic|google)\//, '')
38
+ .replace(/-(?:\d{4}-\d{2}-\d{2}|\d{8})$/, '');
39
+ }
40
+ function isWindowValue(value) {
41
+ return typeof value === 'number' && Number.isSafeInteger(value) &&
42
+ value >= MODEL_WINDOWS_LIMITS.minWindow && value <= MODEL_WINDOWS_LIMITS.maxWindow;
43
+ }
44
+ /**
45
+ * Parse already-decoded text into a table. NEVER THROWS - a hand-edited file
46
+ * is the rule here, not the exception, and a typo must cost the user one dead
47
+ * entry, not the session. Two accepted shapes:
48
+ *
49
+ * { "version": 1, "windows": { "kimi-k3": 1048576 } }
50
+ * { "kimi-k3": 1048576 } (bare map, same effect)
51
+ *
52
+ * The wrapper exists so the format can grow fields without a migration; the
53
+ * bare map is accepted because the first shape a user types is the simple one.
54
+ * A leading UTF-8 BOM is tolerated: `readFileSync(..., 'utf8')` does not strip
55
+ * it, `JSON.parse` cannot parse it, and Notepad writes it by default.
56
+ */
57
+ export function parseModelWindowsResult(text) {
58
+ const body = text.replace(/^\uFEFF/, '');
59
+ let parsed;
60
+ try {
61
+ parsed = JSON.parse(body);
62
+ }
63
+ catch {
64
+ return { table: new Map(), malformed: true };
65
+ }
66
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
67
+ return { table: new Map(), malformed: true };
68
+ }
69
+ const record = parsed;
70
+ const source = record.windows !== undefined && record.windows !== null &&
71
+ typeof record.windows === 'object' && !Array.isArray(record.windows)
72
+ ? record.windows
73
+ : record;
74
+ const table = new Map();
75
+ for (const [rawKey, rawValue] of Object.entries(source)) {
76
+ const key = normalizeModelKey(rawKey);
77
+ if (key.length === 0 || key.length > MODEL_WINDOWS_LIMITS.maxKeyLength)
78
+ continue;
79
+ if (!isWindowValue(rawValue))
80
+ continue;
81
+ table.set(key, rawValue);
82
+ if (table.size >= MODEL_WINDOWS_LIMITS.maxEntries)
83
+ break;
84
+ }
85
+ return { table, malformed: false };
86
+ }
87
+ /** The table alone, for callers that do not care why it might be empty. */
88
+ export function parseModelWindows(text) {
89
+ return parseModelWindowsResult(text).table;
90
+ }
91
+ /**
92
+ * Decode the raw bytes the way a Windows text editor may have saved them.
93
+ *
94
+ * `readFileSync(path, 'utf8')` alone fails THREE ways on this machine's most
95
+ * common editors: a UTF-8 BOM survives into the string and kills `JSON.parse`,
96
+ * and Notepad's "Unicode" saves are UTF-16LE (or BE), which decode as NUL-laden
97
+ * mojibake. All three are silent feature-death for exactly the user who just
98
+ * tried to configure it, so the sniff happens before any parse.
99
+ */
100
+ function decodeModelWindowsFile(bytes) {
101
+ if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xfe) {
102
+ return bytes.subarray(2).toString('utf16le');
103
+ }
104
+ if (bytes.length >= 2 && bytes[0] === 0xfe && bytes[1] === 0xff) {
105
+ // Big-endian UTF-16: Node has no utf16be decoder, so swap pairs in place
106
+ // on a COPY and reuse the little-endian decoder.
107
+ const swapped = Buffer.from(bytes.subarray(2));
108
+ for (let i = 0; i + 1 < swapped.length; i += 2) {
109
+ const high = swapped[i];
110
+ swapped[i] = swapped[i + 1];
111
+ swapped[i + 1] = high;
112
+ }
113
+ return swapped.toString('utf16le');
114
+ }
115
+ if (bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) {
116
+ return bytes.subarray(3).toString('utf8');
117
+ }
118
+ return bytes.toString('utf8');
119
+ }
120
+ /**
121
+ * mtime-cached lookup over the file.
122
+ *
123
+ * WHY A STAT PER LOOKUP RATHER THAN A LOAD AT STARTUP: `getModelInfoFor` is
124
+ * called on every measurement, and the meter's `refreshValidity` re-reads the
125
+ * window precisely so a moved denominator (a `/model` switch, a live config
126
+ * edit) re-measures without a relaunch. A user saving this file mid-session
127
+ * gets the same treatment - `statSync` is microseconds, and the read itself
128
+ * only happens when the stamp actually changes.
129
+ *
130
+ * THE STAMP IS mtimeMs+size, so a rewrite that lands in the SAME millisecond
131
+ * with the SAME byte count serves the previous table once. Squeezing that
132
+ * window shut would need a content hash per lookup, which is exactly the cost
133
+ * the stamp exists to avoid; the next differing write clears it regardless.
134
+ */
135
+ export class ModelWindows {
136
+ path;
137
+ table = new Map();
138
+ stamp = null;
139
+ constructor(path = getModelWindowsPath()) {
140
+ this.path = path;
141
+ }
142
+ /** The declared window for `modelId`, or `undefined` when nobody declared one. */
143
+ lookup(modelId) {
144
+ this.refresh();
145
+ return this.table.get(normalizeModelKey(modelId));
146
+ }
147
+ refresh() {
148
+ let stamp;
149
+ try {
150
+ const stats = statSync(this.path);
151
+ stamp = { mtimeMs: stats.mtimeMs, size: stats.size };
152
+ }
153
+ catch (error) {
154
+ // A deleted file (ENOENT) is a decision: drop the entries. Any OTHER
155
+ // failure (EACCES, an antivirus lock) is transient, and blanking every
156
+ // declared window because a scanner held the file open for 50 ms would
157
+ // swing the gauge to `?` for a reason the user cannot see - keep the
158
+ // last good table and try again on the next lookup.
159
+ if (error.code === 'ENOENT') {
160
+ this.stamp = null;
161
+ this.table = new Map();
162
+ }
163
+ return;
164
+ }
165
+ if (stamp.mtimeMs === this.stamp?.mtimeMs && stamp.size === this.stamp.size)
166
+ return;
167
+ let bytes;
168
+ try {
169
+ bytes = readFileSync(this.path);
170
+ }
171
+ catch {
172
+ return; // Appeared in the stat, gone in the read: try again next lookup.
173
+ }
174
+ const result = parseModelWindowsResult(decodeModelWindowsFile(bytes));
175
+ if (result.malformed) {
176
+ getLogger().warn('config', 'model-windows.json could not be parsed; ignoring it until it is fixed', { path: this.path });
177
+ }
178
+ this.table = result.table;
179
+ this.stamp = stamp;
180
+ }
181
+ }
@@ -786,6 +786,14 @@ export interface PersistedConfig {
786
786
  * asked for; a kill switch is what a bug report needs.
787
787
  */
788
788
  paste: boolean;
789
+ /**
790
+ * Ask the terminal to report modified keys distinctly so Shift+Enter can
791
+ * insert a newline (win32-input-mode on Windows, kitty disambiguate +
792
+ * modifyOtherKeys elsewhere). A KILL SWITCH in the exact sense `paste`
793
+ * above records: the encoding tables are structural and live in
794
+ * `input/win32-input-mode.ts` / `input/csiu-keys.ts`.
795
+ */
796
+ keyboardEnhancement: boolean;
789
797
  /** Idle ms before a paused viewport returns to the newest line; `0` disables. */
790
798
  scrollResumeMs: number;
791
799
  /** Start every session in PLAN mode (plan-mode §4.4). */
@@ -971,6 +979,15 @@ export interface CliConfig {
971
979
  * reach none of them.
972
980
  */
973
981
  paste: boolean;
982
+ /**
983
+ * Whether this session may push the keyboard-enhancement modes.
984
+ *
985
+ * IT MUST EXIST HERE AND NOT ONLY IN `PersistedConfig`, for the reason
986
+ * `paste` above records: `runInteractive` reads it -- where `config`
987
+ * is a `CliConfig` -- to decide the filter's `enhancedKeys` feature
988
+ * and the `enableKeyboardEnhancement` push.
989
+ */
990
+ keyboardEnhancement: boolean;
974
991
  /** Idle ms before a paused viewport returns to the newest line; `0` disables. */
975
992
  scrollResumeMs: number;
976
993
  /**
@@ -896,6 +896,7 @@ export const DEFAULT_CONFIG = {
896
896
  mouse: true,
897
897
  mouseSelect: DEFAULT_MOUSE_SELECT,
898
898
  paste: true,
899
+ keyboardEnhancement: true,
899
900
  scrollResumeMs: DEFAULT_SCROLL_RESUME_MS,
900
901
  planModeDefault: false,
901
902
  planModeMaxAskRounds: DEFAULT_PLAN_MAX_ASK_ROUNDS,
@@ -0,0 +1,66 @@
1
+ /**
2
+ * CSI-u key translation (posix keyboard-enhancement section of the Shift+Enter
3
+ * newline fix).
4
+ *
5
+ * WHY THIS EXISTS. On macOS/Linux the only lever that makes Shift+Enter
6
+ * distinguishable from Enter is asking the terminal to report modified keys in
7
+ * the CSI-u encoding: kitty's disambiguate mode (`CSI > 1 u`, pushed and popped
8
+ * with `CSI < u`) and xterm's `modifyOtherKeys=2` (`CSI > 4 ; 2 m`, reset with
9
+ * `CSI > 4 m`). iTerm2, kitty, ghostty, WezTerm, foot and Alacritty implement
10
+ * at least one of the two; a terminal implementing neither ignores both bytes,
11
+ * which the conhost probe confirmed is a harmless no-op.
12
+ *
13
+ * WHAT CHANGES IN THE STREAM -- AND WHAT NEVER DOES. Both modes re-encode only
14
+ * keys whose legacy encoding is ambiguous: Ctrl/Ctrl+Shift/Ctrl+Alt+letters and
15
+ * digits, the Enter family, the Escape key (kitty), and Backspace with
16
+ * modifiers. `modifyOtherKeys` is named for the OTHER keys: arrows, Home/End,
17
+ * PgUp/PgDn and F-keys keep their standard `\x1b[1;mA` / `\x1b[m~` encodings,
18
+ * and Shift+printable still types text. So the translator below only ever sees
19
+ * codepoints below 128 plus a few named control codes; anything else (a kitty
20
+ * functional-key code from a mode this app never pushes) passes through
21
+ * untouched, which is exactly the pre-enhancement behaviour.
22
+ *
23
+ * THE CONTRACT mirrors `win32-input-mode.ts`: split, translate back to the
24
+ * legacy bytes, with the modified-Enter family becoming `ENTER_NEWLINE_FRAME`.
25
+ * Pure: no node/React imports.
26
+ */
27
+ /** Enable / disable the two CSI-u report modes (posix side). */
28
+ export declare const CSI_U_ENABLE = "\u001B[>1u\u001B[>4;2m";
29
+ export declare const CSI_U_DISABLE = "\u001B[<u\u001B[>4m";
30
+ export interface CsiUKey {
31
+ /** The full original sequence, for pass-through. */
32
+ readonly seq: string;
33
+ /** First parameter's primary number (the key's codepoint). */
34
+ readonly code: number;
35
+ /** Optional `:alternate` sub-parameters of the key field (kitty/mok). */
36
+ readonly alternates: readonly number[];
37
+ /** Modifier parameter minus one (bit 1 shift, 2 alt, 4 ctrl), or 0. */
38
+ readonly mods: number;
39
+ }
40
+ export type CsiUSegment = {
41
+ kind: 'text';
42
+ text: string;
43
+ } | {
44
+ kind: 'key';
45
+ key: CsiUKey;
46
+ };
47
+ /**
48
+ * Split a chunk into text spans and CSI-u sequences.
49
+ *
50
+ * Grammar accepted: `ESC [ <params> u` where params are digits, `;` and `:`
51
+ * only, and at least one field exists. `\x1b[13;2u` is INCLUDED: with
52
+ * enhancement on, this module owns every `u`-terminated sequence and applies
53
+ * the same Enter semantics `enter-sequences.ts` defines.
54
+ */
55
+ export declare function splitCsiUKeys(text: string): CsiUSegment[];
56
+ /**
57
+ * Translate one CSI-u sequence to the legacy bytes the same keypress would
58
+ * have produced without the report modes (Enter family excepted: a modified
59
+ * Enter becomes the newline frame). Unknown keys pass through unchanged.
60
+ */
61
+ export declare function translateCsiUKey(key: CsiUKey): string;
62
+ /**
63
+ * Length of the trailing run of `text` that could still complete into a
64
+ * CSI-u sequence, or 0. Longest match, mirroring the other families.
65
+ */
66
+ export declare function trailingCsiUPrefixLength(text: string): number;
@@ -0,0 +1,170 @@
1
+ /**
2
+ * CSI-u key translation (posix keyboard-enhancement section of the Shift+Enter
3
+ * newline fix).
4
+ *
5
+ * WHY THIS EXISTS. On macOS/Linux the only lever that makes Shift+Enter
6
+ * distinguishable from Enter is asking the terminal to report modified keys in
7
+ * the CSI-u encoding: kitty's disambiguate mode (`CSI > 1 u`, pushed and popped
8
+ * with `CSI < u`) and xterm's `modifyOtherKeys=2` (`CSI > 4 ; 2 m`, reset with
9
+ * `CSI > 4 m`). iTerm2, kitty, ghostty, WezTerm, foot and Alacritty implement
10
+ * at least one of the two; a terminal implementing neither ignores both bytes,
11
+ * which the conhost probe confirmed is a harmless no-op.
12
+ *
13
+ * WHAT CHANGES IN THE STREAM -- AND WHAT NEVER DOES. Both modes re-encode only
14
+ * keys whose legacy encoding is ambiguous: Ctrl/Ctrl+Shift/Ctrl+Alt+letters and
15
+ * digits, the Enter family, the Escape key (kitty), and Backspace with
16
+ * modifiers. `modifyOtherKeys` is named for the OTHER keys: arrows, Home/End,
17
+ * PgUp/PgDn and F-keys keep their standard `\x1b[1;mA` / `\x1b[m~` encodings,
18
+ * and Shift+printable still types text. So the translator below only ever sees
19
+ * codepoints below 128 plus a few named control codes; anything else (a kitty
20
+ * functional-key code from a mode this app never pushes) passes through
21
+ * untouched, which is exactly the pre-enhancement behaviour.
22
+ *
23
+ * THE CONTRACT mirrors `win32-input-mode.ts`: split, translate back to the
24
+ * legacy bytes, with the modified-Enter family becoming `ENTER_NEWLINE_FRAME`.
25
+ * Pure: no node/React imports.
26
+ */
27
+ import { ENTER_NEWLINE_FRAME } from './limits.js';
28
+ /** Enable / disable the two CSI-u report modes (posix side). */
29
+ export const CSI_U_ENABLE = '\x1b[>1u\x1b[>4;2m';
30
+ export const CSI_U_DISABLE = '\x1b[<u\x1b[>4m';
31
+ const isParamChar = (c) => (c >= '0' && c <= '9') || c === ';' || c === ':';
32
+ /**
33
+ * Split a chunk into text spans and CSI-u sequences.
34
+ *
35
+ * Grammar accepted: `ESC [ <params> u` where params are digits, `;` and `:`
36
+ * only, and at least one field exists. `\x1b[13;2u` is INCLUDED: with
37
+ * enhancement on, this module owns every `u`-terminated sequence and applies
38
+ * the same Enter semantics `enter-sequences.ts` defines.
39
+ */
40
+ export function splitCsiUKeys(text) {
41
+ const segments = [];
42
+ let start = 0;
43
+ let index = 0;
44
+ while (index < text.length) {
45
+ if (text[index] !== '\x1b' || text[index + 1] !== '[') {
46
+ index += 1;
47
+ continue;
48
+ }
49
+ let end = index + 2;
50
+ while (end < text.length && isParamChar(text[end]))
51
+ end += 1;
52
+ if (end >= text.length || text[end] !== 'u') {
53
+ index += 1;
54
+ continue;
55
+ }
56
+ const key = parseKey(text.slice(index + 2, end), text.slice(index, end + 1));
57
+ if (!key) {
58
+ index += 1;
59
+ continue;
60
+ }
61
+ if (index > start)
62
+ segments.push({ kind: 'text', text: text.slice(start, index) });
63
+ segments.push({ kind: 'key', key });
64
+ index = end + 1;
65
+ start = index;
66
+ }
67
+ if (start < text.length)
68
+ segments.push({ kind: 'text', text: text.slice(start) });
69
+ return segments;
70
+ }
71
+ /** Parse the parameter body of a CSI-u sequence, or null when malformed. */
72
+ function parseKey(body, seq) {
73
+ if (body.length === 0)
74
+ return null;
75
+ const fields = body.split(';');
76
+ const keyParts = fields[0]?.split(':').map(Number) ?? [];
77
+ if (keyParts.some((n) => !Number.isInteger(n) || n < 0 || n > 0x10ffff))
78
+ return null;
79
+ const code = keyParts[0];
80
+ if (code === undefined)
81
+ return null;
82
+ let mods = 0;
83
+ if (fields.length > 1) {
84
+ const modParts = fields[1]?.split(':').map(Number) ?? [];
85
+ if (modParts.some((n) => !Number.isInteger(n) || n < 0 || n > 64))
86
+ return null;
87
+ const m = modParts[0] ?? 0;
88
+ mods = Math.max(0, m - 1);
89
+ }
90
+ return { seq, code, alternates: keyParts.slice(1), mods };
91
+ }
92
+ /** Control code for Ctrl+letter (a-z -> 1-26), or the char itself. */
93
+ function ctrlByte(char) {
94
+ if (char >= 97 && char <= 122)
95
+ return char - 96;
96
+ if (char >= 65 && char <= 90)
97
+ return char - 64;
98
+ return char;
99
+ }
100
+ /**
101
+ * Translate one CSI-u sequence to the legacy bytes the same keypress would
102
+ * have produced without the report modes (Enter family excepted: a modified
103
+ * Enter becomes the newline frame). Unknown keys pass through unchanged.
104
+ */
105
+ export function translateCsiUKey(key) {
106
+ const m = key.mods;
107
+ const ctrl = (m & 4) !== 0;
108
+ const alt = (m & 2) !== 0;
109
+ const shift = (m & 1) !== 0;
110
+ if (key.code === 13)
111
+ return m === 0 ? '\r' : ENTER_NEWLINE_FRAME;
112
+ if (key.code === 27)
113
+ return alt ? '\x1b\x1b' : '\x1b';
114
+ if (key.code === 9)
115
+ return shift ? '\x1b[Z' : '\t';
116
+ if (key.code === 127) {
117
+ // POST-normalisation bytes, same rationale as VK_BACK in win32-input-mode.
118
+ if (alt)
119
+ return '\x1b\x08';
120
+ return '\x08';
121
+ }
122
+ if (key.code === 32) {
123
+ // Ctrl+Space is 0x00 in legacy xterm; plain/alt space is the character.
124
+ if (ctrl)
125
+ return alt ? '\x1b\x00' : '\x00';
126
+ return alt ? '\x1b ' : ' ';
127
+ }
128
+ if (key.code >= 32 && key.code <= 126) {
129
+ const char = String.fromCharCode(key.code);
130
+ if (ctrl) {
131
+ const code = ctrlByte(key.code);
132
+ const text = code === key.code ? char : String.fromCharCode(code);
133
+ return alt ? `\x1b${text}` : text;
134
+ }
135
+ if (alt)
136
+ return `\x1b${char}`;
137
+ if (shift && key.alternates.length > 0) {
138
+ const shifted = key.alternates[0];
139
+ if (shifted !== undefined && shifted > 0)
140
+ return String.fromCodePoint(shifted);
141
+ }
142
+ return char;
143
+ }
144
+ // Functional-key codes only appear under modes this app never pushes
145
+ // (kitty flags 2+); pass them through so behaviour matches the un-enhanced
146
+ // stream instead of inventing a translation.
147
+ return key.seq;
148
+ }
149
+ /** True when `s` is a PROPER prefix of some CSI-u sequence. */
150
+ function isCsiUPrefix(s) {
151
+ if (s.length < 3 || s[0] !== '\x1b' || s[1] !== '[')
152
+ return false;
153
+ for (let i = 2; i < s.length; i += 1) {
154
+ if (!isParamChar(s[i]))
155
+ return false;
156
+ }
157
+ return true;
158
+ }
159
+ /**
160
+ * Length of the trailing run of `text` that could still complete into a
161
+ * CSI-u sequence, or 0. Longest match, mirroring the other families.
162
+ */
163
+ export function trailingCsiUPrefixLength(text) {
164
+ const max = Math.min(text.length, 32);
165
+ for (let k = max; k >= 1; k -= 1) {
166
+ if (isCsiUPrefix(text.slice(text.length - k)))
167
+ return k;
168
+ }
169
+ return 0;
170
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Keyboard-enhancement lifecycle (the Shift+Enter newline fix).
3
+ *
4
+ * WHY A LIFECYCLE MODULE AND NOT ANOTHER `screen.ts` MODE. The alternate
5
+ * screen is bound to fullscreen rendering; keyboard enhancement must run in
6
+ * every interactive session, inline included, because the composer is always
7
+ * there. It still follows the screen module's discipline: bytes are written
8
+ * ONLY for a TTY stdout, restore is idempotent, and the caller owns every
9
+ * decision -- this module is told the resolved capability and never derives
10
+ * one from config or environment.
11
+ *
12
+ * PLATFORM SPLIT, DECIDED BY BYTE-LEVEL PROBES (see
13
+ * docs/diagnoses/shift-enter-newline-dead-by-default):
14
+ *
15
+ * - win32: `?9001h` (win32-input-mode). It works through ConPTY on both
16
+ * conhost and Windows Terminal; kitty `CSI > 1 u` is swallowed there (a
17
+ * `CSI ? u` query never answers) and modifyOtherKeys is a no-op. Gated on
18
+ * the SAME `vtInputSupported` measurement the mouse feature uses: on Node
19
+ * < 22.17 / < 24.2 libuv never turns on `ENABLE_VIRTUAL_TERMINAL_INPUT`,
20
+ * so the records cannot reach the process and the mode would be a lie.
21
+ * - everything else: kitty disambiguate (`CSI > 1 u`) plus
22
+ * `modifyOtherKeys=2`. iTerm2 / kitty / ghostty / WezTerm / foot implement
23
+ * at least one; terminals that implement neither ignore both bytes.
24
+ */
25
+ export interface KeyboardEnhancementHandle {
26
+ /** Idempotent; writes the disable bytes exactly once. */
27
+ restore(): void;
28
+ }
29
+ /** The handle a gated-out session reads: nothing was pushed, nothing unwinds. */
30
+ export declare const NOOP_KEYBOARD_ENHANCEMENT: KeyboardEnhancementHandle;
31
+ /**
32
+ * Ask the terminal to report modified keys distinctly. The returned handle's
33
+ * `restore()` hands the keyboard back; wire it into the same convergence
34
+ * point the screen restore uses so every exit path unwinds both.
35
+ *
36
+ * `vtInputSupported` is the measurement `cli.tsx` already resolves for the
37
+ * mouse gate; on win32 it decides between `?9001h` and nothing at all.
38
+ */
39
+ export declare function enableKeyboardEnhancement(stdout: NodeJS.WriteStream | undefined, opts: {
40
+ readonly platform: string;
41
+ readonly vtInputSupported: boolean;
42
+ }): KeyboardEnhancementHandle;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Keyboard-enhancement lifecycle (the Shift+Enter newline fix).
3
+ *
4
+ * WHY A LIFECYCLE MODULE AND NOT ANOTHER `screen.ts` MODE. The alternate
5
+ * screen is bound to fullscreen rendering; keyboard enhancement must run in
6
+ * every interactive session, inline included, because the composer is always
7
+ * there. It still follows the screen module's discipline: bytes are written
8
+ * ONLY for a TTY stdout, restore is idempotent, and the caller owns every
9
+ * decision -- this module is told the resolved capability and never derives
10
+ * one from config or environment.
11
+ *
12
+ * PLATFORM SPLIT, DECIDED BY BYTE-LEVEL PROBES (see
13
+ * docs/diagnoses/shift-enter-newline-dead-by-default):
14
+ *
15
+ * - win32: `?9001h` (win32-input-mode). It works through ConPTY on both
16
+ * conhost and Windows Terminal; kitty `CSI > 1 u` is swallowed there (a
17
+ * `CSI ? u` query never answers) and modifyOtherKeys is a no-op. Gated on
18
+ * the SAME `vtInputSupported` measurement the mouse feature uses: on Node
19
+ * < 22.17 / < 24.2 libuv never turns on `ENABLE_VIRTUAL_TERMINAL_INPUT`,
20
+ * so the records cannot reach the process and the mode would be a lie.
21
+ * - everything else: kitty disambiguate (`CSI > 1 u`) plus
22
+ * `modifyOtherKeys=2`. iTerm2 / kitty / ghostty / WezTerm / foot implement
23
+ * at least one; terminals that implement neither ignore both bytes.
24
+ */
25
+ import { CSI_U_DISABLE, CSI_U_ENABLE, } from './csiu-keys.js';
26
+ import { WIN32_INPUT_DISABLE, WIN32_INPUT_ENABLE } from './win32-input-mode.js';
27
+ const NOOP_HANDLE = { restore() { } };
28
+ /** The handle a gated-out session reads: nothing was pushed, nothing unwinds. */
29
+ export const NOOP_KEYBOARD_ENHANCEMENT = NOOP_HANDLE;
30
+ function safeWrite(stdout, data) {
31
+ try {
32
+ stdout.write(data);
33
+ }
34
+ catch {
35
+ // A closed/broken stdout must never crash the exit path.
36
+ }
37
+ }
38
+ /**
39
+ * Ask the terminal to report modified keys distinctly. The returned handle's
40
+ * `restore()` hands the keyboard back; wire it into the same convergence
41
+ * point the screen restore uses so every exit path unwinds both.
42
+ *
43
+ * `vtInputSupported` is the measurement `cli.tsx` already resolves for the
44
+ * mouse gate; on win32 it decides between `?9001h` and nothing at all.
45
+ */
46
+ export function enableKeyboardEnhancement(stdout, opts) {
47
+ if (!stdout || !stdout.isTTY || typeof stdout.write !== 'function')
48
+ return NOOP_HANDLE;
49
+ const enable = opts.platform === 'win32'
50
+ ? (opts.vtInputSupported ? WIN32_INPUT_ENABLE : '')
51
+ : CSI_U_ENABLE;
52
+ if (enable.length === 0)
53
+ return NOOP_HANDLE;
54
+ const disable = opts.platform === 'win32' ? WIN32_INPUT_DISABLE : CSI_U_DISABLE;
55
+ safeWrite(stdout, enable);
56
+ let restored = false;
57
+ return {
58
+ restore() {
59
+ if (restored)
60
+ return;
61
+ restored = true;
62
+ safeWrite(stdout, disable);
63
+ },
64
+ };
65
+ }
@@ -60,6 +60,15 @@ export interface StdinFilterFeatures {
60
60
  readonly mouse: boolean;
61
61
  /** Recognise pastes and deliver them framed. */
62
62
  readonly paste: boolean;
63
+ /**
64
+ * Translate keyboard-enhancement encodings (win32-input-mode records on
65
+ * Windows, CSI-u sequences from kitty/modifyOtherKeys elsewhere) back to
66
+ * legacy bytes BEFORE Ink parses them. TOLD, never derived: `cli.tsx`
67
+ * resolves config, TTY state and `vtInputSupported`, and passes the same
68
+ * boolean to `enableKeyboardEnhancement`, so the filter only ever
69
+ * translates for a session whose mode push actually happened.
70
+ */
71
+ readonly enhancedKeys?: boolean;
63
72
  }
64
73
  export interface StdinFilter {
65
74
  /** Hand this to Ink: `render(<App/>, { stdin: filter.stdin })`. */