@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.
- package/README.md +64 -24
- package/dist/agent/controller.d.ts +10 -0
- package/dist/agent/controller.js +13 -0
- package/dist/cli.js +49 -2
- package/dist/commands/builtins.js +24 -26
- package/dist/compaction/context-command.js +14 -3
- package/dist/compaction/types.d.ts +6 -1
- package/dist/config/app-paths.d.ts +10 -0
- package/dist/config/app-paths.js +12 -0
- package/dist/config/env.d.ts +2 -1
- package/dist/config/env.js +13 -1
- package/dist/config/load.d.ts +9 -0
- package/dist/config/load.js +14 -0
- package/dist/config/model-windows.d.ts +91 -0
- package/dist/config/model-windows.js +181 -0
- package/dist/config/schema.d.ts +17 -0
- package/dist/config/schema.js +1 -0
- package/dist/input/csiu-keys.d.ts +66 -0
- package/dist/input/csiu-keys.js +170 -0
- package/dist/input/keyboard-enhancement.d.ts +42 -0
- package/dist/input/keyboard-enhancement.js +65 -0
- package/dist/input/stdin-filter.d.ts +9 -0
- package/dist/input/stdin-filter.js +45 -9
- package/dist/input/win32-input-mode.d.ts +61 -0
- package/dist/input/win32-input-mode.js +251 -0
- package/package.json +2 -2
|
@@ -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
|
+
}
|
package/dist/config/schema.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/config/schema.js
CHANGED
|
@@ -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 })`. */
|