@aragon-agent/cli 0.6.14 → 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.
Files changed (131) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +295 -238
  3. package/dist/agent/controller.d.ts +37 -1
  4. package/dist/agent/controller.js +195 -45
  5. package/dist/agent/plan-prompt.d.ts +1 -1
  6. package/dist/agent/plan-prompt.js +4 -3
  7. package/dist/agent/project-guidance-prompt.d.ts +4 -0
  8. package/dist/agent/project-guidance-prompt.js +104 -0
  9. package/dist/agent/reducer.d.ts +2 -0
  10. package/dist/agent/reducer.js +4 -0
  11. package/dist/agent/system-prompt.js +4 -0
  12. package/dist/cli.js +49 -2
  13. package/dist/commands/builtins.js +26 -28
  14. package/dist/compaction/child.d.ts +9 -1
  15. package/dist/compaction/child.js +39 -13
  16. package/dist/compaction/command.js +20 -10
  17. package/dist/compaction/compactor.d.ts +33 -204
  18. package/dist/compaction/compactor.js +311 -726
  19. package/dist/compaction/context-command.js +14 -3
  20. package/dist/compaction/digest.d.ts +15 -51
  21. package/dist/compaction/digest.js +113 -148
  22. package/dist/compaction/limits.d.ts +19 -142
  23. package/dist/compaction/limits.js +20 -147
  24. package/dist/compaction/memory-identity.d.ts +16 -0
  25. package/dist/compaction/memory-identity.js +63 -0
  26. package/dist/compaction/memory-input.d.ts +25 -0
  27. package/dist/compaction/memory-input.js +106 -0
  28. package/dist/compaction/memory-render.d.ts +4 -0
  29. package/dist/compaction/memory-render.js +47 -0
  30. package/dist/compaction/memory-validation.d.ts +21 -0
  31. package/dist/compaction/memory-validation.js +268 -0
  32. package/dist/compaction/memory.d.ts +79 -0
  33. package/dist/compaction/memory.js +118 -0
  34. package/dist/compaction/meter.d.ts +14 -10
  35. package/dist/compaction/meter.js +53 -19
  36. package/dist/compaction/operation.d.ts +18 -0
  37. package/dist/compaction/operation.js +68 -0
  38. package/dist/compaction/pressure.d.ts +5 -20
  39. package/dist/compaction/pressure.js +13 -23
  40. package/dist/compaction/summarize-call.d.ts +3 -16
  41. package/dist/compaction/summarize-call.js +20 -20
  42. package/dist/compaction/summary-prompt.d.ts +29 -66
  43. package/dist/compaction/summary-prompt.js +132 -178
  44. package/dist/compaction/types.d.ts +27 -4
  45. package/dist/compaction/wiring.d.ts +10 -25
  46. package/dist/compaction/wiring.js +56 -66
  47. package/dist/config/app-paths.d.ts +10 -0
  48. package/dist/config/app-paths.js +12 -0
  49. package/dist/config/env.d.ts +2 -1
  50. package/dist/config/env.js +13 -1
  51. package/dist/config/load.d.ts +9 -0
  52. package/dist/config/load.js +14 -0
  53. package/dist/config/model-windows.d.ts +91 -0
  54. package/dist/config/model-windows.js +181 -0
  55. package/dist/config/schema.d.ts +18 -1
  56. package/dist/config/schema.js +2 -1
  57. package/dist/exec/events.d.ts +8 -0
  58. package/dist/exec/index.js +13 -6
  59. package/dist/exec/runner.d.ts +8 -0
  60. package/dist/exec/runner.js +26 -2
  61. package/dist/input/csiu-keys.d.ts +66 -0
  62. package/dist/input/csiu-keys.js +170 -0
  63. package/dist/input/keyboard-enhancement.d.ts +42 -0
  64. package/dist/input/keyboard-enhancement.js +65 -0
  65. package/dist/input/stdin-filter.d.ts +9 -0
  66. package/dist/input/stdin-filter.js +45 -9
  67. package/dist/input/win32-input-mode.d.ts +61 -0
  68. package/dist/input/win32-input-mode.js +251 -0
  69. package/dist/session/persist.d.ts +3 -0
  70. package/dist/session/persist.js +9 -0
  71. package/dist/session/store.js +2 -0
  72. package/dist/session/validate-session.js +34 -0
  73. package/dist/team/subagent.js +8 -0
  74. package/dist/ui/App.js +218 -116
  75. package/dist/ui/BottomStatusRow.d.ts +5 -10
  76. package/dist/ui/BottomStatusRow.js +7 -11
  77. package/dist/ui/Composer.d.ts +4 -2
  78. package/dist/ui/Composer.js +2 -2
  79. package/dist/ui/Header.d.ts +6 -2
  80. package/dist/ui/Header.js +7 -6
  81. package/dist/ui/PromptInput.d.ts +3 -1
  82. package/dist/ui/PromptInput.js +32 -41
  83. package/dist/ui/StatusBar.d.ts +6 -0
  84. package/dist/ui/StatusBar.js +8 -7
  85. package/dist/ui/Transcript.js +1 -1
  86. package/dist/ui/entries/CompactionCard.d.ts +3 -1
  87. package/dist/ui/entries/CompactionCard.js +7 -5
  88. package/dist/ui/frame-differ.js +27 -0
  89. package/dist/ui/frame-observer.js +18 -5
  90. package/dist/ui/interaction-copy.d.ts +73 -73
  91. package/dist/ui/interaction-copy.js +30 -38
  92. package/dist/ui/interaction-hints.d.ts +8 -1
  93. package/dist/ui/interaction-hints.js +56 -69
  94. package/dist/ui/layout/AppShell.d.ts +6 -2
  95. package/dist/ui/layout/AppShell.js +2 -2
  96. package/dist/ui/layout/OverlayFrame.d.ts +2 -0
  97. package/dist/ui/layout/OverlayFrame.js +23 -14
  98. package/dist/ui/layout/ScrollViewport.d.ts +3 -1
  99. package/dist/ui/layout/ScrollViewport.js +10 -2
  100. package/dist/ui/layout/budget.d.ts +25 -26
  101. package/dist/ui/layout/budget.js +29 -22
  102. package/dist/ui/layout/document-layout.d.ts +2 -2
  103. package/dist/ui/layout/document-layout.js +2 -3
  104. package/dist/ui/layout/queue-layout.js +4 -4
  105. package/dist/ui/layout/status-detail-layout.d.ts +9 -0
  106. package/dist/ui/layout/status-detail-layout.js +56 -0
  107. package/dist/ui/layout/status-layout.d.ts +19 -2
  108. package/dist/ui/layout/status-layout.js +95 -84
  109. package/dist/ui/layout/wrap-rows.d.ts +2 -0
  110. package/dist/ui/layout/wrap-rows.js +21 -7
  111. package/dist/ui/overlays/ConfirmDialog.d.ts +2 -1
  112. package/dist/ui/overlays/ConfirmDialog.js +22 -3
  113. package/dist/ui/overlays/HelpOverlay.js +9 -2
  114. package/dist/ui/overlays/ModelPicker.d.ts +5 -8
  115. package/dist/ui/overlays/ModelPicker.js +44 -10
  116. package/dist/ui/overlays/ModelProfileEditor.js +2 -0
  117. package/dist/ui/overlays/ModelProfilePicker.d.ts +1 -0
  118. package/dist/ui/overlays/ModelProfilePicker.js +4 -2
  119. package/dist/ui/overlays/PlanReviewOverlay.d.ts +2 -1
  120. package/dist/ui/overlays/PlanReviewOverlay.js +7 -4
  121. package/dist/ui/overlays/QuestionOverlay.d.ts +2 -1
  122. package/dist/ui/overlays/QuestionOverlay.js +45 -32
  123. package/dist/ui/overlays/QueueOverlay.js +2 -2
  124. package/dist/ui/overlays/SettingsScreen.d.ts +1 -0
  125. package/dist/ui/overlays/SettingsScreen.js +10 -3
  126. package/dist/ui/run-metrics.d.ts +23 -0
  127. package/dist/ui/run-metrics.js +27 -0
  128. package/dist/ui/status-feedback.d.ts +33 -0
  129. package/dist/ui/status-feedback.js +36 -0
  130. package/dist/ui/stdout-frame-writer.js +28 -0
  131. package/package.json +2 -2
