@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.
@@ -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
+ }
@@ -33,7 +33,7 @@ const COMMANDS = [
33
33
  'Todo planning: status, switches, the rail, follow-through',
34
34
  ],
35
35
  ['/tools', 'List active tools'],
36
- ['/clear', 'Clear the visible transcript'],
36
+ ['/clear', 'Clear the visible transcript and the todo panel'],
37
37
  ['/reset', 'New conversation'],
38
38
  ['/cwd [dir]', 'Show or change the tool working directory'],
39
39
  ['/save [file]', 'Save the session to JSON'],
@@ -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.12",
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.12",
56
56
  "chalk": "^5.6.2",
57
57
  "cli-highlight": "^2.0.0",
58
58
  "commander": "^12.1.0",