@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 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) => {
@@ -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
- /** Format a snapshot as the five-line block documented in §5.4. */
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;
@@ -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
- /** Format a snapshot as the five-line block documented in §5.4. */
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 = {
@@ -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`
@@ -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;
@@ -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).
@@ -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,
@@ -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;
@@ -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,
@@ -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';
@@ -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 [buffer, setBuffer] = useState('');
132
- const [cursor, setCursor] = useState(0);
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
- const resetDraftFlags = () => {
193
- setHistoryIndex(null);
194
- setDismissed(false);
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
- setBuffer(buffer.slice(0, cursor) + text + buffer.slice(cursor));
199
- setCursor(cursor + text.length);
200
- resetDraftFlags();
204
+ dispatch({ type: 'insert', text });
201
205
  };
202
206
  const submit = (text) => {
203
207
  onSubmit(text);
204
- setBuffer('');
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
- setHistoryIndex(idx);
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
- setHistoryIndex(null);
223
- setBuffer('');
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
- setBuffer(next);
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
- setBuffer(next);
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
- setCursor(moved.cursor);
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
- setSel(Math.max(0, clampedSel - 1));
272
+ dispatch({ type: 'select', sel: Math.max(0, clampedSel - 1) });
278
273
  return;
279
274
  }
280
275
  if (key.downArrow) {
281
- setSel(Math.min(popupItems.length - 1, clampedSel + 1));
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
- setDismissed(true);
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
- setBuffer(next.buffer);
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
- if (cursor > 0) {
325
- setBuffer(buffer.slice(0, cursor - 1) + buffer.slice(cursor));
326
- setCursor(cursor - 1);
327
- resetDraftFlags();
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
- setCursor(Math.max(0, cursor - 1));
325
+ dispatch({ type: 'moveCursor', cursor: Math.max(0, cursor - 1) });
333
326
  return;
334
327
  }
335
328
  if (key.rightArrow) {
336
- setCursor(Math.min(buffer.length, cursor + 1));
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.10",
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.10",
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",