@aragon-agent/cli 0.6.15 → 0.6.17

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.
Files changed (72) hide show
  1. package/README.md +108 -71
  2. package/dist/agent/controller.d.ts +10 -0
  3. package/dist/agent/controller.js +52 -47
  4. package/dist/agent/reducer.js +0 -4
  5. package/dist/cli.js +49 -15
  6. package/dist/commands/builtins.js +27 -43
  7. package/dist/compaction/child.d.ts +10 -8
  8. package/dist/compaction/child.js +23 -25
  9. package/dist/compaction/context-command.js +14 -3
  10. package/dist/compaction/limits.d.ts +0 -2
  11. package/dist/compaction/limits.js +0 -2
  12. package/dist/compaction/types.d.ts +6 -1
  13. package/dist/config/app-paths.d.ts +10 -0
  14. package/dist/config/app-paths.js +12 -0
  15. package/dist/config/cli-commands.d.ts +1 -1
  16. package/dist/config/cli-commands.js +16 -15
  17. package/dist/config/env.d.ts +2 -1
  18. package/dist/config/env.js +13 -1
  19. package/dist/config/load.d.ts +9 -17
  20. package/dist/config/load.js +14 -6
  21. package/dist/config/model-windows.d.ts +91 -0
  22. package/dist/config/model-windows.js +181 -0
  23. package/dist/config/schema.d.ts +34 -5
  24. package/dist/config/schema.js +15 -11
  25. package/dist/exec/events.d.ts +12 -8
  26. package/dist/exec/events.js +2 -2
  27. package/dist/exec/runner.js +4 -1
  28. package/dist/fast/limits.d.ts +2 -2
  29. package/dist/fast/limits.js +2 -2
  30. package/dist/fast/prompt.d.ts +10 -11
  31. package/dist/fast/prompt.js +19 -20
  32. package/dist/fast/reviewer.d.ts +1 -1
  33. package/dist/fast/reviewer.js +1 -1
  34. package/dist/fast/types.d.ts +0 -2
  35. package/dist/fast/wiring.d.ts +18 -34
  36. package/dist/fast/wiring.js +20 -51
  37. package/dist/input/csiu-keys.d.ts +66 -0
  38. package/dist/input/csiu-keys.js +170 -0
  39. package/dist/input/keyboard-enhancement.d.ts +42 -0
  40. package/dist/input/keyboard-enhancement.js +65 -0
  41. package/dist/input/stdin-filter.d.ts +9 -0
  42. package/dist/input/stdin-filter.js +45 -9
  43. package/dist/input/win32-input-mode.d.ts +61 -0
  44. package/dist/input/win32-input-mode.js +251 -0
  45. package/dist/session/validate-session.js +3 -1
  46. package/dist/team/child-reviewer.d.ts +105 -0
  47. package/dist/team/child-reviewer.js +117 -0
  48. package/dist/team/normalize.d.ts +7 -26
  49. package/dist/team/normalize.js +9 -22
  50. package/dist/team/prompt.d.ts +0 -11
  51. package/dist/team/prompt.js +3 -12
  52. package/dist/team/report.d.ts +4 -19
  53. package/dist/team/report.js +1 -27
  54. package/dist/team/runtime.d.ts +5 -10
  55. package/dist/team/runtime.js +35 -39
  56. package/dist/team/subagent.d.ts +18 -18
  57. package/dist/team/subagent.js +80 -30
  58. package/dist/team/task-tool.d.ts +6 -26
  59. package/dist/team/task-tool.js +13 -47
  60. package/dist/team/types.d.ts +3 -42
  61. package/dist/ui/App.js +3 -4
  62. package/dist/ui/BottomStatusRow.js +2 -1
  63. package/dist/ui/StatusBar.js +2 -1
  64. package/dist/ui/TeamPanel.js +1 -1
  65. package/dist/ui/entries/TeamCard.js +1 -6
  66. package/dist/ui/layout/status-detail-layout.js +12 -2
  67. package/dist/ui/layout/status-layout.d.ts +4 -2
  68. package/dist/ui/layout/status-layout.js +24 -1
  69. package/dist/ui/overlays/HelpOverlay.js +1 -2
  70. package/package.json +2 -2
  71. package/dist/ui/ModeChip.d.ts +0 -18
  72. package/dist/ui/ModeChip.js +0 -24
