@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.
- package/CHANGELOG.md +14 -0
- package/README.md +295 -238
- package/dist/agent/controller.d.ts +37 -1
- package/dist/agent/controller.js +195 -45
- package/dist/agent/plan-prompt.d.ts +1 -1
- package/dist/agent/plan-prompt.js +4 -3
- package/dist/agent/project-guidance-prompt.d.ts +4 -0
- package/dist/agent/project-guidance-prompt.js +104 -0
- package/dist/agent/reducer.d.ts +2 -0
- package/dist/agent/reducer.js +4 -0
- package/dist/agent/system-prompt.js +4 -0
- package/dist/cli.js +49 -2
- package/dist/commands/builtins.js +26 -28
- package/dist/compaction/child.d.ts +9 -1
- package/dist/compaction/child.js +39 -13
- package/dist/compaction/command.js +20 -10
- package/dist/compaction/compactor.d.ts +33 -204
- package/dist/compaction/compactor.js +311 -726
- package/dist/compaction/context-command.js +14 -3
- package/dist/compaction/digest.d.ts +15 -51
- package/dist/compaction/digest.js +113 -148
- package/dist/compaction/limits.d.ts +19 -142
- package/dist/compaction/limits.js +20 -147
- package/dist/compaction/memory-identity.d.ts +16 -0
- package/dist/compaction/memory-identity.js +63 -0
- package/dist/compaction/memory-input.d.ts +25 -0
- package/dist/compaction/memory-input.js +106 -0
- package/dist/compaction/memory-render.d.ts +4 -0
- package/dist/compaction/memory-render.js +47 -0
- package/dist/compaction/memory-validation.d.ts +21 -0
- package/dist/compaction/memory-validation.js +268 -0
- package/dist/compaction/memory.d.ts +79 -0
- package/dist/compaction/memory.js +118 -0
- package/dist/compaction/meter.d.ts +14 -10
- package/dist/compaction/meter.js +53 -19
- package/dist/compaction/operation.d.ts +18 -0
- package/dist/compaction/operation.js +68 -0
- package/dist/compaction/pressure.d.ts +5 -20
- package/dist/compaction/pressure.js +13 -23
- package/dist/compaction/summarize-call.d.ts +3 -16
- package/dist/compaction/summarize-call.js +20 -20
- package/dist/compaction/summary-prompt.d.ts +29 -66
- package/dist/compaction/summary-prompt.js +132 -178
- package/dist/compaction/types.d.ts +27 -4
- package/dist/compaction/wiring.d.ts +10 -25
- package/dist/compaction/wiring.js +56 -66
- 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 +18 -1
- package/dist/config/schema.js +2 -1
- package/dist/exec/events.d.ts +8 -0
- package/dist/exec/index.js +13 -6
- package/dist/exec/runner.d.ts +8 -0
- package/dist/exec/runner.js +26 -2
- 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/dist/session/persist.d.ts +3 -0
- package/dist/session/persist.js +9 -0
- package/dist/session/store.js +2 -0
- package/dist/session/validate-session.js +34 -0
- package/dist/team/subagent.js +8 -0
- package/dist/ui/App.js +218 -116
- package/dist/ui/BottomStatusRow.d.ts +5 -10
- package/dist/ui/BottomStatusRow.js +7 -11
- package/dist/ui/Composer.d.ts +4 -2
- package/dist/ui/Composer.js +2 -2
- package/dist/ui/Header.d.ts +6 -2
- package/dist/ui/Header.js +7 -6
- package/dist/ui/PromptInput.d.ts +3 -1
- package/dist/ui/PromptInput.js +32 -41
- package/dist/ui/StatusBar.d.ts +6 -0
- package/dist/ui/StatusBar.js +8 -7
- package/dist/ui/Transcript.js +1 -1
- package/dist/ui/entries/CompactionCard.d.ts +3 -1
- package/dist/ui/entries/CompactionCard.js +7 -5
- package/dist/ui/frame-differ.js +27 -0
- package/dist/ui/frame-observer.js +18 -5
- package/dist/ui/interaction-copy.d.ts +73 -73
- package/dist/ui/interaction-copy.js +30 -38
- package/dist/ui/interaction-hints.d.ts +8 -1
- package/dist/ui/interaction-hints.js +56 -69
- package/dist/ui/layout/AppShell.d.ts +6 -2
- package/dist/ui/layout/AppShell.js +2 -2
- package/dist/ui/layout/OverlayFrame.d.ts +2 -0
- package/dist/ui/layout/OverlayFrame.js +23 -14
- package/dist/ui/layout/ScrollViewport.d.ts +3 -1
- package/dist/ui/layout/ScrollViewport.js +10 -2
- package/dist/ui/layout/budget.d.ts +25 -26
- package/dist/ui/layout/budget.js +29 -22
- package/dist/ui/layout/document-layout.d.ts +2 -2
- package/dist/ui/layout/document-layout.js +2 -3
- package/dist/ui/layout/queue-layout.js +4 -4
- package/dist/ui/layout/status-detail-layout.d.ts +9 -0
- package/dist/ui/layout/status-detail-layout.js +56 -0
- package/dist/ui/layout/status-layout.d.ts +19 -2
- package/dist/ui/layout/status-layout.js +95 -84
- package/dist/ui/layout/wrap-rows.d.ts +2 -0
- package/dist/ui/layout/wrap-rows.js +21 -7
- package/dist/ui/overlays/ConfirmDialog.d.ts +2 -1
- package/dist/ui/overlays/ConfirmDialog.js +22 -3
- package/dist/ui/overlays/HelpOverlay.js +9 -2
- package/dist/ui/overlays/ModelPicker.d.ts +5 -8
- package/dist/ui/overlays/ModelPicker.js +44 -10
- package/dist/ui/overlays/ModelProfileEditor.js +2 -0
- package/dist/ui/overlays/ModelProfilePicker.d.ts +1 -0
- package/dist/ui/overlays/ModelProfilePicker.js +4 -2
- package/dist/ui/overlays/PlanReviewOverlay.d.ts +2 -1
- package/dist/ui/overlays/PlanReviewOverlay.js +7 -4
- package/dist/ui/overlays/QuestionOverlay.d.ts +2 -1
- package/dist/ui/overlays/QuestionOverlay.js +45 -32
- package/dist/ui/overlays/QueueOverlay.js +2 -2
- package/dist/ui/overlays/SettingsScreen.d.ts +1 -0
- package/dist/ui/overlays/SettingsScreen.js +10 -3
- package/dist/ui/run-metrics.d.ts +23 -0
- package/dist/ui/run-metrics.js +27 -0
- package/dist/ui/status-feedback.d.ts +33 -0
- package/dist/ui/status-feedback.js +36 -0
- package/dist/ui/stdout-frame-writer.js +28 -0
- package/package.json +2 -2
package/dist/config/schema.js
CHANGED
|
@@ -744,7 +744,7 @@ export const DEFAULT_COMPACTION_CONFIG = {
|
|
|
744
744
|
warnThreshold: 0.75,
|
|
745
745
|
keepRecentTurns: 4,
|
|
746
746
|
useFastTier: true,
|
|
747
|
-
onFailure: '
|
|
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,
|
package/dist/exec/events.d.ts
CHANGED
|
@@ -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;
|
package/dist/exec/index.js
CHANGED
|
@@ -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(
|
|
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
|
},
|
package/dist/exec/runner.d.ts
CHANGED
|
@@ -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;
|
package/dist/exec/runner.js
CHANGED
|
@@ -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.
|
|
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
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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;
|