@aragon-agent/cli 0.5.10 → 0.5.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,30 @@ were written.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ### Fixed
11
+
12
+ - **`/clear` now takes the todo panel with it.** Finish a plan, type `/clear`,
13
+ and the transcript emptied while the `7/7 done` rail stayed exactly where it
14
+ was — on a screen you had just asked to be cleared. It stayed until your next
15
+ real message, which is precisely the pause you take after clearing to decide
16
+ what to ask next, so essentially everyone met it.
17
+
18
+ This was deliberate once. The list is a projection of what the model believes,
19
+ and `/clear` wipes the transcript rather than the conversation, so the model
20
+ still believed in the plan and a panel contradicting the model was judged worse
21
+ than no panel. That reasoning holds for everything the CLI does on its own; it
22
+ does not hold against you typing the command. An explicit instruction was
23
+ always this rule's exception — `/todo clear` has used the same door from the
24
+ start — and `/clear` now goes through it.
25
+
26
+ The list itself is cleared, not just the pixels, so `/save`, `/todo status`,
27
+ `/todo continue` and auto follow-through all agree with the screen: no plan is
28
+ written into a saved session, nothing offers to continue one, and nothing spends
29
+ a follow-through on it. `/reset` is unchanged and still the only command that
30
+ clears the conversation. Nothing is stranded either way — the model's next
31
+ `todo_write` is a full replacement, so the panel comes back the moment there is
32
+ a plan to show.
33
+
10
34
  ### Added
11
35
 
12
36
  - **An update that will not start is undone automatically.** The updater shipped
package/README.md CHANGED
@@ -107,7 +107,7 @@ Exit codes: `0` success · `1` agent/runtime error · `2` config/usage error ·
107
107
  | `/thinking <level>` | Set the thinking level |
108
108
  | `/max-tokens [<n>\|auto\|default]` | Output token cap; with no argument, reports the setting **and** the effective cap for the live model |
109
109
  | `/tools` | List active tools |
110
- | `/clear` | Clear the visible transcript |
110
+ | `/clear` | Clear the visible transcript and the todo panel |
111
111
  | `/reset` | New conversation |
112
112
  | `/cwd [dir]` | Show / change the tool working directory |
113
113
  | `/save [file]` | Save the session to JSON |
@@ -498,9 +498,11 @@ against an empty plan is refused outright. A session that never plans has
498
498
  byte-identical layout to a build without this feature.
499
499
 
500
500
  **The list is a projection of what the model believes**, so nothing that does
501
- not also tell the model may change it:
501
+ not also tell the model may change it — except you, saying so:
502
502
 
503
- - `/clear` wipes the transcript but not the conversation, so the plan stays.
503
+ - `/clear` and `/todo clear` take the plan off the screen. Neither touches the
504
+ conversation, so the model still knows what it was doing and its next update
505
+ brings the panel straight back.
504
506
  - `/reset` clears the conversation, so the plan goes with it.
505
507
  - `/save` writes the list and `/resume` restores it. Resuming a session that has
506
508
  no list clears the current one, rather than leaving a plan on screen whose