@@ -16,33 +16,32 @@
16
16
  import { FAST_BLOCK_VERSION } from './limits.js';
17
17
  export { FAST_BLOCK_VERSION };
18
18
  /**
19
- * The lead's `<fast_tier>` block. Under 1100 characters on purpose: it is paid
20
- * for on EVERY turn of every fast-enabled session, so anything that does not
21
- * change a delegation decision does not belong in it (R-11).
19
+ * The `<fast_tier>` block, spliced for the lead AND for task children running
20
+ * under a per-child reviewer. Under 1100 characters on purpose: it is paid for
21
+ * on EVERY turn of every fast-enabled session, so anything that does not change
22
+ * a decision does not belong in it (R-11).
22
23
  *
23
- * EACH PARAGRAPH IS PRESENT ONLY WHEN ITS CAPABILITY IS. `fast.delegate: false,
24
- * fast.review: true` advertises only the review, because telling a model about a
25
- * `model: "fast"` field that the normalizer will downgrade every time is how you
26
- * get a model that keeps asking for something it can never have.
24
+ * REVIEW-ONLY SIN MAIN-AGENT PARITY: every `task` child runs the lead's own
25
+ * model, so the block no longer advertises a per-child `model:"fast"`. Telling
26
+ * a model about a field that no longer exists is how you get a model that keeps
27
+ * asking for something it can never have.
27
28
  *
28
- * Returns `''` when neither capability is live, which the splice treats as "no
29
- * block" and which keeps the byte-identity branch intact.
29
+ * Returns `''` when the review is off, which the splice treats as "no block"
30
+ * and which keeps the byte-identity branch intact.
30
31
  */
31
32
  export function buildFastBlock(params) {
32
- if (!params.delegate && !params.review)
33
+ if (!params.review)
33
34
  return '';
34
- const lines = [
35
+ return [
35
36
  '<fast_tier>',
36
37
  `A second, cheaper model is available in this session (${params.model}).`,
37
- ];
38
- if (params.delegate) {
39
- lines.push('', 'Delegation: pass model:"fast" to a task subagent whose work is mechanical and', 'high-volume - reading or searching many files, summarizing long command output,', 'applying the same small edit in several places. Keep model:"main" (the default)', 'for design decisions, tricky debugging, and anything that has to be right the', 'first time. A fast subagent has the same tools and the same permissions as any', 'other; only the model differs.');
40
- }
41
- if (params.review) {
42
- lines.push('', 'Reviews: every few turns a <fast_review> block may appear in the conversation.', 'It is an automated second opinion from the fast model - NOT a message from the', 'user. Treat it as advice: act on it when it is right, say so briefly and carry', 'on when it is not, and never ask the user to confirm it.');
43
- }
44
- lines.push('</fast_tier>');
45
- return lines.join('\n');
38
+ '',
39
+ 'Reviews: every few turns a <fast_review> block may appear in the conversation.',
40
+ 'It is an automated second opinion from the fast model - NOT a message from the',
41
+ 'user. Treat it as advice: act on it when it is right, say so briefly and carry',
42
+ 'on when it is not, and never ask the user to confirm it.',
43
+ '</fast_tier>',
44
+ ].join('\n');
46
45
  }
47
46
  /**
48
47
  * The reviewer's own system prompt.
@@ -97,7 +97,7 @@ export interface FastReviewerDeps {
97
97
  * `FastWiring.snapshot()`, so the self-disable can announce itself.
98
98
  *
99
99
  * The reviewer cannot build a `FastSnapshot` on its own — `live`,
100
- * `sameAsMain`, `delegated` and `pricingUnknown` all belong to the wiring —
100
+ * `sameAsMain` and `pricingUnknown` all belong to the wiring —
101
101
  * and the two existing `tier_changed` emitters read it the same way.
102
102
  */
103
103
  snapshot(): FastSnapshot;
@@ -375,7 +375,7 @@ export class FastReviewer {
375
375
  return;
376
376
  this.budgetAnnouncedAt = limit;
377
377
  this.deps.notify('info', `Fast reviews: session budget reached (${limit}). ` +
378
- `Delegation is unaffected. Raise it with /fast budget <n>.`);
378
+ `Raise it with /fast budget <n>.`);
379
379
  this.log.info('fast_review_budget_reached', { limit, reviews: this.reviewIndex });
380
380
  }
381
381
  // -----------------------------------------------------------------------
