@aragon-agent/cli 0.5.10 → 0.5.11
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/dist/cli.js +93 -0
- package/dist/commands/perf.d.ts +5 -1
- package/dist/commands/perf.js +43 -1
- package/dist/config/env.d.ts +1 -0
- package/dist/config/env.js +17 -0
- package/dist/config/load.d.ts +13 -0
- package/dist/config/load.js +31 -0
- package/dist/config/schema.d.ts +35 -0
- package/dist/config/schema.js +26 -0
- package/dist/ui/PromptInput.d.ts +10 -0
- package/dist/ui/PromptInput.js +41 -48
- package/dist/ui/editor-reducer.d.ts +62 -0
- package/dist/ui/editor-reducer.js +90 -0
- package/dist/ui/frame-differ.d.ts +95 -0
- package/dist/ui/frame-differ.js +230 -0
- package/dist/ui/stdout-frame-writer.d.ts +41 -0
- package/dist/ui/stdout-frame-writer.js +102 -0
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -34,6 +34,9 @@ import { runSkillsCommand } from './skills/cli-commands.js';
|
|
|
34
34
|
import { runHeadless } from './agent/headless.js';
|
|
35
35
|
import { App } from './ui/App.js';
|
|
36
36
|
import { decideRenderMode } from './ui/layout/frame.js';
|
|
37
|
+
import { createFrameDiffer } from './ui/frame-differ.js';
|
|
38
|
+
import { wrapStdoutForFrames } from './ui/stdout-frame-writer.js';
|
|
39
|
+
import { setFrameStatsProvider } from './commands/perf.js';
|
|
37
40
|
import { tryCreateMouseFilter } from './input/stdin-mouse-filter.js';
|
|
38
41
|
import { enterAltScreen, writeExitTranscript } from './ui/screen.js';
|
|
39
42
|
import { readExitSnapshot } from './ui/exit-snapshot.js';
|
|
@@ -140,6 +143,16 @@ function toFlags(opts) {
|
|
|
140
143
|
transcriptRetain: opts.transcriptRetain,
|
|
141
144
|
renderGovernor: opts.renderGovernor,
|
|
142
145
|
maxRenderInterval: opts.maxRenderInterval,
|
|
146
|
+
// Site three of three for `--diff-render` / `--sync-output` — EIGHT
|
|
147
|
+
// features, one trap (`mouse` P1-2, `team` P1-4, `todo` C-9, render L4,
|
|
148
|
+
// retry, fast, update, now frame diffing). A flag needs the `.option()`
|
|
149
|
+
// declaration below, a field on `CliFlags`, AND a line here; miss this one
|
|
150
|
+
// and the flag is inert with no compile error and no runtime error. It bites
|
|
151
|
+
// hardest for `--no-diff-render`: that is the documented escape hatch for a
|
|
152
|
+
// user whose terminal renders the diffed frame wrongly, and a user who has
|
|
153
|
+
// been told to pass it and sees no change has no next move.
|
|
154
|
+
diffRender: opts.diffRender,
|
|
155
|
+
syncOutput: opts.syncOutput,
|
|
143
156
|
// Site three of three for `--update` / `--no-update` — SEVEN features, one
|
|
144
157
|
// trap (`mouse` P1-2, `team` P1-4, `todo` C-9, render L4, retry, fast, now
|
|
145
158
|
// update). A flag needs the `.option()` declaration below, a field on
|
|
@@ -224,6 +237,20 @@ function makeController(flags, opts = { interactive: true }) {
|
|
|
224
237
|
});
|
|
225
238
|
return { controller, confirmBridge, humanInputBridge };
|
|
226
239
|
}
|
|
240
|
+
/**
|
|
241
|
+
* Raised once per session, on the 0->1 edge of the writer's `fallbacks` counter
|
|
242
|
+
* (tui-input-flicker-fix §5.6 / AC-15).
|
|
243
|
+
*
|
|
244
|
+
* The characteristic failure of frame diffing is SILENT — absolute addressing
|
|
245
|
+
* loses its origin and the user sees a subtly wrong screen — so leaving its only
|
|
246
|
+
* signal behind a slash command the affected user has no reason to run is not an
|
|
247
|
+
* acceptable end state for a fix whose whole premise is "the user should not have
|
|
248
|
+
* to notice the renderer". `warn`, not `error`: a single fallback is self-healing
|
|
249
|
+
* by construction (I-6), and it is worth reporting only because the PATTERN is
|
|
250
|
+
* not.
|
|
251
|
+
*/
|
|
252
|
+
const FRAME_FALLBACK_NOTICE = 'Frame diffing lost sync with the terminal and fell back to a full repaint. ' +
|
|
253
|
+
'If the display looks wrong, restart with `--no-diff-render`.';
|
|
227
254
|
/**
|
|
228
255
|
* The interactive TUI — and the ONLY place that may touch the screen.
|
|
229
256
|
*
|
|
@@ -272,6 +299,40 @@ function runInteractive(flags, extras = {}) {
|
|
|
272
299
|
? tryCreateMouseFilter(process.stdin, (reason) => logger.warn('cli', 'mouse_filter_failed', { reason }))
|
|
273
300
|
: null;
|
|
274
301
|
const disposeMouse = () => mouseFilter?.dispose();
|
|
302
|
+
// --- Frame differ (tui-input-flicker-fix §3.2 / §4.2). -------------------
|
|
303
|
+
//
|
|
304
|
+
// FULL-SCREEN ONLY, and that is invariant I-3 rather than caution: inline mode
|
|
305
|
+
// prints settled entries through Ink's `<Static>`, starts wherever the shell's
|
|
306
|
+
// cursor happened to be and scrolls, so row k of a frame is not absolute row k
|
|
307
|
+
// there and never will be. `--no-diff-render` skips construction entirely,
|
|
308
|
+
// which is what makes rung 2 of the fail-safe ladder byte-identical to a
|
|
309
|
+
// pre-fix build rather than merely equivalent.
|
|
310
|
+
//
|
|
311
|
+
// `rows` is a THUNK, not a snapshot: it is the only input to the I-9 geometry
|
|
312
|
+
// guard and it changes under the differ's feet on every resize.
|
|
313
|
+
const frameWriter = mode === 'fullscreen' && config.diffRender
|
|
314
|
+
? wrapStdoutForFrames(process.stdout, createFrameDiffer({
|
|
315
|
+
sync: config.syncOutput && process.env.TERM !== 'dumb',
|
|
316
|
+
rows: () => process.stdout.rows,
|
|
317
|
+
// The console bridge (`App.tsx`, full-screen only) turns this into
|
|
318
|
+
// `dispatch({type:'notice', level:'warn'})`, which is the only legal
|
|
319
|
+
// user-visible channel with the TUI mounted — a direct write would
|
|
320
|
+
// itself be the foreign write being reported. Latched on the 0->1
|
|
321
|
+
// edge inside the differ: a notice per fallback would be a repaint
|
|
322
|
+
// storm triggered by a repaint problem (§5.6).
|
|
323
|
+
onFirstFallback: () => console.warn(FRAME_FALLBACK_NOTICE),
|
|
324
|
+
}))
|
|
325
|
+
: null;
|
|
326
|
+
// `null` when no writer was built, so `/perf` can say "diff render off" rather
|
|
327
|
+
// than printing zeroes that read like a writer doing nothing (§5.4).
|
|
328
|
+
if (frameWriter)
|
|
329
|
+
setFrameStatsProvider(frameWriter.stats);
|
|
330
|
+
const disposeFrameWriter = () => {
|
|
331
|
+
if (!frameWriter)
|
|
332
|
+
return;
|
|
333
|
+
setFrameStatsProvider(null);
|
|
334
|
+
frameWriter.dispose();
|
|
335
|
+
};
|
|
275
336
|
const replayTranscript = () => {
|
|
276
337
|
if (replayed || mode !== 'fullscreen' || !config.exitTranscript)
|
|
277
338
|
return;
|
|
@@ -348,6 +409,13 @@ function runInteractive(flags, extras = {}) {
|
|
|
348
409
|
// I-5: with mouse support off the REAL stdin is handed over unwrapped, so
|
|
349
410
|
// every existing path is provably unchanged.
|
|
350
411
|
stdin: mouseFilter?.stdin ?? process.stdin,
|
|
412
|
+
// SPREAD, not `frameWriter?.stdout ?? process.stdout`. This call passed no
|
|
413
|
+
// `stdout` at all before, and Ink defaults it to `process.stdout`; omitting
|
|
414
|
+
// the key entirely when the writer is off keeps the options object the same
|
|
415
|
+
// SHAPE it has always had, which is what makes AC-5 / AC-6 ("byte-identical
|
|
416
|
+
// with --no-diff-render, inline mode and `aragon -p`") a statement about
|
|
417
|
+
// the code rather than about Ink's defaulting.
|
|
418
|
+
...(frameWriter ? { stdout: frameWriter.stdout } : {}),
|
|
351
419
|
});
|
|
352
420
|
// H1's primary disarm, and this is the earliest moment at which "this build
|
|
353
421
|
// STARTS" is proven: the module graph loaded, config resolved, Ink mounted
|
|
@@ -402,6 +470,11 @@ function runInteractive(flags, extras = {}) {
|
|
|
402
470
|
.then(() => {
|
|
403
471
|
screen?.restore();
|
|
404
472
|
disposeMouse();
|
|
473
|
+
// BOTH BRANCHES, the same discipline `controller.dispose()` and
|
|
474
|
+
// `updateService?.dispose()` already follow. It drops the `'resize'`
|
|
475
|
+
// listener of I-8 and detaches the `/perf` provider so a later `/perf` in a
|
|
476
|
+
// torn-down tree reports honestly instead of reading a dead closure.
|
|
477
|
+
disposeFrameWriter();
|
|
405
478
|
// Orphaned subagents survive `Ctrl+C` otherwise, and a live child holds
|
|
406
479
|
// the event loop open so `aragon` never returns to the shell (R-15). The
|
|
407
480
|
// App's unmount cleanup also calls this; `dispose()` is idempotent.
|
|
@@ -421,6 +494,7 @@ function runInteractive(flags, extras = {}) {
|
|
|
421
494
|
.catch(() => {
|
|
422
495
|
screen?.restore();
|
|
423
496
|
disposeMouse();
|
|
497
|
+
disposeFrameWriter();
|
|
424
498
|
controller.dispose();
|
|
425
499
|
updateService?.dispose();
|
|
426
500
|
});
|
|
@@ -516,6 +590,10 @@ const CONFIG_SET_KEYS = new Set([
|
|
|
516
590
|
'transcriptRetain',
|
|
517
591
|
'renderGovernor',
|
|
518
592
|
'maxRenderIntervalMs',
|
|
593
|
+
// tui-input-flicker-fix §5.1. Listed here AND cased in the switch below, for
|
|
594
|
+
// the reason `transcriptRetain` records three lines up.
|
|
595
|
+
'diffRender',
|
|
596
|
+
'syncOutput',
|
|
519
597
|
// Without these two, `density` and `hints` would be settable only as one-shot
|
|
520
598
|
// flags and never persist -- inconsistent with every other config field.
|
|
521
599
|
'density',
|
|
@@ -687,6 +765,12 @@ function runConfigSet(key, value) {
|
|
|
687
765
|
case 'maxRenderIntervalMs':
|
|
688
766
|
patch.maxRenderIntervalMs = clampMaxRenderInterval(value, DEFAULT_MAX_RENDER_INTERVAL_MS);
|
|
689
767
|
break;
|
|
768
|
+
case 'diffRender':
|
|
769
|
+
patch.diffRender = value === 'true' || value === '1';
|
|
770
|
+
break;
|
|
771
|
+
case 'syncOutput':
|
|
772
|
+
patch.syncOutput = value === 'true' || value === '1';
|
|
773
|
+
break;
|
|
690
774
|
case 'density':
|
|
691
775
|
patch.density = clampDensity(value, 'comfortable');
|
|
692
776
|
break;
|
|
@@ -1037,6 +1121,15 @@ function buildProgram() {
|
|
|
1037
1121
|
.option('--render-governor', 'Adapt the frame interval under load (the default)')
|
|
1038
1122
|
.option('--no-render-governor', 'Pin the frame interval at 33ms however expensive a frame is')
|
|
1039
1123
|
.option('--max-render-interval <ms>', 'Governor ceiling in ms (33-1000, default 320)')
|
|
1124
|
+
// Composer flicker (tui-input-flicker-fix §5.2). Positive form FIRST for the
|
|
1125
|
+
// tri-state reason recorded five times above: both keys are persisted and
|
|
1126
|
+
// default to `true`, so a negative-only declaration would let commander
|
|
1127
|
+
// synthesize `true` and overwrite a stored `false` on every run — turning the
|
|
1128
|
+
// one documented escape hatch for a mis-rendering terminal into a no-op.
|
|
1129
|
+
.option('--diff-render', 'Repaint only changed rows (default, full-screen only)')
|
|
1130
|
+
.option('--no-diff-render', 'Repaint the whole frame every time (pre-fix behavior)')
|
|
1131
|
+
.option('--sync-output', 'Ask the terminal to present each repaint atomically (the default)')
|
|
1132
|
+
.option('--no-sync-output', 'Do not emit DEC 2026 begin/end synchronized update')
|
|
1040
1133
|
.option('--no-color', 'Disable ANSI color')
|
|
1041
1134
|
.option('--quiet', '(print mode) suppress tool/usage lines on stderr')
|
|
1042
1135
|
.action(async (prompt, opts) => {
|
package/dist/commands/perf.d.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* live panel, because a performance readout that costs a repaint per second to
|
|
16
16
|
* display would be self-defeating.
|
|
17
17
|
*/
|
|
18
|
+
import type { FrameWriterStats } from '../ui/frame-differ.js';
|
|
18
19
|
import type { SlashCommand } from './registry.js';
|
|
19
20
|
export interface PerfSnapshot {
|
|
20
21
|
/** Governor rung, its resolved interval, and the last measured commit cost. */
|
|
@@ -40,6 +41,9 @@ export interface PerfSnapshot {
|
|
|
40
41
|
export declare function setPerfSnapshotProvider(fn: (() => PerfSnapshot | null) | null): void;
|
|
41
42
|
export declare function readPerfSnapshot(): PerfSnapshot | null;
|
|
42
43
|
export declare function setPerfResetHook(fn: (() => void) | null): void;
|
|
43
|
-
/**
|
|
44
|
+
/** Called by `cli.tsx` after building the writer. Passing `null` detaches. */
|
|
45
|
+
export declare function setFrameStatsProvider(fn: (() => FrameWriterStats) | null): void;
|
|
46
|
+
export declare function readFrameStats(): FrameWriterStats | null;
|
|
47
|
+
/** Format a snapshot as the six-line block documented in §5.4. */
|
|
44
48
|
export declare function formatPerfReport(snap: PerfSnapshot): string;
|
|
45
49
|
export declare const perfCommand: SlashCommand;
|
package/dist/commands/perf.js
CHANGED
|
@@ -30,10 +30,51 @@ let resetGovernor = null;
|
|
|
30
30
|
export function setPerfResetHook(fn) {
|
|
31
31
|
resetGovernor = fn;
|
|
32
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* Frame-writer counters (tui-input-flicker-fix §5.4).
|
|
35
|
+
*
|
|
36
|
+
* A SECOND MODULE-LEVEL SLOT rather than a prop on `App`, and that is this
|
|
37
|
+
* file's own documented convention rather than a shortcut: the writer is built
|
|
38
|
+
* in `runInteractive`, lives entirely outside React, and its counters never
|
|
39
|
+
* render — so routing them through a 1900-line component's props would buy
|
|
40
|
+
* nothing and cost `App.tsx` plus `app.test.tsx` churn (P1-2).
|
|
41
|
+
*
|
|
42
|
+
* `null` means the writer was never built — `--no-diff-render`, inline mode, or
|
|
43
|
+
* a non-interactive path. `/perf` says so in words, so "the flag is off" and
|
|
44
|
+
* "the writer crashed" never look alike.
|
|
45
|
+
*/
|
|
46
|
+
let frameStats = null;
|
|
47
|
+
/** Called by `cli.tsx` after building the writer. Passing `null` detaches. */
|
|
48
|
+
export function setFrameStatsProvider(fn) {
|
|
49
|
+
frameStats = fn;
|
|
50
|
+
}
|
|
51
|
+
export function readFrameStats() {
|
|
52
|
+
return frameStats ? frameStats() : null;
|
|
53
|
+
}
|
|
33
54
|
function mb(bytes) {
|
|
34
55
|
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
35
56
|
}
|
|
36
|
-
|
|
57
|
+
function ratio(total, frames) {
|
|
58
|
+
return frames > 0 ? (total / frames).toFixed(1) : '0.0';
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The writer line (§5.4), in this function's existing ASCII separator shape.
|
|
62
|
+
*
|
|
63
|
+
* `fallbacks > 0` on a healthy session is the single most useful diagnostic this
|
|
64
|
+
* feature can emit: it means something wrote to stdout behind Ink's back and
|
|
65
|
+
* absolute addressing lost its origin.
|
|
66
|
+
*/
|
|
67
|
+
function formatWriterLine() {
|
|
68
|
+
const stats = readFrameStats();
|
|
69
|
+
if (!stats)
|
|
70
|
+
return 'writer full - diff render off';
|
|
71
|
+
const bytesPerFrame = stats.framesTotal > 0 ? Math.round(stats.bytesWritten / stats.framesTotal) : 0;
|
|
72
|
+
return (`writer diff - frames ${stats.framesTotal} ` +
|
|
73
|
+
`(diffed ${stats.framesDiffed}, full ${stats.framesFull}) - ` +
|
|
74
|
+
`lines/frame ${ratio(stats.linesWritten, stats.framesTotal)} - ` +
|
|
75
|
+
`bytes/frame ${bytesPerFrame} - fallbacks ${stats.fallbacks}`);
|
|
76
|
+
}
|
|
77
|
+
/** Format a snapshot as the six-line block documented in §5.4. */
|
|
37
78
|
export function formatPerfReport(snap) {
|
|
38
79
|
const caches = renderCacheStats();
|
|
39
80
|
const eco = snap.rung > 0 ? ' - eco' : '';
|
|
@@ -49,6 +90,7 @@ export function formatPerfReport(snap) {
|
|
|
49
90
|
`hl ${caches.highlightEntries} (${mb(caches.highlightBytes)}) - ` +
|
|
50
91
|
`lines ${caches.lineEntries}`,
|
|
51
92
|
`mode ${snap.mode} - viewport ${snap.viewportRows} rows - offset ${snap.offset}`,
|
|
93
|
+
formatWriterLine(),
|
|
52
94
|
].join('\n');
|
|
53
95
|
}
|
|
54
96
|
export const perfCommand = {
|
package/dist/config/env.d.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* ARAGON_LOG_LEVEL,
|
|
14
14
|
* ARAGON_LOG_FILE, ARAGON_LOG_DIR, ARAGON_TRANSCRIPT_RETAIN,
|
|
15
15
|
* ARAGON_RENDER_GOVERNOR, ARAGON_MAX_RENDER_INTERVAL_MS,
|
|
16
|
+
* ARAGON_DIFF_RENDER, ARAGON_SYNC_OUTPUT,
|
|
16
17
|
* ARAGON_UPDATE.
|
|
17
18
|
*
|
|
18
19
|
* READ ELSEWHERE, ON PURPOSE: `ARAGON_UPDATE_REGISTRY` and `npm_config_registry`
|
package/dist/config/env.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* ARAGON_LOG_LEVEL,
|
|
14
14
|
* ARAGON_LOG_FILE, ARAGON_LOG_DIR, ARAGON_TRANSCRIPT_RETAIN,
|
|
15
15
|
* ARAGON_RENDER_GOVERNOR, ARAGON_MAX_RENDER_INTERVAL_MS,
|
|
16
|
+
* ARAGON_DIFF_RENDER, ARAGON_SYNC_OUTPUT,
|
|
16
17
|
* ARAGON_UPDATE.
|
|
17
18
|
*
|
|
18
19
|
* READ ELSEWHERE, ON PURPOSE: `ARAGON_UPDATE_REGISTRY` and `npm_config_registry`
|
|
@@ -347,6 +348,22 @@ export function readEnvConfig() {
|
|
|
347
348
|
if (Number.isFinite(parsed) && parsed > 0)
|
|
348
349
|
partial.maxRenderIntervalMs = parsed;
|
|
349
350
|
}
|
|
351
|
+
// ARAGON_DIFF_RENDER=0 restores the pre-fix full-frame repaint;
|
|
352
|
+
// ARAGON_SYNC_OUTPUT=0 drops the DEC 2026 envelope (tui-input-flicker-fix
|
|
353
|
+
// §5.3). Parsed by the POSITIVE list, exactly as ARAGON_FULLSCREEN /
|
|
354
|
+
// ARAGON_MOUSE / ARAGON_PLAN / ARAGON_TEAM / ARAGON_TODO / ARAGON_RENDER_GOVERNOR
|
|
355
|
+
// are — NOT by `envBool` below, whose NEGATIVE list disagrees with it on values
|
|
356
|
+
// like `ARAGON_DIFF_RENDER=disable`.
|
|
357
|
+
const diffRender = process.env.ARAGON_DIFF_RENDER?.trim().toLowerCase();
|
|
358
|
+
if (diffRender !== undefined && diffRender.length > 0) {
|
|
359
|
+
partial.diffRender =
|
|
360
|
+
diffRender === '1' || diffRender === 'true' || diffRender === 'on' || diffRender === 'yes';
|
|
361
|
+
}
|
|
362
|
+
const syncOutput = process.env.ARAGON_SYNC_OUTPUT?.trim().toLowerCase();
|
|
363
|
+
if (syncOutput !== undefined && syncOutput.length > 0) {
|
|
364
|
+
partial.syncOutput =
|
|
365
|
+
syncOutput === '1' || syncOutput === 'true' || syncOutput === 'on' || syncOutput === 'yes';
|
|
366
|
+
}
|
|
350
367
|
const log = readEnvLogConfig();
|
|
351
368
|
if (log)
|
|
352
369
|
partial.log = log;
|
package/dist/config/load.d.ts
CHANGED
|
@@ -106,6 +106,19 @@ export interface CliFlags {
|
|
|
106
106
|
renderGovernor?: boolean;
|
|
107
107
|
/** `--max-render-interval <ms>` — the governor ceiling for this run (L4). */
|
|
108
108
|
maxRenderInterval?: string | number;
|
|
109
|
+
/**
|
|
110
|
+
* `--diff-render` ⇒ true, `--no-diff-render` ⇒ false, absent ⇒ undefined
|
|
111
|
+
* (tui-input-flicker-fix §5.2).
|
|
112
|
+
*
|
|
113
|
+
* DECLARED AS A PAIR for the reason `--render-governor` records one field up:
|
|
114
|
+
* `diffRender` is PERSISTED and defaults to `true`, so a lone
|
|
115
|
+
* `--no-diff-render` would make commander default `opts.diffRender` to `true`
|
|
116
|
+
* and silently overwrite a stored `false` on every run that passed no flag at
|
|
117
|
+
* all — i.e. the documented escape hatch would quietly un-set itself.
|
|
118
|
+
*/
|
|
119
|
+
diffRender?: boolean;
|
|
120
|
+
/** `--sync-output` ⇒ true, `--no-sync-output` ⇒ false, absent ⇒ undefined (§5.2). */
|
|
121
|
+
syncOutput?: boolean;
|
|
109
122
|
/**
|
|
110
123
|
* `--retry` ⇒ true, `--no-retry` ⇒ false, absent ⇒ undefined
|
|
111
124
|
* (llm-api-retry-backoff §6.2).
|
package/dist/config/load.js
CHANGED
|
@@ -227,6 +227,35 @@ function resolveRenderGovernor(flags, env, file) {
|
|
|
227
227
|
return file.renderGovernor;
|
|
228
228
|
return DEFAULT_CONFIG.renderGovernor;
|
|
229
229
|
}
|
|
230
|
+
/**
|
|
231
|
+
* Resolve per-line frame diffing: flag › env › file › default `true`
|
|
232
|
+
* (tui-input-flicker-fix §5.1).
|
|
233
|
+
*
|
|
234
|
+
* Shaped exactly like `resolveRenderGovernor` above, and for the same reason:
|
|
235
|
+
* there are no heuristics for an explicit `true` to override, so a persisted
|
|
236
|
+
* `true` is a value rather than an opinion that has to be told apart from
|
|
237
|
+
* silence. `!== undefined` on the flag is what makes `--no-diff-render` beat a
|
|
238
|
+
* config file that left the key at its default.
|
|
239
|
+
*/
|
|
240
|
+
function resolveDiffRender(flags, env, file) {
|
|
241
|
+
if (flags.diffRender !== undefined)
|
|
242
|
+
return flags.diffRender;
|
|
243
|
+
if (env.diffRender !== undefined)
|
|
244
|
+
return env.diffRender;
|
|
245
|
+
if (file.diffRender !== undefined)
|
|
246
|
+
return file.diffRender;
|
|
247
|
+
return DEFAULT_CONFIG.diffRender;
|
|
248
|
+
}
|
|
249
|
+
/** Same ladder for the DEC 2026 envelope (§5.1). */
|
|
250
|
+
function resolveSyncOutput(flags, env, file) {
|
|
251
|
+
if (flags.syncOutput !== undefined)
|
|
252
|
+
return flags.syncOutput;
|
|
253
|
+
if (env.syncOutput !== undefined)
|
|
254
|
+
return env.syncOutput;
|
|
255
|
+
if (file.syncOutput !== undefined)
|
|
256
|
+
return file.syncOutput;
|
|
257
|
+
return DEFAULT_CONFIG.syncOutput;
|
|
258
|
+
}
|
|
230
259
|
/** Merge the `log` section across all four layers (defaults › file › env › flags). */
|
|
231
260
|
function resolveLogConfig(flags, env, file) {
|
|
232
261
|
const merged = clampLogConfig({
|
|
@@ -421,6 +450,8 @@ export function loadConfig(flags = {}) {
|
|
|
421
450
|
...(requestedRetain < transcriptRetain ? { transcriptRetainRequested: requestedRetain } : {}),
|
|
422
451
|
renderGovernor: resolveRenderGovernor(flags, env.partial, file),
|
|
423
452
|
maxRenderIntervalMs: clampMaxRenderInterval(pick(flags.maxRenderInterval, env.partial.maxRenderIntervalMs, file.maxRenderIntervalMs), DEFAULT_CONFIG.maxRenderIntervalMs),
|
|
453
|
+
diffRender: resolveDiffRender(flags, env.partial, file),
|
|
454
|
+
syncOutput: resolveSyncOutput(flags, env.partial, file),
|
|
424
455
|
confirmTools,
|
|
425
456
|
toolTimeoutMs,
|
|
426
457
|
idleTimeoutMs,
|
package/dist/config/schema.d.ts
CHANGED
|
@@ -98,6 +98,23 @@ export declare const DEFAULT_RENDER_GOVERNOR = true;
|
|
|
98
98
|
*/
|
|
99
99
|
export declare const DEFAULT_MAX_RENDER_INTERVAL_MS = 320;
|
|
100
100
|
export declare function clampMaxRenderInterval(v: unknown, fallback: number): number;
|
|
101
|
+
/**
|
|
102
|
+
* Repaint only the frame lines that changed (full-screen only).
|
|
103
|
+
*
|
|
104
|
+
* ON by default, and `--no-diff-render` restores today's byte stream EXACTLY —
|
|
105
|
+
* the interposer is not constructed at all, so a regression on an exotic
|
|
106
|
+
* terminal is one flag away from being neutralised (§3.5 rung 2).
|
|
107
|
+
*/
|
|
108
|
+
export declare const DEFAULT_DIFF_RENDER = true;
|
|
109
|
+
/**
|
|
110
|
+
* Wrap each repaint batch in DEC 2026 begin/end synchronized update.
|
|
111
|
+
*
|
|
112
|
+
* ON by default with NO capability probe (§3.3): terminals that do not implement
|
|
113
|
+
* mode 2026 parse the CSI sequence, find an unknown private mode and discard it,
|
|
114
|
+
* so a false positive costs nothing — and a false negative costs nothing either,
|
|
115
|
+
* because the frame differ already removes the flicker on its own.
|
|
116
|
+
*/
|
|
117
|
+
export declare const DEFAULT_SYNC_OUTPUT = true;
|
|
101
118
|
/** `ask_user` calls allowed per user turn. */
|
|
102
119
|
export declare const DEFAULT_PLAN_MAX_ASK_ROUNDS = 4;
|
|
103
120
|
/**
|
|
@@ -530,6 +547,10 @@ export interface PersistedConfig {
|
|
|
530
547
|
renderGovernor: boolean;
|
|
531
548
|
/** Ceiling of the governor ladder in ms; `33` flattens it (L4). */
|
|
532
549
|
maxRenderIntervalMs: number;
|
|
550
|
+
/** Repaint only the frame rows that changed (tui-input-flicker-fix §5.1). */
|
|
551
|
+
diffRender: boolean;
|
|
552
|
+
/** Wrap each repaint batch in DEC 2026 synchronized update (§5.1). */
|
|
553
|
+
syncOutput: boolean;
|
|
533
554
|
confirmTools: boolean;
|
|
534
555
|
toolTimeoutMs: number;
|
|
535
556
|
idleTimeoutMs: number;
|
|
@@ -671,6 +692,20 @@ export interface CliConfig {
|
|
|
671
692
|
renderGovernor: boolean;
|
|
672
693
|
/** Ceiling of the governor ladder in ms; `33` flattens it (L4). */
|
|
673
694
|
maxRenderIntervalMs: number;
|
|
695
|
+
/**
|
|
696
|
+
* Repaint only the frame rows that changed (tui-input-flicker-fix §5.1).
|
|
697
|
+
*
|
|
698
|
+
* IT MUST EXIST HERE AND NOT ONLY IN `PersistedConfig`, for the reason
|
|
699
|
+
* `showThinking` / `liveToolOutput` / `update` each record: `runInteractive`
|
|
700
|
+
* reads `config.diffRender`, where `config` is a `CliConfig`, so a key added to
|
|
701
|
+
* the persisted shape alone would round-trip through `config set`, appear in
|
|
702
|
+
* `config list` — and never reach the one gate that constructs the writer.
|
|
703
|
+
* `--no-diff-render` and `ARAGON_DIFF_RENDER=0` would parse and be discarded,
|
|
704
|
+
* with no compile error and no runtime error.
|
|
705
|
+
*/
|
|
706
|
+
diffRender: boolean;
|
|
707
|
+
/** Wrap each repaint batch in DEC 2026 synchronized update (§5.1). */
|
|
708
|
+
syncOutput: boolean;
|
|
674
709
|
confirmTools: boolean;
|
|
675
710
|
toolTimeoutMs: number;
|
|
676
711
|
idleTimeoutMs: number;
|
package/dist/config/schema.js
CHANGED
|
@@ -145,6 +145,30 @@ export function clampMaxRenderInterval(v, fallback) {
|
|
|
145
145
|
return clampInt(v, fallback, RENDER_INTERVAL_RANGE);
|
|
146
146
|
}
|
|
147
147
|
// ---------------------------------------------------------------------------
|
|
148
|
+
// Frame diffing (tui-input-flicker-fix §5.1)
|
|
149
|
+
//
|
|
150
|
+
// TWO TROUBLESHOOTING SWITCHES, in the same class as `historyEnabled`: neither
|
|
151
|
+
// appears on the settings overlay, because a user who needs them is debugging a
|
|
152
|
+
// terminal, not adjusting a preference.
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
/**
|
|
155
|
+
* Repaint only the frame lines that changed (full-screen only).
|
|
156
|
+
*
|
|
157
|
+
* ON by default, and `--no-diff-render` restores today's byte stream EXACTLY —
|
|
158
|
+
* the interposer is not constructed at all, so a regression on an exotic
|
|
159
|
+
* terminal is one flag away from being neutralised (§3.5 rung 2).
|
|
160
|
+
*/
|
|
161
|
+
export const DEFAULT_DIFF_RENDER = true;
|
|
162
|
+
/**
|
|
163
|
+
* Wrap each repaint batch in DEC 2026 begin/end synchronized update.
|
|
164
|
+
*
|
|
165
|
+
* ON by default with NO capability probe (§3.3): terminals that do not implement
|
|
166
|
+
* mode 2026 parse the CSI sequence, find an unknown private mode and discard it,
|
|
167
|
+
* so a false positive costs nothing — and a false negative costs nothing either,
|
|
168
|
+
* because the frame differ already removes the flicker on its own.
|
|
169
|
+
*/
|
|
170
|
+
export const DEFAULT_SYNC_OUTPUT = true;
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
148
172
|
// Plan mode (plan-mode §4.4)
|
|
149
173
|
//
|
|
150
174
|
// THREE FLAT KEYS, not a nested `planMode` object. `store.ts` documents that its
|
|
@@ -669,6 +693,8 @@ export const DEFAULT_CONFIG = {
|
|
|
669
693
|
transcriptRetain: DEFAULT_TRANSCRIPT_RETAIN,
|
|
670
694
|
renderGovernor: DEFAULT_RENDER_GOVERNOR,
|
|
671
695
|
maxRenderIntervalMs: DEFAULT_MAX_RENDER_INTERVAL_MS,
|
|
696
|
+
diffRender: DEFAULT_DIFF_RENDER,
|
|
697
|
+
syncOutput: DEFAULT_SYNC_OUTPUT,
|
|
672
698
|
confirmTools: false,
|
|
673
699
|
toolTimeoutMs: DEFAULT_TOOL_TIMEOUT_MS,
|
|
674
700
|
idleTimeoutMs: DEFAULT_IDLE_TIMEOUT_MS,
|
package/dist/ui/PromptInput.d.ts
CHANGED
|
@@ -7,6 +7,16 @@
|
|
|
7
7
|
* The buffer/cursor transforms (`applyEdit`, `moveVertical`) and the suggestion
|
|
8
8
|
* computations (`slashSuggestions`, `fileTokenAt`) are pure module functions,
|
|
9
9
|
* exported for `input.test.ts`, so the component itself stays thin.
|
|
10
|
+
*
|
|
11
|
+
* THE EDITOR'S FIVE PIECES OF STATE LIVE IN ONE REDUCER, and every branch of the
|
|
12
|
+
* input handler performs AT MOST ONE `dispatch` (tui-input-flicker-fix §3.4).
|
|
13
|
+
* Ink mounts a LEGACY React root, where `setState` outside a React event handler
|
|
14
|
+
* is not batched — and `useInput` runs from a stdin `'data'` listener, which is
|
|
15
|
+
* outside React entirely. The five `setState` calls one keystroke used to make
|
|
16
|
+
* were five commits, five whole-tree Yoga layouts and, through Ink's 32 ms
|
|
17
|
+
* leading+trailing throttle, two full-frame repaints per character. `fileMatches`
|
|
18
|
+
* stays a plain `useState` because it is written from the debounced glob effect
|
|
19
|
+
* rather than from a key.
|
|
10
20
|
*/
|
|
11
21
|
import React from 'react';
|
|
12
22
|
import type { Theme } from './theme.js';
|
package/dist/ui/PromptInput.js
CHANGED
|
@@ -8,13 +8,24 @@ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
|
8
8
|
* The buffer/cursor transforms (`applyEdit`, `moveVertical`) and the suggestion
|
|
9
9
|
* computations (`slashSuggestions`, `fileTokenAt`) are pure module functions,
|
|
10
10
|
* exported for `input.test.ts`, so the component itself stays thin.
|
|
11
|
+
*
|
|
12
|
+
* THE EDITOR'S FIVE PIECES OF STATE LIVE IN ONE REDUCER, and every branch of the
|
|
13
|
+
* input handler performs AT MOST ONE `dispatch` (tui-input-flicker-fix §3.4).
|
|
14
|
+
* Ink mounts a LEGACY React root, where `setState` outside a React event handler
|
|
15
|
+
* is not batched — and `useInput` runs from a stdin `'data'` listener, which is
|
|
16
|
+
* outside React entirely. The five `setState` calls one keystroke used to make
|
|
17
|
+
* were five commits, five whole-tree Yoga layouts and, through Ink's 32 ms
|
|
18
|
+
* leading+trailing throttle, two full-frame repaints per character. `fileMatches`
|
|
19
|
+
* stays a plain `useState` because it is written from the debounced glob effect
|
|
20
|
+
* rather than from a key.
|
|
11
21
|
*/
|
|
12
|
-
import { useEffect, useRef, useState } from 'react';
|
|
22
|
+
import { useEffect, useReducer, useRef, useState } from 'react';
|
|
13
23
|
import { Box, Text, useInput } from 'ink';
|
|
14
24
|
import { glob as tinyGlob } from 'tinyglobby';
|
|
15
25
|
import { pickGlyphs } from './glyphs.js';
|
|
16
26
|
import { recognize } from '../input/keymap.js';
|
|
17
27
|
import { AutocompletePopup } from './AutocompletePopup.js';
|
|
28
|
+
import { editorReducer, INITIAL_EDITOR_STATE } from './editor-reducer.js';
|
|
18
29
|
// ---------------------------------------------------------------------------
|
|
19
30
|
// Pure editing helpers (unit-tested in input.test.ts)
|
|
20
31
|
// ---------------------------------------------------------------------------
|
|
@@ -128,11 +139,8 @@ function isControlSeq(input) {
|
|
|
128
139
|
// Component
|
|
129
140
|
// ---------------------------------------------------------------------------
|
|
130
141
|
export function PromptInput({ isActive, running, history, commands, cwd, theme, caps, popupMaxRows, agentMode, onSubmit, onHelp, bordered, onDraftChange, }) {
|
|
131
|
-
const [
|
|
132
|
-
const
|
|
133
|
-
const [historyIndex, setHistoryIndex] = useState(null);
|
|
134
|
-
const [dismissed, setDismissed] = useState(false);
|
|
135
|
-
const [sel, setSel] = useState(0);
|
|
142
|
+
const [editor, dispatch] = useReducer(editorReducer, INITIAL_EDITOR_STATE);
|
|
143
|
+
const { buffer, cursor, historyIndex, dismissed, sel } = editor;
|
|
136
144
|
const [fileMatches, setFileMatches] = useState([]);
|
|
137
145
|
// --- Derived suggestion state (shared by render + input handler). --------
|
|
138
146
|
const slashSug = dismissed ? null : slashSuggestions(buffer, commands);
|
|
@@ -189,46 +197,36 @@ export function PromptInput({ isActive, running, history, commands, cwd, theme,
|
|
|
189
197
|
onDraftChange(hasDraft);
|
|
190
198
|
}, [hasDraft, onDraftChange]);
|
|
191
199
|
// --- Mutators. -----------------------------------------------------------
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
setSel(0);
|
|
196
|
-
};
|
|
200
|
+
// ONE DISPATCH EACH, and that is the rule §3.4 asks a reviewer to check line
|
|
201
|
+
// by line. Two dispatches from one key would put the legacy root back to two
|
|
202
|
+
// commits and undo the whole of F3 for that branch.
|
|
197
203
|
const insert = (text) => {
|
|
198
|
-
|
|
199
|
-
setCursor(cursor + text.length);
|
|
200
|
-
resetDraftFlags();
|
|
204
|
+
dispatch({ type: 'insert', text });
|
|
201
205
|
};
|
|
202
206
|
const submit = (text) => {
|
|
203
207
|
onSubmit(text);
|
|
204
|
-
|
|
205
|
-
setCursor(0);
|
|
206
|
-
resetDraftFlags();
|
|
208
|
+
dispatch({ type: 'clear' });
|
|
207
209
|
};
|
|
208
210
|
const recallUp = () => {
|
|
209
211
|
if (history.length === 0)
|
|
210
212
|
return;
|
|
211
213
|
const idx = historyIndex === null ? history.length - 1 : Math.max(0, historyIndex - 1);
|
|
212
214
|
const value = history[idx] ?? '';
|
|
213
|
-
|
|
214
|
-
setBuffer(value);
|
|
215
|
-
setCursor(value.length);
|
|
215
|
+
dispatch({ type: 'recall', buffer: value, cursor: value.length, historyIndex: idx });
|
|
216
216
|
};
|
|
217
217
|
const recallDown = () => {
|
|
218
218
|
if (historyIndex === null)
|
|
219
219
|
return;
|
|
220
220
|
const idx = historyIndex + 1;
|
|
221
|
+
// Past the newest entry the walk ends and the draft goes back to empty —
|
|
222
|
+
// `historyIndex: null` is what tells `verticalOrHistory` the user is editing
|
|
223
|
+
// again rather than still stepping.
|
|
221
224
|
if (idx >= history.length) {
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
setCursor(0);
|
|
225
|
-
}
|
|
226
|
-
else {
|
|
227
|
-
const value = history[idx] ?? '';
|
|
228
|
-
setHistoryIndex(idx);
|
|
229
|
-
setBuffer(value);
|
|
230
|
-
setCursor(value.length);
|
|
225
|
+
dispatch({ type: 'recall', buffer: '', cursor: 0, historyIndex: null });
|
|
226
|
+
return;
|
|
231
227
|
}
|
|
228
|
+
const value = history[idx] ?? '';
|
|
229
|
+
dispatch({ type: 'recall', buffer: value, cursor: value.length, historyIndex: idx });
|
|
232
230
|
};
|
|
233
231
|
const completeSelection = () => {
|
|
234
232
|
const chosen = popupItems[clampedSel];
|
|
@@ -236,20 +234,17 @@ export function PromptInput({ isActive, running, history, commands, cwd, theme,
|
|
|
236
234
|
return;
|
|
237
235
|
if (popupKind === 'slash') {
|
|
238
236
|
const next = `${chosen.label} `;
|
|
239
|
-
|
|
240
|
-
setCursor(next.length);
|
|
237
|
+
dispatch({ type: 'replace', buffer: next, cursor: next.length });
|
|
241
238
|
}
|
|
242
239
|
else if (popupKind === 'file' && fileTok) {
|
|
243
240
|
const next = buffer.slice(0, fileTok.start) + chosen.label + buffer.slice(fileTok.end);
|
|
244
|
-
|
|
245
|
-
setCursor(fileTok.start + chosen.label.length);
|
|
241
|
+
dispatch({ type: 'replace', buffer: next, cursor: fileTok.start + chosen.label.length });
|
|
246
242
|
}
|
|
247
|
-
resetDraftFlags();
|
|
248
243
|
};
|
|
249
244
|
const verticalOrHistory = (dir) => {
|
|
250
245
|
const moved = moveVertical(buffer, cursor, dir);
|
|
251
246
|
if (moved) {
|
|
252
|
-
|
|
247
|
+
dispatch({ type: 'moveCursor', cursor: moved.cursor });
|
|
253
248
|
return;
|
|
254
249
|
}
|
|
255
250
|
// At an edge: recall history only from an empty buffer or while already
|
|
@@ -274,11 +269,11 @@ export function PromptInput({ isActive, running, history, commands, cwd, theme,
|
|
|
274
269
|
// Popup navigation owns Up/Down/Tab/→/Enter/Esc while it is open.
|
|
275
270
|
if (popupItems.length > 0) {
|
|
276
271
|
if (key.upArrow) {
|
|
277
|
-
|
|
272
|
+
dispatch({ type: 'select', sel: Math.max(0, clampedSel - 1) });
|
|
278
273
|
return;
|
|
279
274
|
}
|
|
280
275
|
if (key.downArrow) {
|
|
281
|
-
|
|
276
|
+
dispatch({ type: 'select', sel: Math.min(popupItems.length - 1, clampedSel + 1) });
|
|
282
277
|
return;
|
|
283
278
|
}
|
|
284
279
|
if (key.tab || (key.rightArrow && cursor >= buffer.length)) {
|
|
@@ -292,7 +287,7 @@ export function PromptInput({ isActive, running, history, commands, cwd, theme,
|
|
|
292
287
|
return;
|
|
293
288
|
}
|
|
294
289
|
if (key.escape) {
|
|
295
|
-
|
|
290
|
+
dispatch({ type: 'dismiss' });
|
|
296
291
|
return;
|
|
297
292
|
}
|
|
298
293
|
// Any other key falls through so typing keeps filtering the popup.
|
|
@@ -303,9 +298,7 @@ export function PromptInput({ isActive, running, history, commands, cwd, theme,
|
|
|
303
298
|
const intent = recognize(input, key);
|
|
304
299
|
if (intent) {
|
|
305
300
|
const next = applyEdit(buffer, cursor, intent);
|
|
306
|
-
|
|
307
|
-
setCursor(next.cursor);
|
|
308
|
-
resetDraftFlags();
|
|
301
|
+
dispatch({ type: 'replace', buffer: next.buffer, cursor: next.cursor });
|
|
309
302
|
return;
|
|
310
303
|
}
|
|
311
304
|
if (key.ctrl)
|
|
@@ -321,19 +314,19 @@ export function PromptInput({ isActive, running, history, commands, cwd, theme,
|
|
|
321
314
|
return;
|
|
322
315
|
}
|
|
323
316
|
if (key.backspace || key.delete) {
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
}
|
|
317
|
+
// The `cursor > 0` guard lives in the reducer now, so this branch is one
|
|
318
|
+
// unconditional dispatch and a no-op at the left edge returns the same
|
|
319
|
+
// state object — which React bails out on, exactly as the old
|
|
320
|
+
// `if (cursor > 0)` skipped the `setState`s.
|
|
321
|
+
dispatch({ type: 'backspace' });
|
|
329
322
|
return;
|
|
330
323
|
}
|
|
331
324
|
if (key.leftArrow) {
|
|
332
|
-
|
|
325
|
+
dispatch({ type: 'moveCursor', cursor: Math.max(0, cursor - 1) });
|
|
333
326
|
return;
|
|
334
327
|
}
|
|
335
328
|
if (key.rightArrow) {
|
|
336
|
-
|
|
329
|
+
dispatch({ type: 'moveCursor', cursor: Math.min(buffer.length, cursor + 1) });
|
|
337
330
|
return;
|
|
338
331
|
}
|
|
339
332
|
// `!key.shift` is load-bearing: Shift+↑/↓ is the viewport's line-scroll
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Composer editor state — F3 of tui-input-flicker-fix (§3.4).
|
|
3
|
+
*
|
|
4
|
+
* Ink creates its container with tag `0` — a LEGACY React root
|
|
5
|
+
* (`ink/build/ink.js:59-61`). In a legacy root, `setState` called outside a
|
|
6
|
+
* React event handler is NOT batched: each call schedules and immediately
|
|
7
|
+
* flushes a synchronous render. `useInput`'s callback runs from a stdin
|
|
8
|
+
* `'data'` listener, which is outside React entirely, so `PromptInput.insert`'s
|
|
9
|
+
* five `setState` calls became five commits, five whole-tree Yoga layouts, and
|
|
10
|
+
* — through Ink's 32 ms leading+trailing throttle — TWO full-frame repaints per
|
|
11
|
+
* printable character (§2 R3). The flicker got worse the faster the user typed,
|
|
12
|
+
* which is exactly what the report described.
|
|
13
|
+
*
|
|
14
|
+
* Collapsing the five always-together pieces of state into one reducer makes a
|
|
15
|
+
* keystroke cost one commit. `fileMatches` deliberately stays a separate
|
|
16
|
+
* `useState` in the component: it is written from the debounced async glob
|
|
17
|
+
* effect, not from the key handler, so folding it in here would couple two
|
|
18
|
+
* unrelated update sources.
|
|
19
|
+
*
|
|
20
|
+
* THE RULE A REVIEWER SHOULD CHECK LINE BY LINE: every branch of the `useInput`
|
|
21
|
+
* callback performs at most ONE `dispatch` and then returns.
|
|
22
|
+
*
|
|
23
|
+
* Extracted from the component so it is unit-testable without mounting Ink.
|
|
24
|
+
*/
|
|
25
|
+
export interface EditorState {
|
|
26
|
+
buffer: string;
|
|
27
|
+
cursor: number;
|
|
28
|
+
historyIndex: number | null;
|
|
29
|
+
dismissed: boolean;
|
|
30
|
+
sel: number;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* A `type` and not an `interface` because it is a discriminated union — the one
|
|
34
|
+
* documented exception to this package's interface-first convention.
|
|
35
|
+
*/
|
|
36
|
+
export type EditorAction = {
|
|
37
|
+
type: 'insert';
|
|
38
|
+
text: string;
|
|
39
|
+
} | {
|
|
40
|
+
type: 'replace';
|
|
41
|
+
buffer: string;
|
|
42
|
+
cursor: number;
|
|
43
|
+
} | {
|
|
44
|
+
type: 'backspace';
|
|
45
|
+
} | {
|
|
46
|
+
type: 'moveCursor';
|
|
47
|
+
cursor: number;
|
|
48
|
+
} | {
|
|
49
|
+
type: 'recall';
|
|
50
|
+
buffer: string;
|
|
51
|
+
cursor: number;
|
|
52
|
+
historyIndex: number | null;
|
|
53
|
+
} | {
|
|
54
|
+
type: 'select';
|
|
55
|
+
sel: number;
|
|
56
|
+
} | {
|
|
57
|
+
type: 'dismiss';
|
|
58
|
+
} | {
|
|
59
|
+
type: 'clear';
|
|
60
|
+
};
|
|
61
|
+
export declare const INITIAL_EDITOR_STATE: EditorState;
|
|
62
|
+
export declare function editorReducer(state: EditorState, action: EditorAction): EditorState;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Composer editor state — F3 of tui-input-flicker-fix (§3.4).
|
|
3
|
+
*
|
|
4
|
+
* Ink creates its container with tag `0` — a LEGACY React root
|
|
5
|
+
* (`ink/build/ink.js:59-61`). In a legacy root, `setState` called outside a
|
|
6
|
+
* React event handler is NOT batched: each call schedules and immediately
|
|
7
|
+
* flushes a synchronous render. `useInput`'s callback runs from a stdin
|
|
8
|
+
* `'data'` listener, which is outside React entirely, so `PromptInput.insert`'s
|
|
9
|
+
* five `setState` calls became five commits, five whole-tree Yoga layouts, and
|
|
10
|
+
* — through Ink's 32 ms leading+trailing throttle — TWO full-frame repaints per
|
|
11
|
+
* printable character (§2 R3). The flicker got worse the faster the user typed,
|
|
12
|
+
* which is exactly what the report described.
|
|
13
|
+
*
|
|
14
|
+
* Collapsing the five always-together pieces of state into one reducer makes a
|
|
15
|
+
* keystroke cost one commit. `fileMatches` deliberately stays a separate
|
|
16
|
+
* `useState` in the component: it is written from the debounced async glob
|
|
17
|
+
* effect, not from the key handler, so folding it in here would couple two
|
|
18
|
+
* unrelated update sources.
|
|
19
|
+
*
|
|
20
|
+
* THE RULE A REVIEWER SHOULD CHECK LINE BY LINE: every branch of the `useInput`
|
|
21
|
+
* callback performs at most ONE `dispatch` and then returns.
|
|
22
|
+
*
|
|
23
|
+
* Extracted from the component so it is unit-testable without mounting Ink.
|
|
24
|
+
*/
|
|
25
|
+
export const INITIAL_EDITOR_STATE = {
|
|
26
|
+
buffer: '',
|
|
27
|
+
cursor: 0,
|
|
28
|
+
historyIndex: null,
|
|
29
|
+
dismissed: false,
|
|
30
|
+
sel: 0,
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* The three flags `resetDraftFlags()` used to clear with three separate
|
|
34
|
+
* `setState` calls.
|
|
35
|
+
*
|
|
36
|
+
* Every action that CHANGES THE BUFFER resets them, and that is the pre-existing
|
|
37
|
+
* semantics rather than a new rule: an edit invalidates the history cursor (the
|
|
38
|
+
* draft is no longer the recalled entry), un-dismisses the popup (the user is
|
|
39
|
+
* typing again, so suggestions should come back), and re-homes the selection
|
|
40
|
+
* (the candidate list is about to be recomputed).
|
|
41
|
+
*/
|
|
42
|
+
const DRAFT_FLAGS = { historyIndex: null, dismissed: false, sel: 0 };
|
|
43
|
+
export function editorReducer(state, action) {
|
|
44
|
+
switch (action.type) {
|
|
45
|
+
case 'insert':
|
|
46
|
+
return {
|
|
47
|
+
...state,
|
|
48
|
+
...DRAFT_FLAGS,
|
|
49
|
+
buffer: state.buffer.slice(0, state.cursor) + action.text + state.buffer.slice(state.cursor),
|
|
50
|
+
cursor: state.cursor + action.text.length,
|
|
51
|
+
};
|
|
52
|
+
case 'replace':
|
|
53
|
+
return { ...state, ...DRAFT_FLAGS, buffer: action.buffer, cursor: action.cursor };
|
|
54
|
+
case 'backspace': {
|
|
55
|
+
if (state.cursor <= 0)
|
|
56
|
+
return state;
|
|
57
|
+
return {
|
|
58
|
+
...state,
|
|
59
|
+
...DRAFT_FLAGS,
|
|
60
|
+
buffer: state.buffer.slice(0, state.cursor - 1) + state.buffer.slice(state.cursor),
|
|
61
|
+
cursor: state.cursor - 1,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
case 'moveCursor':
|
|
65
|
+
// NO DRAFT-FLAG RESET. Moving the caret is not an edit: clearing
|
|
66
|
+
// `historyIndex` here would end a history walk the moment the user pressed
|
|
67
|
+
// ← to look at what they recalled.
|
|
68
|
+
return state.cursor === action.cursor ? state : { ...state, cursor: action.cursor };
|
|
69
|
+
case 'recall':
|
|
70
|
+
// `historyIndex` is SET, not reset, which is why recall cannot reuse
|
|
71
|
+
// `replace` — the two differ in exactly the field that keeps the walk
|
|
72
|
+
// alive. `dismissed` and `sel` are LEFT ALONE, which is not an oversight:
|
|
73
|
+
// the three `setState` calls this replaces did not touch them either, and
|
|
74
|
+
// an Esc-dismissed popup that reopened when the user stepped through their
|
|
75
|
+
// history would be a behaviour change smuggled in by a refactor whose whole
|
|
76
|
+
// claim is that input semantics are identical (K-7).
|
|
77
|
+
return {
|
|
78
|
+
...state,
|
|
79
|
+
buffer: action.buffer,
|
|
80
|
+
cursor: action.cursor,
|
|
81
|
+
historyIndex: action.historyIndex,
|
|
82
|
+
};
|
|
83
|
+
case 'select':
|
|
84
|
+
return state.sel === action.sel ? state : { ...state, sel: action.sel };
|
|
85
|
+
case 'dismiss':
|
|
86
|
+
return state.dismissed ? state : { ...state, dismissed: true };
|
|
87
|
+
case 'clear':
|
|
88
|
+
return INITIAL_EDITOR_STATE;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Frame differ — F1 of tui-input-flicker-fix (§3.2).
|
|
3
|
+
*
|
|
4
|
+
* Ink 5.2.1 has exactly one repaint strategy: on every commit it serialises the
|
|
5
|
+
* WHOLE frame and hands it to `log-update`, which writes
|
|
6
|
+
* `eraseLines(previousLineCount) + frame` in a single `stdout.write`
|
|
7
|
+
* (`ink/build/log-update.js:17`). `ansiEscapes.eraseLines` walks BOTTOM-UP, so
|
|
8
|
+
* the composer — the last row of the frame — is erased near the start of every
|
|
9
|
+
* repaint and rewritten at the very end of it. During a run that happens 15–30
|
|
10
|
+
* times a second with the composer's bytes unchanged, and the user sees the
|
|
11
|
+
* bottom of their screen flicker while they type (§2 R1/R2).
|
|
12
|
+
*
|
|
13
|
+
* This module recognises that write shape, diffs the new frame against the
|
|
14
|
+
* previous one line by line, and emits only the rows that actually changed, each
|
|
15
|
+
* addressed absolutely. It is PURE: no I/O, no Node globals, no event emitter —
|
|
16
|
+
* `stdout-frame-writer.ts` is what binds it to a real stream. That is what keeps
|
|
17
|
+
* it unit-testable with plain string literals and no TTY.
|
|
18
|
+
*
|
|
19
|
+
* THREE INVARIANTS LIVE HERE AND EACH ONE FAILS SILENTLY IF DROPPED:
|
|
20
|
+
*
|
|
21
|
+
* - I-4 · An emitted line is `CUP · SGR0 · text · SGR0 · CSI K`, IN THAT ORDER,
|
|
22
|
+
* and it never contains `CSI 2K`. Erasing first would blank the row and refill
|
|
23
|
+
* it, which is R1's defect at 1/H the scale but on the one row the user is
|
|
24
|
+
* watching. Overwriting in place means no cell is ever presentably empty; the
|
|
25
|
+
* trailing `CSI K` removes only the residue a longer previous line left, and
|
|
26
|
+
* the trailing SGR reset before it stops a BCE terminal painting that residue
|
|
27
|
+
* in whatever background colour the line ended inside.
|
|
28
|
+
* - I-5 · Every emitted batch ends parked on row `H+1`. Ink's own
|
|
29
|
+
* `previousLineCount` is computed from the string IT produced and is
|
|
30
|
+
* unaffected by what we actually wrote, so a later pass-through's
|
|
31
|
+
* `eraseLines(H+1)` must find the cursor exactly where log-update left it last
|
|
32
|
+
* time. Drop this and one fallback shifts the frame up a row for the rest of
|
|
33
|
+
* the session.
|
|
34
|
+
* - I-6 · Any unrecognised write invalidates the cache. Absolute addressing is
|
|
35
|
+
* valid only while nothing else writes to stdout. Full-screen mode already
|
|
36
|
+
* forbids that, but "forbidden" is not "impossible", and the failure mode of
|
|
37
|
+
* the guard being absent is a permanently misaligned screen while the failure
|
|
38
|
+
* mode of it firing spuriously is one extra full repaint.
|
|
39
|
+
*/
|
|
40
|
+
/**
|
|
41
|
+
* Rebuild `ansiEscapes.eraseLines(count)` byte for byte.
|
|
42
|
+
*
|
|
43
|
+
* Exported for the upgrade tripwire (`frame-differ-ink-shape.test.ts`), which
|
|
44
|
+
* drives the real `ink/build/log-update.js` and asserts this reconstruction is
|
|
45
|
+
* byte-equal to what it produced. If a future Ink changes the shape, that test
|
|
46
|
+
* fails loudly instead of this module silently degrading to pass-through for the
|
|
47
|
+
* rest of the package's life (K-2).
|
|
48
|
+
*/
|
|
49
|
+
export declare function eraseLinesPrefix(count: number): string;
|
|
50
|
+
export interface FrameDifferOptions {
|
|
51
|
+
/** Emit the DEC 2026 envelope around each batch. */
|
|
52
|
+
sync: boolean;
|
|
53
|
+
/**
|
|
54
|
+
* Live terminal height. Read on every `transform`, NEVER cached: it is the
|
|
55
|
+
* only input to the I-9 geometry guard and it changes under the differ's feet
|
|
56
|
+
* on resize. `undefined` ⇒ stand down and pass through.
|
|
57
|
+
*/
|
|
58
|
+
rows: () => number | undefined;
|
|
59
|
+
/** Raised once, on the 0→1 edge of `fallbacks` (§5.6). */
|
|
60
|
+
onFirstFallback?: () => void;
|
|
61
|
+
}
|
|
62
|
+
export interface FrameWriterStats {
|
|
63
|
+
/** Frames the differ EMITTED — `framesDiffed + framesFull`, never pass-throughs. */
|
|
64
|
+
framesTotal: number;
|
|
65
|
+
framesDiffed: number;
|
|
66
|
+
framesFull: number;
|
|
67
|
+
linesWritten: number;
|
|
68
|
+
bytesWritten: number;
|
|
69
|
+
/**
|
|
70
|
+
* Pass-throughs caused by a write the differ did not understand.
|
|
71
|
+
*
|
|
72
|
+
* NOT INVALIDATIONS (AC-11 / K-14). A resize legitimately invalidates and shows
|
|
73
|
+
* up as `framesFull`; counting invalidations here would put AC-7 and AC-11 in
|
|
74
|
+
* direct contradiction and one of them would end up quietly relaxed. It also
|
|
75
|
+
* excludes the two pass-throughs this module inflicts on ITSELF and already
|
|
76
|
+
* knows are benign — the session's seed write and the degenerate-geometry
|
|
77
|
+
* stand-down — because `fallbacks > 0` is documented to mean "something wrote
|
|
78
|
+
* to stdout behind Ink's back" and is what raises the §5.6 notice.
|
|
79
|
+
*/
|
|
80
|
+
fallbacks: number;
|
|
81
|
+
}
|
|
82
|
+
export interface FrameDiffer {
|
|
83
|
+
/**
|
|
84
|
+
* Bytes to write instead of `chunk`; `''` = write nothing; `null` = write
|
|
85
|
+
* `chunk`. The parameter is `string` and only `string` (P1-4): decoding, and
|
|
86
|
+
* the decision about what is even diffable, belong to the writer — the only
|
|
87
|
+
* place untyped input arrives.
|
|
88
|
+
*/
|
|
89
|
+
transform(chunk: string): string | null;
|
|
90
|
+
/** Force the next frame to be a full repaint. */
|
|
91
|
+
invalidate(): void;
|
|
92
|
+
/** Single owner of the counters (P2-2); the writer re-exports this. */
|
|
93
|
+
stats(): FrameWriterStats;
|
|
94
|
+
}
|
|
95
|
+
export declare function createFrameDiffer(options: FrameDifferOptions): FrameDiffer;
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Frame differ — F1 of tui-input-flicker-fix (§3.2).
|
|
3
|
+
*
|
|
4
|
+
* Ink 5.2.1 has exactly one repaint strategy: on every commit it serialises the
|
|
5
|
+
* WHOLE frame and hands it to `log-update`, which writes
|
|
6
|
+
* `eraseLines(previousLineCount) + frame` in a single `stdout.write`
|
|
7
|
+
* (`ink/build/log-update.js:17`). `ansiEscapes.eraseLines` walks BOTTOM-UP, so
|
|
8
|
+
* the composer — the last row of the frame — is erased near the start of every
|
|
9
|
+
* repaint and rewritten at the very end of it. During a run that happens 15–30
|
|
10
|
+
* times a second with the composer's bytes unchanged, and the user sees the
|
|
11
|
+
* bottom of their screen flicker while they type (§2 R1/R2).
|
|
12
|
+
*
|
|
13
|
+
* This module recognises that write shape, diffs the new frame against the
|
|
14
|
+
* previous one line by line, and emits only the rows that actually changed, each
|
|
15
|
+
* addressed absolutely. It is PURE: no I/O, no Node globals, no event emitter —
|
|
16
|
+
* `stdout-frame-writer.ts` is what binds it to a real stream. That is what keeps
|
|
17
|
+
* it unit-testable with plain string literals and no TTY.
|
|
18
|
+
*
|
|
19
|
+
* THREE INVARIANTS LIVE HERE AND EACH ONE FAILS SILENTLY IF DROPPED:
|
|
20
|
+
*
|
|
21
|
+
* - I-4 · An emitted line is `CUP · SGR0 · text · SGR0 · CSI K`, IN THAT ORDER,
|
|
22
|
+
* and it never contains `CSI 2K`. Erasing first would blank the row and refill
|
|
23
|
+
* it, which is R1's defect at 1/H the scale but on the one row the user is
|
|
24
|
+
* watching. Overwriting in place means no cell is ever presentably empty; the
|
|
25
|
+
* trailing `CSI K` removes only the residue a longer previous line left, and
|
|
26
|
+
* the trailing SGR reset before it stops a BCE terminal painting that residue
|
|
27
|
+
* in whatever background colour the line ended inside.
|
|
28
|
+
* - I-5 · Every emitted batch ends parked on row `H+1`. Ink's own
|
|
29
|
+
* `previousLineCount` is computed from the string IT produced and is
|
|
30
|
+
* unaffected by what we actually wrote, so a later pass-through's
|
|
31
|
+
* `eraseLines(H+1)` must find the cursor exactly where log-update left it last
|
|
32
|
+
* time. Drop this and one fallback shifts the frame up a row for the rest of
|
|
33
|
+
* the session.
|
|
34
|
+
* - I-6 · Any unrecognised write invalidates the cache. Absolute addressing is
|
|
35
|
+
* valid only while nothing else writes to stdout. Full-screen mode already
|
|
36
|
+
* forbids that, but "forbidden" is not "impossible", and the failure mode of
|
|
37
|
+
* the guard being absent is a permanently misaligned screen while the failure
|
|
38
|
+
* mode of it firing spuriously is one extra full repaint.
|
|
39
|
+
*/
|
|
40
|
+
import { MIN_FULLSCREEN_ROWS } from './layout/frame.js';
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Constants — exact, so no call site ever re-derives an escape sequence.
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
/** Control Sequence Introducer. Spelled out once; nothing below re-escapes it. */
|
|
45
|
+
const CSI = '\x1b[';
|
|
46
|
+
/** Erase the WHOLE line. Ink uses it on every repaint; this module never emits it (I-4). */
|
|
47
|
+
const ERASE_LINE = `${CSI}2K`;
|
|
48
|
+
/** Erase cursor → end of line. This is what an emitted line ends with. */
|
|
49
|
+
const ERASE_TO_EOL = `${CSI}K`;
|
|
50
|
+
const CURSOR_UP_1 = `${CSI}1A`;
|
|
51
|
+
const CURSOR_LEFT = `${CSI}G`;
|
|
52
|
+
const SGR_RESET = `${CSI}0m`;
|
|
53
|
+
/** Erase cursor → end of screen. One sequence instead of H+1 line erases. */
|
|
54
|
+
const ERASE_DOWN = `${CSI}J`;
|
|
55
|
+
/** DEC private mode 2026 — begin/end synchronized update (F2, §3.3). */
|
|
56
|
+
const SYNC_BEGIN = `${CSI}?2026h`;
|
|
57
|
+
const SYNC_END = `${CSI}?2026l`;
|
|
58
|
+
/**
|
|
59
|
+
* `eraseLines(n)` for any n ≥ 1, anchored at the start of the chunk.
|
|
60
|
+
*
|
|
61
|
+
* FALLBACK ONLY (P2-3). Whenever `prev !== null` the differ already knows the
|
|
62
|
+
* exact prefix it expects — `eraseLinesPrefix(prev.length + 1)` is a
|
|
63
|
+
* deterministic string — so the hot path is `chunk.startsWith(expected)`:
|
|
64
|
+
* linear, unambiguous, and it collapses step 7's `erased !== prev.length + 1`
|
|
65
|
+
* test into the same comparison. This regex is reached only on the first
|
|
66
|
+
* diffable chunk after a session start or an `invalidate()`, where no
|
|
67
|
+
* expectation exists yet.
|
|
68
|
+
*/
|
|
69
|
+
const ERASE_PREFIX_RE = /^(?:\x1b\[2K(?:\x1b\[1A)?)+\x1b\[G/;
|
|
70
|
+
function cursorTo(row) {
|
|
71
|
+
return `${CSI}${row};1H`;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Rebuild `ansiEscapes.eraseLines(count)` byte for byte.
|
|
75
|
+
*
|
|
76
|
+
* Exported for the upgrade tripwire (`frame-differ-ink-shape.test.ts`), which
|
|
77
|
+
* drives the real `ink/build/log-update.js` and asserts this reconstruction is
|
|
78
|
+
* byte-equal to what it produced. If a future Ink changes the shape, that test
|
|
79
|
+
* fails loudly instead of this module silently degrading to pass-through for the
|
|
80
|
+
* rest of the package's life (K-2).
|
|
81
|
+
*/
|
|
82
|
+
export function eraseLinesPrefix(count) {
|
|
83
|
+
if (count <= 0)
|
|
84
|
+
return '';
|
|
85
|
+
let out = '';
|
|
86
|
+
for (let i = 0; i < count; i += 1) {
|
|
87
|
+
out += ERASE_LINE + (i < count - 1 ? CURSOR_UP_1 : '');
|
|
88
|
+
}
|
|
89
|
+
return out + CURSOR_LEFT;
|
|
90
|
+
}
|
|
91
|
+
function countErases(prefix) {
|
|
92
|
+
return prefix.split(ERASE_LINE).length - 1;
|
|
93
|
+
}
|
|
94
|
+
export function createFrameDiffer(options) {
|
|
95
|
+
/** Last frame, one entry per row. `null` = no cache, next frame is a full repaint. */
|
|
96
|
+
let prev = null;
|
|
97
|
+
/**
|
|
98
|
+
* Whether any chunk has been seen at all.
|
|
99
|
+
*
|
|
100
|
+
* The session's FIRST write has `previousLineCount === 0`, so `eraseLines(0)`
|
|
101
|
+
* is the empty string and the chunk carries no erase prefix (§3.2.1). That is
|
|
102
|
+
* expected, not a desync, so it must not raise the §5.6 notice on every launch.
|
|
103
|
+
* After it, every chunk Ink produces carries a prefix, so a prefix-less chunk
|
|
104
|
+
* from then on really is foreign.
|
|
105
|
+
*/
|
|
106
|
+
let seenAnyChunk = false;
|
|
107
|
+
const counters = {
|
|
108
|
+
framesTotal: 0,
|
|
109
|
+
framesDiffed: 0,
|
|
110
|
+
framesFull: 0,
|
|
111
|
+
linesWritten: 0,
|
|
112
|
+
bytesWritten: 0,
|
|
113
|
+
fallbacks: 0,
|
|
114
|
+
};
|
|
115
|
+
const invalidate = () => {
|
|
116
|
+
prev = null;
|
|
117
|
+
};
|
|
118
|
+
/**
|
|
119
|
+
* Write `chunk` verbatim — today's behaviour, and the only failure mode.
|
|
120
|
+
*
|
|
121
|
+
* `counted` is false for the two cases the differ inflicts on itself and knows
|
|
122
|
+
* are harmless (the seed write, degenerate geometry); true means a foreign
|
|
123
|
+
* write desynced absolute addressing, which is what `fallbacks` reports.
|
|
124
|
+
*/
|
|
125
|
+
const passThrough = (counted) => {
|
|
126
|
+
invalidate();
|
|
127
|
+
if (counted) {
|
|
128
|
+
counters.fallbacks += 1;
|
|
129
|
+
if (counters.fallbacks === 1)
|
|
130
|
+
options.onFirstFallback?.();
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
};
|
|
134
|
+
const wrapSync = (payload) => options.sync ? SYNC_BEGIN + payload + SYNC_END : payload;
|
|
135
|
+
const emit = (payload, lines, addressed, full) => {
|
|
136
|
+
prev = lines;
|
|
137
|
+
counters.framesTotal += 1;
|
|
138
|
+
if (full)
|
|
139
|
+
counters.framesFull += 1;
|
|
140
|
+
else
|
|
141
|
+
counters.framesDiffed += 1;
|
|
142
|
+
counters.linesWritten += addressed;
|
|
143
|
+
counters.bytesWritten += payload.length;
|
|
144
|
+
return payload;
|
|
145
|
+
};
|
|
146
|
+
/** `undefined` ⇒ the I-9 stand-down: a viewport too small to address safely. */
|
|
147
|
+
const usableRows = () => {
|
|
148
|
+
const rows = options.rows();
|
|
149
|
+
if (rows === undefined || !Number.isFinite(rows))
|
|
150
|
+
return undefined;
|
|
151
|
+
return rows < MIN_FULLSCREEN_ROWS ? undefined : rows;
|
|
152
|
+
};
|
|
153
|
+
const matchErasePrefix = (chunk) => {
|
|
154
|
+
if (prev !== null) {
|
|
155
|
+
const expected = eraseLinesPrefix(prev.length + 1);
|
|
156
|
+
if (chunk.startsWith(expected))
|
|
157
|
+
return { prefix: expected, erased: prev.length + 1 };
|
|
158
|
+
}
|
|
159
|
+
const m = ERASE_PREFIX_RE.exec(chunk);
|
|
160
|
+
if (m === null)
|
|
161
|
+
return null;
|
|
162
|
+
return { prefix: m[0], erased: countErases(m[0]) };
|
|
163
|
+
};
|
|
164
|
+
/**
|
|
165
|
+
* Full repaint — the first diffable frame of a session, every invalidation
|
|
166
|
+
* (including the resize invalidation of I-8), and any line-count change.
|
|
167
|
+
*
|
|
168
|
+
* `CSI J` replaces the H+1 individual line erases with one sequence, and the
|
|
169
|
+
* alternate screen is exactly the region being erased. `\r\n` rather than `\n`
|
|
170
|
+
* is deliberate: Ink relies on the tty's `ONLCR` post-processing to turn `\n`
|
|
171
|
+
* into CRLF, and that dependency is invisible until it is not there.
|
|
172
|
+
*
|
|
173
|
+
* P0-1's blank-then-paint objection does not apply here and the asymmetry with
|
|
174
|
+
* the per-line path is deliberate: a full repaint rewrites every row, so there
|
|
175
|
+
* is no unchanged content that has to survive, and full repaints are rare by
|
|
176
|
+
* construction — one per session, one per resize, one per invalidation.
|
|
177
|
+
*/
|
|
178
|
+
const fullRepaint = (lines) => {
|
|
179
|
+
const payload = cursorTo(1) +
|
|
180
|
+
SGR_RESET +
|
|
181
|
+
ERASE_DOWN +
|
|
182
|
+
lines.join('\r\n') +
|
|
183
|
+
'\r\n' +
|
|
184
|
+
cursorTo(lines.length + 1);
|
|
185
|
+
return emit(wrapSync(payload), lines, lines.length, true);
|
|
186
|
+
};
|
|
187
|
+
const diffRepaint = (lines, previous) => {
|
|
188
|
+
const out = [];
|
|
189
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
190
|
+
// I-4: paint, THEN clear the tail. Never `ERASE_LINE` first.
|
|
191
|
+
if (lines[i] !== previous[i]) {
|
|
192
|
+
out.push(cursorTo(i + 1) + SGR_RESET + lines[i] + SGR_RESET + ERASE_TO_EOL);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
// DEFENSIVE ONLY, not a live path (P2-4). `log-update.js:13-15` returns early
|
|
196
|
+
// on an identical frame, so a chunk that reaches here differs by at least one
|
|
197
|
+
// line; the only way to observe zero is a cache that disagrees with the
|
|
198
|
+
// screen, which is the state `invalidate()` exists to prevent.
|
|
199
|
+
if (out.length === 0)
|
|
200
|
+
return emit('', lines, 0, false);
|
|
201
|
+
out.push(cursorTo(lines.length + 1)); // I-5
|
|
202
|
+
return emit(wrapSync(out.join('')), lines, out.length - 1, false);
|
|
203
|
+
};
|
|
204
|
+
const transform = (chunk) => {
|
|
205
|
+
const firstChunk = !seenAnyChunk;
|
|
206
|
+
seenAnyChunk = true;
|
|
207
|
+
const rows = usableRows();
|
|
208
|
+
if (rows === undefined)
|
|
209
|
+
return passThrough(false); // I-9
|
|
210
|
+
const matched = matchErasePrefix(chunk);
|
|
211
|
+
// A prefix-less chunk is the session's seed write (P1-1) or a foreign one.
|
|
212
|
+
if (matched === null)
|
|
213
|
+
return passThrough(!firstChunk);
|
|
214
|
+
const body = chunk.slice(matched.prefix.length);
|
|
215
|
+
if (!body.endsWith('\n'))
|
|
216
|
+
return passThrough(true); // `log.clear()` and friends
|
|
217
|
+
const lines = body.slice(0, -1).split('\n');
|
|
218
|
+
if (lines.length + 1 > rows)
|
|
219
|
+
return passThrough(false); // I-9
|
|
220
|
+
if (prev === null || prev.length !== lines.length || matched.erased !== prev.length + 1) {
|
|
221
|
+
return fullRepaint(lines);
|
|
222
|
+
}
|
|
223
|
+
return diffRepaint(lines, prev);
|
|
224
|
+
};
|
|
225
|
+
return {
|
|
226
|
+
transform,
|
|
227
|
+
invalidate,
|
|
228
|
+
stats: () => ({ ...counters }),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stdout frame writer — the `Proxy` that binds `frame-differ.ts` to a real
|
|
3
|
+
* stream (tui-input-flicker-fix §3.2 / §5.5).
|
|
4
|
+
*
|
|
5
|
+
* Ink offers no hook for "how to write a frame": `Ink#log` is constructed
|
|
6
|
+
* internally and `log-update` is a private module. It DOES accept a `stdout` in
|
|
7
|
+
* `render()`'s options, so the whole interposition is one `Proxy` whose only
|
|
8
|
+
* trapped behaviour is `write` — everything else is the real stream.
|
|
9
|
+
*
|
|
10
|
+
* THREE THINGS IN HERE ARE LOAD-BEARING:
|
|
11
|
+
*
|
|
12
|
+
* - The `get` trap MUST bind non-`write` function members to the REAL stream.
|
|
13
|
+
* Without the bind, `stdout.on('resize', …)` executes with `this === proxy`,
|
|
14
|
+
* and `useTerminalSize` registers and removes its listener against a
|
|
15
|
+
* different `_events` object than the one Node emits on — the frame stops
|
|
16
|
+
* resizing and nothing reports why.
|
|
17
|
+
* - The UNTYPED BOUNDARY LIVES HERE, not in the differ (P1-4). A `Uint8Array`
|
|
18
|
+
* chunk, and any `write` whose `encoding` is present and is not UTF-8, is
|
|
19
|
+
* passed straight through after `invalidate()`: in both cases the bytes on
|
|
20
|
+
* the wire are not the string a differ could reason about, and diffing one
|
|
21
|
+
* encoding while writing another is the kind of bug that only shows up on
|
|
22
|
+
* someone else's machine.
|
|
23
|
+
* - The `'resize'` SUBSCRIPTION (I-8 / P0-2). Ink renders SYNCHRONOUSLY from its
|
|
24
|
+
* own `'resize'` handler while the frame's height is still the pre-resize one
|
|
25
|
+
* — `useTerminalSize` debounces by 50 ms — so the line count is unchanged
|
|
26
|
+
* across that window and the differ's line-count guard cannot see the resize.
|
|
27
|
+
* Meanwhile the emulator has re-flowed the alternate screen, so every row the
|
|
28
|
+
* differ believes unchanged is a row it will not repaint, for the rest of the
|
|
29
|
+
* drag. It goes on the REAL stream, not the `Proxy`, and `dispose()` removes
|
|
30
|
+
* it.
|
|
31
|
+
*/
|
|
32
|
+
import type { FrameDiffer, FrameWriterStats } from './frame-differ.js';
|
|
33
|
+
export interface FrameWriterHandle {
|
|
34
|
+
/** Hand this to `render(…, { stdout })`. */
|
|
35
|
+
stdout: NodeJS.WriteStream;
|
|
36
|
+
/** Hand this to `setFrameStatsProvider`. */
|
|
37
|
+
stats(): FrameWriterStats;
|
|
38
|
+
/** Removes the `'resize'` subscription of I-8. Idempotent. */
|
|
39
|
+
dispose(): void;
|
|
40
|
+
}
|
|
41
|
+
export declare function wrapStdoutForFrames(stdout: NodeJS.WriteStream, differ: FrameDiffer): FrameWriterHandle;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stdout frame writer — the `Proxy` that binds `frame-differ.ts` to a real
|
|
3
|
+
* stream (tui-input-flicker-fix §3.2 / §5.5).
|
|
4
|
+
*
|
|
5
|
+
* Ink offers no hook for "how to write a frame": `Ink#log` is constructed
|
|
6
|
+
* internally and `log-update` is a private module. It DOES accept a `stdout` in
|
|
7
|
+
* `render()`'s options, so the whole interposition is one `Proxy` whose only
|
|
8
|
+
* trapped behaviour is `write` — everything else is the real stream.
|
|
9
|
+
*
|
|
10
|
+
* THREE THINGS IN HERE ARE LOAD-BEARING:
|
|
11
|
+
*
|
|
12
|
+
* - The `get` trap MUST bind non-`write` function members to the REAL stream.
|
|
13
|
+
* Without the bind, `stdout.on('resize', …)` executes with `this === proxy`,
|
|
14
|
+
* and `useTerminalSize` registers and removes its listener against a
|
|
15
|
+
* different `_events` object than the one Node emits on — the frame stops
|
|
16
|
+
* resizing and nothing reports why.
|
|
17
|
+
* - The UNTYPED BOUNDARY LIVES HERE, not in the differ (P1-4). A `Uint8Array`
|
|
18
|
+
* chunk, and any `write` whose `encoding` is present and is not UTF-8, is
|
|
19
|
+
* passed straight through after `invalidate()`: in both cases the bytes on
|
|
20
|
+
* the wire are not the string a differ could reason about, and diffing one
|
|
21
|
+
* encoding while writing another is the kind of bug that only shows up on
|
|
22
|
+
* someone else's machine.
|
|
23
|
+
* - The `'resize'` SUBSCRIPTION (I-8 / P0-2). Ink renders SYNCHRONOUSLY from its
|
|
24
|
+
* own `'resize'` handler while the frame's height is still the pre-resize one
|
|
25
|
+
* — `useTerminalSize` debounces by 50 ms — so the line count is unchanged
|
|
26
|
+
* across that window and the differ's line-count guard cannot see the resize.
|
|
27
|
+
* Meanwhile the emulator has re-flowed the alternate screen, so every row the
|
|
28
|
+
* differ believes unchanged is a row it will not repaint, for the rest of the
|
|
29
|
+
* drag. It goes on the REAL stream, not the `Proxy`, and `dispose()` removes
|
|
30
|
+
* it.
|
|
31
|
+
*/
|
|
32
|
+
/** The only encodings for which the chunk string IS the bytes on the wire. */
|
|
33
|
+
function isUtf8Encoding(encoding) {
|
|
34
|
+
const e = encoding.toLowerCase();
|
|
35
|
+
return e === 'utf8' || e === 'utf-8';
|
|
36
|
+
}
|
|
37
|
+
export function wrapStdoutForFrames(stdout, differ) {
|
|
38
|
+
const bound = new Map();
|
|
39
|
+
const writeReal = (chunk, encoding, cb) => {
|
|
40
|
+
if (encoding !== undefined)
|
|
41
|
+
return stdout.write(chunk, encoding, cb);
|
|
42
|
+
return stdout.write(chunk, cb);
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* All three Node overloads: `write(chunk)`, `write(chunk, cb)`,
|
|
46
|
+
* `write(chunk, encoding, cb)`.
|
|
47
|
+
*/
|
|
48
|
+
const write = (chunk, encodingOrCb, maybeCb) => {
|
|
49
|
+
const encoding = typeof encodingOrCb === 'string' ? encodingOrCb : undefined;
|
|
50
|
+
const cb = typeof encodingOrCb === 'function'
|
|
51
|
+
? encodingOrCb
|
|
52
|
+
: typeof maybeCb === 'function'
|
|
53
|
+
? maybeCb
|
|
54
|
+
: undefined;
|
|
55
|
+
// Foreign by construction: Ink never writes Buffers, and a non-UTF-8
|
|
56
|
+
// encoding means the string is not the bytes (P1-4).
|
|
57
|
+
if (typeof chunk !== 'string' || (encoding !== undefined && !isUtf8Encoding(encoding))) {
|
|
58
|
+
differ.invalidate();
|
|
59
|
+
return writeReal(chunk, encoding, cb);
|
|
60
|
+
}
|
|
61
|
+
const out = differ.transform(chunk);
|
|
62
|
+
if (out === null)
|
|
63
|
+
return writeReal(chunk, encoding, cb);
|
|
64
|
+
// Nothing to write, but the callback is still part of the contract — a
|
|
65
|
+
// skipped `cb` strands whatever was waiting on the drain.
|
|
66
|
+
if (out.length === 0) {
|
|
67
|
+
cb?.();
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
return writeReal(out, encoding, cb);
|
|
71
|
+
};
|
|
72
|
+
const onResize = () => differ.invalidate();
|
|
73
|
+
stdout.on('resize', onResize);
|
|
74
|
+
let disposed = false;
|
|
75
|
+
const proxy = new Proxy(stdout, {
|
|
76
|
+
get(target, prop) {
|
|
77
|
+
if (prop === 'write')
|
|
78
|
+
return write;
|
|
79
|
+
const value = Reflect.get(target, prop, target);
|
|
80
|
+
if (typeof value !== 'function')
|
|
81
|
+
return value;
|
|
82
|
+
// Cached so repeated property access does not allocate a new bound
|
|
83
|
+
// function on a hot path (K-9).
|
|
84
|
+
let fn = bound.get(prop);
|
|
85
|
+
if (fn === undefined) {
|
|
86
|
+
fn = value.bind(target);
|
|
87
|
+
bound.set(prop, fn);
|
|
88
|
+
}
|
|
89
|
+
return fn;
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
return {
|
|
93
|
+
stdout: proxy,
|
|
94
|
+
stats: () => differ.stats(),
|
|
95
|
+
dispose() {
|
|
96
|
+
if (disposed)
|
|
97
|
+
return;
|
|
98
|
+
disposed = true;
|
|
99
|
+
stdout.off('resize', onResize);
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aragon-agent/cli",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.11",
|
|
4
4
|
"description": "AragonAgent CLI — a Claude-Code / Codex-style interactive terminal UI (TUI) on top of the @aragon-agent/core engine. Installable and runnable via npm / npx.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "AragonAgent contributors",
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"prepublishOnly": "npm run build"
|
|
53
53
|
},
|
|
54
54
|
"dependencies": {
|
|
55
|
-
"@aragon-agent/core": "^0.2.
|
|
55
|
+
"@aragon-agent/core": "^0.2.11",
|
|
56
56
|
"chalk": "^5.6.2",
|
|
57
57
|
"cli-highlight": "^2.0.0",
|
|
58
58
|
"commander": "^12.1.0",
|