@@ -339,7 +339,11 @@ export declare class AgentController {
339
339
  subscribeTodos(listener: TodoEventListener): () => void;
340
340
  /** `/resume`. An absent or empty list CLEARS — see `TodoStore.restore` (P0-2). */
341
341
  restoreTodos(items: unknown): void;
342
- /** `/todo clear`. A user override of a projection; refused mid-run by `/todo`. */
342
+ /**
343
+ * `/todo clear` and `/clear`. A user override of a projection (I-2's standing
344
+ * exception). `/todo` refuses mid-run before it gets here; `/clear` does not,
345
+ * on purpose — refusing there would leave it unable to clear the transcript.
346
+ */
343
347
  clearTodos(): void;
344
348
  getRetryConfig(): RetryConfig;
345
349
  /**
@@ -916,7 +916,11 @@ export class AgentController {
916
916
  restoreTodos(items) {
917
917
  this.todos?.restore(items);
918
918
  }
919
- /** `/todo clear`. A user override of a projection; refused mid-run by `/todo`. */
919
+ /**
920
+ * `/todo clear` and `/clear`. A user override of a projection (I-2's standing
921
+ * exception). `/todo` refuses mid-run before it gets here; `/clear` does not,
922
+ * on purpose — refusing there would leave it unable to clear the transcript.
923
+ */
920
924
  clearTodos() {
921
925
  this.todos?.clear('user');
922
926
  }
@@ -1057,9 +1061,10 @@ export class AgentController {
1057
1061
  // in this conversation" hint would otherwise start lying (P2-9).
1058
1062
  this.skills.getRegistry().clearActive();
1059
1063
  // `/reset` clears `messages`, so the belief that justifies the panel is gone
1060
- // with them and the list must go too (I-2). `/clear` reaches the transcript
1061
- // and NOT this method, which is the whole distinction: it tells the model
1062
- // nothing, so it may not change the list.
1064
+ // with them and the list must go too (I-2). `/clear` also drops the list, but
1065
+ // through `clearTodos()` and reason `'user'`, which is the distinction that
1066
+ // remains: this path is a belief that no longer exists, that one is a user
1067
+ // overriding a projection of a belief the model still holds.
1063
1068
  this.todos?.clear('reset');
1064
1069
  }
1065
1070
  /** Read-only view of the live system prompt (regression tests, /debug). */
@@ -692,10 +692,20 @@ export function viewReducer(state, action) {
692
692
  streamingId: undefined,
693
693
  expandedToolIds: {},
694
694
  teamEntryId: undefined,
695
- // The entry it pointed at is gone, so the id goes with it. `todos` is
696
- // DELIBERATELY UNTOUCHED (I-2): `/clear` wipes the transcript and not
697
- // the conversation, so the model still believes in the plan, and a panel
698
- // that disagrees with the model is worse than no panel.
695
+ // The list goes with the screen it was on. `/clear` is an explicit user
696
+ // instruction, which is I-2's standing exception (`TodoClearReason`
697
+ // `'user'`, the same door `/todo clear` uses) — so the command clears the
698
+ // STORE via `controller.clearTodos()` and this clears the MIRROR, exactly
699
+ // the pairing `resetConversation` below has always had.
700
+ //
701
+ // The mirror write is same-valued with the store's own `todoCleared`
702
+ // dispatch rather than redundant with it: it is what makes this action
703
+ // stand on its own. `builtins.ts` is the only dispatcher of it TODAY, and
704
+ // a second one — a real clear-screen key, a session-restore path — would
705
+ // otherwise bring the bug back in identical form and in silence.
706
+ //
707
+ // The entry id is dropped because the entry it pointed at is gone.
708
+ todos: null,
699
709
  todoEntryId: undefined,
700
710
  // Same reasoning for the retry card: the entry is gone, so the id must go
701
711
  // with it or `retryScheduled` would `mapEntry` an id that no longer
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) => {
@@ -831,8 +831,27 @@ const COMMANDS = [
831
831
  },
832
832
  {
833
833
  name: 'clear',
834
- description: 'Clear the visible transcript',
835
- run: (ctx) => ctx.dispatch({ type: 'clearTranscript' }),
834
+ description: 'Clear the visible transcript and the todo panel',
835
+ run: (ctx) => {
836
+ // A USER OVERRIDE OF A PROJECTION, the same class as `/todo clear`, so it
837
+ // reuses reason `'user'`; I-2 and its standing exception are stated on
838
+ // `TodoStore`. Clearing the STORE and not merely the view mirror is what
839
+ // keeps `/save`, `/todo status|continue` and follow-through agreeing with
840
+ // the screen — they read the store directly. `messages` is untouched:
841
+ // clearing the conversation is still `/reset` alone.
842
+ //
843
+ // DELIBERATELY WITHOUT `/todo clear`'s two guards: an `isRunning()` refusal
844
+ // would leave `/clear` unable to clear even the transcript mid-run, and an
845
+ // early return on an empty list would skip the dispatch below entirely.
846
+ // Clearing an empty store is already silent — it emits into a reducer
847
+ // branch that writes two fields that are both already at their defaults.
848
+ //
849
+ // Bounded side effect: the emptied store re-arms `todo_write`'s "a fresh
850
+ // plan has at least two items" gate, so a model that shrinks a plan to one
851
+ // item right after a mid-run `/clear` gets the refusal text and retries.
852
+ ctx.controller.clearTodos();
853
+ ctx.dispatch({ type: 'clearTranscript' });
854
+ },
836
855
  },
837
856
  {
838
857
  name: 'reset',
@@ -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,
@@ -10,12 +10,18 @@
10
10
  * `TeamRuntime`.
11
11
  *
12
12
  * INVARIANT I-2 — the list is a PROJECTION OF WHAT THE MODEL BELIEVES, and
13
- * nothing that does not tell the model may change it. `/clear` wipes the
14
- * transcript but not the conversation, so it must NOT reach this class;
15
- * `/reset` clears `messages`, so it must. Every write is a FULL REPLACEMENT and
16
- * every clear is a FULL REMOVAL - there is no partial update anywhere, which is
17
- * what makes I-9 ("the panel is never a contradiction of the model") provable
18
- * rather than argued.
13
+ * nothing that does not tell the model may change it — EXCEPT AN EXPLICIT USER
14
+ * INSTRUCTION, which is the rule's standing exception and is marked
15
+ * `TodoClearReason = 'user'`. Two commands come through that door: `/todo clear`
16
+ * and `/clear`. Neither tells the model anything, and that is fine, because what
17
+ * they leave behind is "panel absent, model still remembers" - the direction I-9
18
+ * below calls safe, and one the model's next full-replacement write heals on its
19
+ * own. `/reset` (reason `'reset'`) remains the only clear caused by the belief
20
+ * itself being destroyed, because it clears `messages`.
21
+ *
22
+ * Every write is a FULL REPLACEMENT and every clear is a FULL REMOVAL - there is
23
+ * no partial update anywhere, which is what makes I-9 ("the panel is never a
24
+ * contradiction of the model") provable rather than argued.
19
25
  */
20
26
  import { type NormalizeResult } from './normalize.js';
21
27
  import type { TodoClearReason, TodoEventListener, TodoSnapshot } from './types.js';