@@ -107,8 +107,6 @@ export interface FastSnapshot {
107
107
  * report a budget reached for a session that never had one (RV-H10).
108
108
  */
109
109
  budgetReached: boolean;
110
- /** Fast-tier children dispatched this session. */
111
- delegated: number;
112
110
  usage: TokenUsage;
113
111
  /**
114
112
  * No price table for `model` (C-11 / RV-4).
@@ -7,8 +7,8 @@
7
7
  * THIS CLASS EXISTS FOR A BUDGET REASON, NOT AN AESTHETIC ONE (C-12 / RV-5).
8
8
  * `CLAUDE.md` caps a source file at 1000 lines and `controller.ts` was at 960
9
9
  * before this feature, which handed it nine new responsibilities. `FastWiring`
10
- * owns the flag pair, the tier cache, the reviewer's lifetime and the
11
- * `resolveTier` closure; `AgentController` keeps one field and four thin
10
+ * owns the flag pair, the tier cache, the reviewer's lifetime and the shared
11
+ * fail-fast review transport; `AgentController` keeps one field and four thin
12
12
  * forwarders. It is still CLI-local and still holds no `ui/` import.
13
13
  *
14
14
  * TWO FLAGS AND ONE LIVE PREDICATE (§3.3), which is the shape every optional
@@ -31,10 +31,10 @@
31
31
  * caused it (`agent-loop.ts:140-145`).
32
32
  */
33
33
  import type { ModelRole } from '../config/model-profiles.js';
34
- import { type AgentEvent, type AssistantMessage, type LLMRequest, type ModelRef, type ThinkingLevel } from '@aragon-agent/core';
34
+ import { type AgentEvent, type AssistantMessage, type LLMRequest, type ModelRef } from '@aragon-agent/core';
35
35
  import type { CliConfig } from '../config/schema.js';
36
36
  import type { NoticeLevel } from '../agent/reducer.js';
37
- import type { FastEventListener, FastSnapshot, FastTier, FastTierName } from './types.js';
37
+ import type { FastEventListener, FastSnapshot, FastTier } from './types.js';
38
38
  export interface FastWiringDeps {
39
39
  /** Read LIVE on every call - the controller replaces the object on mutation. */
40
40
  getConfig: () => CliConfig;
@@ -88,7 +88,6 @@ export declare class FastWiring {
88
88
  private tier;
89
89
  /** Warned once per TRANSITION, so a broken tier does not shout every turn. */
90
90
  private lastReportedReason;
91
- private delegated;
92
91
  constructor(deps: FastWiringDeps);
93
92
  /**
94
93
  * The registry the REVIEW calls through — separate from the lead's, and
@@ -120,22 +119,13 @@ export declare class FastWiring {
120
119
  * §3.3's live predicate. THE THIRD TERM IS THE POINT - see the header.
121
120
  *
122
121
  * `FastReviewer.isSelfDisabled()` is DELIBERATELY NOT a fourth term. Folding
123
- * it in would look tidier and would break something else: `delegationAvailable()`
124
- * is `available() && config.fast.delegate === true`, so three consecutive
125
- * REVIEW failures would silently switch DELEGATION off as well - turning a
126
- * diagnostic signal into a behaviour change for every user. The two facts are
127
- * reported separately on `FastSnapshot` instead (`live` / `selfDisabled`).
122
+ * it in would look tidier and would break something else: per-child reviewers
123
+ * gate on `available()` too, so three consecutive REVIEW failures would
124
+ * silently stop every child's reviews as well - turning a diagnostic signal
125
+ * into a behaviour change for every dispatch. The two facts are reported
126
+ * separately on `FastSnapshot` instead (`live` / `selfDisabled`).
128
127
  */
129
128
  available(): boolean;
130
- /**
131
- * Whether `model: "fast"` on `task` is honoured right now.
132
- *
133
- * Evaluated AT DISPATCH TIME, never snapshot (§3.4 / RV-3). `fast.delegate:
134
- * false` and "the tier stopped resolving twenty minutes ago" are the same
135
- * observable to the model - a downgrade, counted and reported - which is the
136
- * honest reading of both.
137
- */
138
- delegationAvailable(): boolean;
139
129
  setEnabled(enabled: boolean): void;
140
130
  /**
141
131
  * Re-resolve the tier after ANY config mutation (§3.2 rule 8 / §3.9 / RV-3).
@@ -152,24 +142,18 @@ export declare class FastWiring {
152
142
  * (§3.3). Raised once, at construction, through the controller's `notify`.
153
143
  */
154
144
  startupNotice(): string | null;
145
+ /** The resolved fast `ModelRef`, or `null` - for cost lookups (§3.6). */
146
+ fastRef(): ModelRef | null;
155
147
  /**
156
- * The `ModelRef` and thinking level one subagent tier runs on.
148
+ * The fail-fast review transport, exposed so PER-CHILD reviewers share ONE
149
+ * registry instead of building one each - the same adapter-state reuse
150
+ * argument `TeamRuntime` makes about the lead's `ProviderRegistry`.
157
151
  *
158
- * `'fast'` FALLS BACK TO MAIN when delegation is not available, so the two
159
- * guards agree BY CONSTRUCTION: the normalizer can never produce a
160
- * `tier: 'fast'` spec this factory would refuse, and this factory can never
161
- * build a child against a `ModelRef` the tier no longer resolves (RV-3).
152
+ * Lazy: a session that never reviews allocates nothing (I-2). The retry
153
+ * policy is `FAST_RETRY_POLICY`, deliberately not the user's `retry` config
154
+ * - see `getFastRegistry()` for the reason.
162
155
  */
163
- resolveTier(tier: FastTierName): {
164
- ref: ModelRef;
165
- thinkingLevel: ThinkingLevel;
166
- role?: ModelRole;
167
- };
168
- /** The resolved fast `ModelRef`, or `null` - for per-tier cost lookup (§3.6). */
169
- fastRef(): ModelRef | null;
170
- /** Count fast-tier children so `/fast status` can answer "is this saving me
171
- * anything?" — the aggregate on the status bar cannot. */
172
- noteDelegated(count: number): void;
156
+ completeViaFastRegistry(providerId: string, request: LLMRequest): Promise<AssistantMessage>;
173
157
  setGoal(text: string): void;
174
158
  abort(): void;
175
159
  dispose(): void;
@@ -7,8 +7,8 @@
7
7
  * THIS CLASS EXISTS FOR A BUDGET REASON, NOT AN AESTHETIC ONE (C-12 / RV-5).
8
8
  * `CLAUDE.md` caps a source file at 1000 lines and `controller.ts` was at 960
9
9
  * before this feature, which handed it nine new responsibilities. `FastWiring`
10
- * owns the flag pair, the tier cache, the reviewer's lifetime and the
11
- * `resolveTier` closure; `AgentController` keeps one field and four thin
10
+ * owns the flag pair, the tier cache, the reviewer's lifetime and the shared
11
+ * fail-fast review transport; `AgentController` keeps one field and four thin
12
12
  * forwarders. It is still CLI-local and still holds no `ui/` import.
13
13
  *
14
14
  * TWO FLAGS AND ONE LIVE PREDICATE (§3.3), which is the shape every optional
@@ -64,7 +64,6 @@ export function offFastStatus() {
64
64
  // `budgetReached` is CARRIED and callers must never re-derive it.
65
65
  reviewBudget: 0,
66
66
  budgetReached: false,
67
- delegated: 0,
68
67
  usage: { inputTokens: 0, outputTokens: 0 },
69
68
  pricingUnknown: false,
70
69
  inFlight: false,
@@ -86,7 +85,6 @@ export class FastWiring {
86
85
  tier;
87
86
  /** Warned once per TRANSITION, so a broken tier does not shout every turn. */
88
87
  lastReportedReason = null;
89
- delegated = 0;
90
88
  constructor(deps) {
91
89
  this.deps = deps;
92
90
  this.tier = resolveFastTier(deps.getConfig(), deps.hasKey);
@@ -168,26 +166,15 @@ export class FastWiring {
168
166
  * §3.3's live predicate. THE THIRD TERM IS THE POINT - see the header.
169
167
  *
170
168
  * `FastReviewer.isSelfDisabled()` is DELIBERATELY NOT a fourth term. Folding
171
- * it in would look tidier and would break something else: `delegationAvailable()`
172
- * is `available() && config.fast.delegate === true`, so three consecutive
173
- * REVIEW failures would silently switch DELEGATION off as well - turning a
174
- * diagnostic signal into a behaviour change for every user. The two facts are
175
- * reported separately on `FastSnapshot` instead (`live` / `selfDisabled`).
169
+ * it in would look tidier and would break something else: per-child reviewers
170
+ * gate on `available()` too, so three consecutive REVIEW failures would
171
+ * silently stop every child's reviews as well - turning a diagnostic signal
172
+ * into a behaviour change for every dispatch. The two facts are reported
173
+ * separately on `FastSnapshot` instead (`live` / `selfDisabled`).
176
174
  */
177
175
  available() {
178
176
  return this.registered && this.enabled && this.tier.ok;
179
177
  }
180
- /**
181
- * Whether `model: "fast"` on `task` is honoured right now.
182
- *
183
- * Evaluated AT DISPATCH TIME, never snapshot (§3.4 / RV-3). `fast.delegate:
184
- * false` and "the tier stopped resolving twenty minutes ago" are the same
185
- * observable to the model - a downgrade, counted and reported - which is the
186
- * honest reading of both.
187
- */
188
- delegationAvailable() {
189
- return this.available() && this.deps.getConfig().fast.delegate === true;
190
- }
191
178
  setEnabled(enabled) {
192
179
  this.enabled = enabled;
193
180
  this.deps.onPromptChanged();
@@ -233,40 +220,23 @@ export class FastWiring {
233
220
  return describeFastTierProblem(this.tier, fastProviderOf(this.deps.getConfig()));
234
221
  }
235
222
  // -----------------------------------------------------------------------
236
- // Tier resolution for subagents (§3.4)
223
+ // The tier's transport, shared with per-child reviewers (§3.4)
237
224
  // -----------------------------------------------------------------------
238
- /**
239
- * The `ModelRef` and thinking level one subagent tier runs on.
240
- *
241
- * `'fast'` FALLS BACK TO MAIN when delegation is not available, so the two
242
- * guards agree BY CONSTRUCTION: the normalizer can never produce a
243
- * `tier: 'fast'` spec this factory would refuse, and this factory can never
244
- * build a child against a `ModelRef` the tier no longer resolves (RV-3).
245
- */
246
- resolveTier(tier) {
247
- const config = this.deps.getConfig();
248
- if (tier === 'fast' && this.delegationAvailable() && this.tier.ok) {
249
- return { ref: this.tier.ref, thinkingLevel: this.tier.thinkingLevel, role: 'fast' };
250
- }
251
- return {
252
- ref: {
253
- providerId: config.provider,
254
- modelId: config.model,
255
- ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}),
256
- },
257
- thinkingLevel: config.thinkingLevel,
258
- role: 'main',
259
- };
260
- }
261
- /** The resolved fast `ModelRef`, or `null` - for per-tier cost lookup (§3.6). */
225
+ /** The resolved fast `ModelRef`, or `null` - for cost lookups (§3.6). */
262
226
  fastRef() {
263
227
  return this.tier.ok ? this.tier.ref : null;
264
228
  }
265
- /** Count fast-tier children so `/fast status` can answer "is this saving me
266
- * anything?" — the aggregate on the status bar cannot. */
267
- noteDelegated(count) {
268
- if (count > 0)
269
- this.delegated += count;
229
+ /**
230
+ * The fail-fast review transport, exposed so PER-CHILD reviewers share ONE
231
+ * registry instead of building one each - the same adapter-state reuse
232
+ * argument `TeamRuntime` makes about the lead's `ProviderRegistry`.
233
+ *
234
+ * Lazy: a session that never reviews allocates nothing (I-2). The retry
235
+ * policy is `FAST_RETRY_POLICY`, deliberately not the user's `retry` config
236
+ * - see `getFastRegistry()` for the reason.
237
+ */
238
+ completeViaFastRegistry(providerId, request) {
239
+ return this.getFastRegistry().complete(providerId, request);
270
240
  }
271
241
  // -----------------------------------------------------------------------
272
242
  // Reviewer lifetime
@@ -302,7 +272,6 @@ export class FastWiring {
302
272
  reviews: this.reviewer.reviewCount(),
303
273
  reviewBudget: this.reviewer.budgetLimit(),
304
274
  budgetReached: this.reviewer.isBudgetReached(),
305
- delegated: this.delegated,
306
275
  usage: this.reviewer.sessionUsage(),
307
276
  // UNKNOWN PRICING IS NOT ZERO PRICING (C-11 / RV-4). The fast tier is
308
277
  // precisely where an unrecognised model id is likely, and rendering it as
@@ -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
+ }