@@ -744,7 +744,7 @@ export const DEFAULT_COMPACTION_CONFIG = {
744
744
  warnThreshold: 0.75,
745
745
  keepRecentTurns: 4,
746
746
  useFastTier: true,
747
- onFailure: 'truncate',
747
+ onFailure: 'stop',
748
748
  subagents: true,
749
749
  archive: true,
750
750
  };
@@ -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,
@@ -274,6 +274,14 @@ export interface ExecFastTierEvent {
274
274
  * it. What a wrapper does need is the SHAPE of what happened, which is here.
275
275
  */
276
276
  export interface ExecCompactionEvent {
277
+ decision?: {
278
+ occupied: number;
279
+ contextWindow: number;
280
+ threshold: number;
281
+ source: 'usage' | 'estimate';
282
+ deltaTokens: number;
283
+ };
284
+ memoryVersion?: 2;
277
285
  type: 'compaction';
278
286
  sessionId: string;
279
287
  turn: number;
@@ -111,7 +111,7 @@ async function execute(input) {
111
111
  now,
112
112
  });
113
113
  if (plan.saved) {
114
- controller.replaceMessages(plan.saved.messages);
114
+ controller.replaceMessages(plan.saved.messages, plan.saved.compactionIdentity);
115
115
  controller.restoreTodos(plan.saved.todos);
116
116
  }
117
117
  const config = controller.getConfig();
@@ -187,7 +187,7 @@ async function execute(input) {
187
187
  outputTokens: stats.usage.outputTokens,
188
188
  totalTokens: stats.usage.inputTokens + stats.usage.outputTokens,
189
189
  },
190
- cost: buildCost(controller, stats.usage),
190
+ cost: buildCost(controller, stats.usage, stats.compactionBilling),
191
191
  model: { provider: config.provider, id: config.model },
192
192
  todos: todo ? { total: todo.total, done: todo.doneCount } : null,
193
193
  error: stats.errorCode && stats.errorMessage
@@ -253,17 +253,24 @@ async function drive(input, controller, runner) {
253
253
  function withPermissionMode(flags, mode) {
254
254
  return mode === 'plan' ? { ...flags, plan: true } : flags;
255
255
  }
256
- function buildCost(controller, usage) {
256
+ function buildCost(controller, usage, compaction) {
257
257
  const config = controller.getConfig();
258
- const known = controller.isPricedModel({
258
+ const known = !compaction?.pricingUnknown && controller.isPricedModel({
259
259
  providerId: config.provider,
260
260
  modelId: config.model,
261
261
  });
262
+ const mainUsage = {
263
+ inputTokens: usage.inputTokens - (compaction?.usage.inputTokens ?? 0),
264
+ outputTokens: usage.outputTokens - (compaction?.usage.outputTokens ?? 0),
265
+ cacheReadTokens: (usage.cacheReadTokens ?? 0) - (compaction?.usage.cacheReadTokens ?? 0),
266
+ cacheWriteTokens: (usage.cacheWriteTokens ?? 0) - (compaction?.usage.cacheWriteTokens ?? 0),
267
+ };
262
268
  // `amount` is `0` when the price is unknown, mirroring the fast tier's rule
263
269
  // that a feature which looks free while it is spending money is worse than one
264
270
  // that admits it does not know.
265
271
  return {
266
- amount: known ? computeCost(usage, controller.getModelInfo().cost) : 0,
272
+ amount: known ? computeCost(mainUsage, controller.getModelInfo().cost) +
273
+ (compaction?.costUsd ?? 0) : 0,
267
274
  currency: 'USD',
268
275
  known,
269
276
  };
@@ -460,7 +467,7 @@ function persist(plan, controller, turns, usage, cli, at) {
460
467
  modelId: config.model,
461
468
  ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}),
462
469
  },
463
- messages: controller.getMessages(),
470
+ ...(controller.getSessionSnapshot?.() ?? { messages: controller.getMessages() }),
464
471
  entries: [],
465
472
  todos: controller.getTodoSnapshot?.()?.items ?? [],
466
473
  },
@@ -72,7 +72,13 @@ export interface ExecRunnerOptions {
72
72
  /** Turns already spent by earlier invocations of a resumed session. */
73
73
  priorTurns?: number;
74
74
  }
75
+ export interface ExecCompactionBilling {
76
+ usage: TokenUsage;
77
+ costUsd: number;
78
+ pricingUnknown: boolean;
79
+ }
75
80
  export interface ExecRunStats {
81
+ compactionBilling?: ExecCompactionBilling;
76
82
  turns: number;
77
83
  usage: TokenUsage;
78
84
  lastAssistantText: string;
@@ -93,6 +99,7 @@ export declare class ExecRunner {
93
99
  private unsubscribers;
94
100
  private turns;
95
101
  private readonly usage;
102
+ private compactionBilling;
96
103
  private lastAssistantText;
97
104
  private errored;
98
105
  private sawTurnEnd;
@@ -143,6 +150,7 @@ export declare class ExecRunner {
143
150
  * the two faces cannot drift about what "unfinished" means.
144
151
  */
145
152
  run(source: ExecPromptSource): Promise<void>;
153
+ private onCompactionUsage;
146
154
  private requestSeq;
147
155
  private requestHadError;
148
156
  private isRequestCancelled;
@@ -46,6 +46,7 @@ export class ExecRunner {
46
46
  unsubscribers = [];
47
47
  turns = 0;
48
48
  usage = { inputTokens: 0, outputTokens: 0 };
49
+ compactionBilling;
49
50
  lastAssistantText = '';
50
51
  errored = false;
51
52
  sawTurnEnd = false;
@@ -175,8 +176,7 @@ export class ExecRunner {
175
176
  // wrapper gets.
176
177
  const compaction = controller.subscribeCompaction?.((event) => {
177
178
  if (event.type === 'usage') {
178
- this.usage.inputTokens += event.usage.inputTokens;
179
- this.usage.outputTokens += event.usage.outputTokens;
179
+ this.onCompactionUsage(event);
180
180
  return;
181
181
  }
182
182
  if (event.type === 'compaction_start') {
@@ -208,6 +208,8 @@ export class ExecRunner {
208
208
  tokensBefore: r.tokensBefore,
209
209
  tokensAfter: r.tokensAfter,
210
210
  ...(r.tailRelief ? { tailRelief: r.tailRelief } : {}),
211
+ ...(r.decision ? { decision: r.decision } : {}),
212
+ ...(r.memoryVersion ? { memoryVersion: r.memoryVersion } : {}),
211
213
  durationMs: r.durationMs,
212
214
  });
213
215
  });
@@ -246,6 +248,9 @@ export class ExecRunner {
246
248
  return {
247
249
  turns: this.turns,
248
250
  usage: { ...this.usage },
251
+ ...(this.compactionBilling ? { compactionBilling: {
252
+ ...this.compactionBilling, usage: { ...this.compactionBilling.usage },
253
+ } } : {}),
249
254
  lastAssistantText: this.lastAssistantText,
250
255
  errored: this.errored,
251
256
  stopReason: this.stopReason,
@@ -322,6 +327,25 @@ export class ExecRunner {
322
327
  }
323
328
  }
324
329
  }
330
+ onCompactionUsage(event) {
331
+ const usage = event.usage;
332
+ this.usage.inputTokens += usage.inputTokens;
333
+ this.usage.outputTokens += usage.outputTokens;
334
+ for (const key of ['cacheReadTokens', 'cacheWriteTokens']) {
335
+ if (usage[key] !== undefined)
336
+ this.usage[key] = (this.usage[key] ?? 0) + usage[key];
337
+ }
338
+ if (event.costUsd === undefined && event.pricingUnknown === undefined)
339
+ return;
340
+ const billing = this.compactionBilling ??= { costUsd: 0, pricingUnknown: false,
341
+ usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 } };
342
+ billing.costUsd += event.costUsd ?? 0;
343
+ billing.pricingUnknown ||= event.pricingUnknown === true;
344
+ billing.usage.inputTokens += usage.inputTokens;
345
+ billing.usage.outputTokens += usage.outputTokens;
346
+ billing.usage.cacheReadTokens += usage.cacheReadTokens ?? 0;
347
+ billing.usage.cacheWriteTokens += usage.cacheWriteTokens ?? 0;
348
+ }
325
349
  requestSeq = 0;
326
350
  requestHadError = false;
327
351
  isRequestCancelled() {
@@ -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 })`. */
@@ -36,6 +36,8 @@ import { MAX_PENDING_MOUSE_CHARS, splitMouseEvents, isMousePrefix, } from './mou
36
36
  import { ENTER_NEWLINE_FRAME, PASTE_ASSEMBLY_MAX_MS, PASTE_BURST_MS, PASTE_CLOSE, PASTE_MAX_BYTES, PASTE_OPEN, formatPasteSize, } from './limits.js';
37
37
  import { PASTE_BEGIN_MARK, PASTE_END_MARK, classifyChunk, sanitisePaste, trailingPastePrefixLength, } from './paste-parse.js';
38
38
  import { splitEnterSequences, trailingEnterPrefixLength } from './enter-sequences.js';
39
+ import { splitCsiUKeys, trailingCsiUPrefixLength, translateCsiUKey } from './csiu-keys.js';
40
+ import { splitWin32KeySequences, trailingWin32PrefixLength, translateWin32Key, } from './win32-input-mode.js';
39
41
  /**
40
42
  * How long a possible-prefix tail is held before being flushed as text.
41
43
  *
@@ -82,6 +84,7 @@ function trailingMousePrefixLength(text) {
82
84
  return 0;
83
85
  }
84
86
  export function createStdinFilter(real, features, bridge, onWarn) {
87
+ const enhancedKeys = features.enhancedKeys === true;
85
88
  const wrapper = new PassThrough();
86
89
  const listeners = new Set();
87
90
  let pending = '';
@@ -251,17 +254,50 @@ export function createStdinFilter(real, features, bridge, onWarn) {
251
254
  else
252
255
  writeInput(split.pending);
253
256
  };
257
+ /**
258
+ * THE ENHANCED-KEY WALK, OUTSIDE PASTES ONLY. win32-input-mode records are
259
+ * split first (their bodies are digits/semicolons, so no paste marker or
260
+ * CSI-u sequence can live inside one), then CSI-u sequences in the
261
+ * remaining text, and only then the legacy Enter family. Each translated
262
+ * key terminates a heuristic burst exactly like an Enter frame does -- a
263
+ * keystroke mid-burst is the definition of "not a paste".
264
+ */
265
+ const writeEnhancedKey = (legacy) => {
266
+ if (legacy.length === 0)
267
+ return;
268
+ flushBurst();
269
+ writeInput(legacy);
270
+ };
254
271
  const handleOutsidePaste = (text, holdTail) => {
255
- const segments = splitEnterSequences(text);
256
- for (let index = 0; index < segments.length; index += 1) {
257
- const segment = segments[index];
258
- if (segment.kind === 'text') {
259
- handleText(segment.text, holdTail && index === segments.length - 1);
272
+ const win32 = enhancedKeys ? splitWin32KeySequences(text)
273
+ : [{ kind: 'text', text }];
274
+ for (let w = 0; w < win32.length; w += 1) {
275
+ const wSeg = win32[w];
276
+ const wLast = holdTail && w === win32.length - 1;
277
+ if (wSeg.kind === 'key') {
278
+ writeEnhancedKey(translateWin32Key(wSeg.record));
260
279
  continue;
261
280
  }
262
- // A key terminates the heuristic paste before its internal frame is written.
263
- flushBurst();
264
- writeInput(segment.kind === 'submit' ? '\r' : ENTER_NEWLINE_FRAME);
281
+ const csiu = enhancedKeys ? splitCsiUKeys(wSeg.text)
282
+ : [{ kind: 'text', text: wSeg.text }];
283
+ for (let c = 0; c < csiu.length; c += 1) {
284
+ const cSeg = csiu[c];
285
+ if (cSeg.kind === 'key') {
286
+ writeEnhancedKey(translateCsiUKey(cSeg.key));
287
+ continue;
288
+ }
289
+ const segments = splitEnterSequences(cSeg.text);
290
+ for (let index = 0; index < segments.length; index += 1) {
291
+ const segment = segments[index];
292
+ if (segment.kind === 'text') {
293
+ handleText(segment.text, wLast && c === csiu.length - 1
294
+ && index === segments.length - 1);
295
+ continue;
296
+ }
297
+ // A key terminates the heuristic paste before its internal frame is written.
298
+ writeEnhancedKey(segment.kind === 'submit' ? '\r' : ENTER_NEWLINE_FRAME);
299
+ }
300
+ }
265
301
  }
266
302
  };
267
303
  const appendBracketed = (text) => {
@@ -310,7 +346,7 @@ export function createStdinFilter(real, features, bridge, onWarn) {
310
346
  // the ESC / ESC-[ head bytes, which the max keeps for whoever needs
311
347
  // them. The existing 12 ms flush and the 32-char ceiling bound the
312
348
  // hold, so no new timer is needed.
313
- const keep = Math.max(trailingPastePrefixLength(rest), features.mouse ? trailingMousePrefixLength(rest) : 0, trailingEnterPrefixLength(rest));
349
+ const keep = Math.max(trailingPastePrefixLength(rest), features.mouse ? trailingMousePrefixLength(rest) : 0, trailingEnterPrefixLength(rest), enhancedKeys ? trailingWin32PrefixLength(rest) : 0, enhancedKeys ? trailingCsiUPrefixLength(rest) : 0);
314
350
  const tail = rest.slice(rest.length - keep);
315
351
  handleOutsidePaste(rest.slice(0, rest.length - keep), true);
316
352
  pending += tail;