@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.
@@ -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 { getLogger } from '../logging/logger.js';
21
27
  import { TODO_LIMITS } from './limits.js';
@@ -42,7 +42,8 @@ export interface TodoSnapshot {
42
42
  /**
43
43
  * Why a list went away.
44
44
  *
45
- * `user` - `/todo clear`.
45
+ * `user` - `/todo clear`, or `/clear`. Both are an explicit user instruction,
46
+ * which is I-2's standing exception; neither touches `messages`.
46
47
  * `reset` - `/reset`, or a `/resume` whose file carried no list (§3.13).
47
48
  * `turn` - every item was completed when the next user turn started.
48
49
  * `stale` - unfinished, and unwritten for more than `TODO_LIMITS.staleTurns`
@@ -21,6 +21,22 @@
21
21
  *
22
22
  * What is left is the one thing the status bar cannot say: THAT THE SILENCE IS
23
23
  * WORK. The bar shows state; this row shows life.
24
+ *
25
+ * THIS ROW OWNS THE ONLY ANIMATED SPINNER WHILE IT IS MOUNTED
26
+ * (single-spinner-while-running D-1). Seven other sites can animate — the
27
+ * streaming assistant marker, the tool badge, the team/fast/retry cards, and the
28
+ * two panels' rows — and each was independently right to. Emergently they put up
29
+ * to five braille animations on adjacent rows, each on its own 80 ms timer: one
30
+ * piece of information rendered five times and out of phase, which is the same
31
+ * duplication the paragraph above legislates against for text. So `App` widens
32
+ * the `reducedMotion` it hands those seven to `cfg.reducedMotion || (running &&
33
+ * !overlay)`, and they all fall back to the static glyphs they already had.
34
+ *
35
+ * THIS COMPONENT KEEPS THE RAW CONFIG FLAG, and that asymmetry is deliberate
36
+ * (D-5). It reads the flag TWICE: once for the spinner below, and once for
37
+ * `pickActivityPhrase`'s rotation. Handing it the widened value would freeze the
38
+ * phrase at the first word of every run — a second, unrelated regression, and
39
+ * one nobody would connect to a change about icons.
24
40
  */
25
41
  import React from 'react';
26
42
  import type { Theme } from './theme.js';
package/dist/ui/App.js CHANGED
@@ -1422,6 +1422,30 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
1422
1422
  const opener = empty ? (_jsx(SessionOpener, { variant: fullscreen ? pickOpenerVariant(viewportBudget, contentCols, caps) : 'none', version: version, cwd: controller.getCwd(), hasKey: hasKey, viewportRows: fullscreen ? viewportBudget : Number.POSITIVE_INFINITY, theme: theme, caps: caps })) : null;
1423
1423
  const density = cfg.density;
1424
1424
  const transcriptWindow = cfg.transcriptWindow ?? DEFAULT_TRANSCRIPT_WINDOW;
1425
+ // --- One spinner per run (single-spinner-while-running D-1 .. D-5). ------
1426
+ //
1427
+ // THE MOUNT CONDITION, NAMED ONCE AND USED TWICE (D-4). This const both gates
1428
+ // the `<ActivityLine>` element below and feeds the derivation on the next
1429
+ // line, so "the line is up" and "every other site is still" can never
1430
+ // disagree. Two copies of the same boolean expression is exactly how that
1431
+ // invariant becomes false in six months, silently and in only one branch.
1432
+ //
1433
+ // NOT `running` ALONE (D-3). While an overlay owns the screen the activity row
1434
+ // is suppressed, so keying suppression off `running` would leave the frame
1435
+ // with ZERO life signals: the bar shows state, this row shows life. Never
1436
+ // trade one duplicate for one absence.
1437
+ const activityVisible = running && !overlayNode;
1438
+ // The FOUR VIEW CONSUMERS (transcript x 2, team panel, todo panel) take this;
1439
+ // the activity line keeps the raw config flag (D-5), because it reads that
1440
+ // flag a second time to gate phrase rotation and the widened value would
1441
+ // freeze the phrase at the first word of every run.
1442
+ //
1443
+ // So below `App` the prop now means "this frame does not animate here" rather
1444
+ // than "the user asked for reduced motion". `cfg.reducedMotion` (`:279`) stays
1445
+ // the sole config source, and `spinner-census.test.ts` pins this wiring —
1446
+ // threading the raw flag into a fifth consumer is the one silent way back to
1447
+ // two spinners (R-2).
1448
+ const viewReducedMotion = reducedMotion || activityVisible;
1425
1449
  // Assigned during render so `/perf` reads the CURRENT frame (see `perfRef`).
1426
1450
  // `mountedEntries` is the same number `render-budget.test.tsx` asserts (AC-9).
1427
1451
  perfRef.current = {
@@ -1440,20 +1464,25 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
1440
1464
  viewportRows: fullscreen ? viewportBudget : rows,
1441
1465
  offset: scrolledLines,
1442
1466
  };
1443
- const viewport = fullscreen ? (overlayNode ? (_jsx(Box, { flexDirection: "column", flexGrow: 1, overflow: "hidden", children: overlayNode })) : (_jsxs(ScrollViewport, { intent: scrollIntent, pinToBottomNonce: pinToBottomNonce, onScrolledLinesChange: setScrolledLines, showScrollIndicator: true, cols: contentCols, theme: theme, caps: caps, children: [opener, _jsx(TranscriptList, { entries: state.entries, expandedToolIds: state.expandedToolIds, thinkingVisible: state.thinkingVisible, reducedMotion: reducedMotion, density: density, mode: mode, nowSec: nowSec, theme: theme, caps: caps, windowSize: transcriptWindow, cols: contentCols, heights: heights, mountedSink: mountedCount })] }))) : (_jsxs(_Fragment, { children: [opener, _jsx(Transcript, { entries: state.entries, expandedToolIds: state.expandedToolIds, thinkingVisible: state.thinkingVisible, reducedMotion: reducedMotion, density: density, mode: mode, nowSec: nowSec, theme: theme, caps: caps }), overlayNode] }));
1467
+ const viewport = fullscreen ? (overlayNode ? (_jsx(Box, { flexDirection: "column", flexGrow: 1, overflow: "hidden", children: overlayNode })) : (_jsxs(ScrollViewport, { intent: scrollIntent, pinToBottomNonce: pinToBottomNonce, onScrolledLinesChange: setScrolledLines, showScrollIndicator: true, cols: contentCols, theme: theme, caps: caps, children: [opener, _jsx(TranscriptList, { entries: state.entries, expandedToolIds: state.expandedToolIds, thinkingVisible: state.thinkingVisible, reducedMotion: viewReducedMotion, density: density, mode: mode, nowSec: nowSec, theme: theme, caps: caps, windowSize: transcriptWindow, cols: contentCols, heights: heights, mountedSink: mountedCount })] }))) : (_jsxs(_Fragment, { children: [opener, _jsx(Transcript, { entries: state.entries, expandedToolIds: state.expandedToolIds, thinkingVisible: state.thinkingVisible, reducedMotion: viewReducedMotion, density: density, mode: mode, nowSec: nowSec, theme: theme, caps: caps }), overlayNode] }));
1444
1468
  // The completion popup lives inside the bottom chrome and can add up to 9
1445
1469
  // rows; unbounded, it pushes the status bar past the frame height where
1446
1470
  // `overflow: hidden` clips it away (R-14 / M-14). Cap it against the viewport.
1447
1471
  const popupMaxRows = fullscreen ? Math.max(1, viewportBudget - 4) : undefined;
1448
1472
  const composer = fullscreen ? (_jsx(Composer, { isActive: overlay === null, running: state.status === 'running', history: promptHistory, commands: commandOptions, cwd: controller.getCwd(), showHint: rows >= HINT_MIN_ROWS, submitCount: cfg.submitCount, hintsEnabled: cfg.hints, agentMode: state.agentMode, popupMaxRows: popupMaxRows, theme: theme, caps: caps, onSubmit: (text) => void handleSubmit(text), onHelp: () => dispatch({ type: 'setOverlay', overlay: 'help' }) })) : (_jsx(Box, { marginTop: 1, children: _jsx(PromptInput, { isActive: overlay === null, running: state.status === 'running', history: promptHistory, commands: commandOptions, cwd: controller.getCwd(), agentMode: state.agentMode, theme: theme, caps: caps, onSubmit: (text) => void handleSubmit(text), onHelp: () => dispatch({ type: 'setOverlay', overlay: 'help' }) }) }));
1449
- return (_jsx(AppShell, { mode: mode, rows: rows, cols: cols, header: header, viewport: viewport, team: state.team ? (_jsx(TeamPanel, { snapshot: state.team, rows: rows, cols: cols, reducedMotion: reducedMotion, theme: theme, caps: caps })) : null, rail: showRail && state.todos ? (_jsx(TodoPanel, { snapshot: state.todos, width: railWidth, rows: railRows, running: state.status === 'running', reducedMotion: reducedMotion, theme: theme, caps: caps })) : null, strip: showStrip && state.todos ? (_jsx(TodoStrip, { snapshot: state.todos, cols: cols, theme: theme, caps: caps })) : null, toast: _jsx(BottomStatusRow, { mode: mode, toasts: state.toasts,
1473
+ return (_jsx(AppShell, { mode: mode, rows: rows, cols: cols, header: header, viewport: viewport, team: state.team ? (_jsx(TeamPanel, { snapshot: state.team, rows: rows, cols: cols, reducedMotion: viewReducedMotion, theme: theme, caps: caps })) : null, rail: showRail && state.todos ? (_jsx(TodoPanel, { snapshot: state.todos, width: railWidth, rows: railRows, running: running, reducedMotion: viewReducedMotion, theme: theme, caps: caps })) : null, strip: showStrip && state.todos ? (_jsx(TodoStrip, { snapshot: state.todos, cols: cols, theme: theme, caps: caps })) : null, toast: _jsx(BottomStatusRow, { mode: mode, toasts: state.toasts,
1450
1474
  // `!overlayNode` suppresses the line while an overlay owns the screen:
1451
1475
  // the full-screen branch replaces the whole viewport with the overlay,
1452
1476
  // and a working line under a settings screen is noise attached to a
1453
1477
  // surface the run is not visible on. (Inline overlays are part of the
1454
1478
  // document flow, so there the suppression is conservative rather than
1455
1479
  // necessary — P2-5.)
1456
- activity: running && !overlayNode ? (_jsx(ActivityLine, { startedAt: runStartedAt.current, elapsedMs: elapsedMs, reducedMotion: reducedMotion, runningTool: runningTool, theme: theme, caps: caps })) : null,
1480
+ //
1481
+ // THE CONDITION IS `activityVisible`, THE SAME CONST THE SUPPRESSION
1482
+ // SIGNAL IS DERIVED FROM (D-4): inlining it here again is how the two
1483
+ // drift apart, and the drift is silent in whichever branch is not
1484
+ // edited.
1485
+ activity: activityVisible ? (_jsx(ActivityLine, { startedAt: runStartedAt.current, elapsedMs: elapsedMs, reducedMotion: reducedMotion, runningTool: runningTool, theme: theme, caps: caps })) : null,
1457
1486
  // PRESENCE IS DECIDED HERE, NOT BY `UpdateLine` RETURNING `null`
1458
1487
  // (C-15 / cli-auto-update P0-1 / D-19). `BottomStatusRow` tests this
1459
1488
  // prop for truthiness, and a React element is truthy however it
@@ -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
@@ -138,6 +138,21 @@ function EntryViewImpl({ entry, prev, expanded, thinkingVisible, reducedMotion,
138
138
  // While text streams, the spinner IS the role marker. Braille dots are
139
139
  // both an animation and a Unicode-only glyph, so reduced motion and an
140
140
  // ASCII terminal fall back to the same static marker (P2-11 / A-10).
141
+ //
142
+ // `reducedMotion` ARRIVES PRE-WIDENED FROM `App`: it is now
143
+ // `cfg.reducedMotion || activityVisible`, so this marker is static for the
144
+ // whole of every run and the activity row above the composer carries the
145
+ // one animation (single-spinner-while-running D-2). Nothing here changes —
146
+ // the expression already meant "do not animate here".
147
+ //
148
+ // THAT IS SAFE FOR `<Static>` BY CONSTRUCTION, not by luck: a settled
149
+ // prefix is printed once and can never be re-printed, so a boolean that
150
+ // changed how an already-printed entry renders would tear the history.
151
+ // `computeSettledCount` below breaks on EVERY condition that makes an
152
+ // entry animate (`:84` streaming, `:85` running tool, `:91` active team,
153
+ // `:104` retry, `:109` live fast), so every animating entry is in the live
154
+ // region and every entry in `<Static>` already has `animate === false` on
155
+ // both sides of the flip. Weakening a break clause breaks that proof.
141
156
  const animate = entry.streaming && !reducedMotion && caps.unicode;
142
157
  return frame(animate ? _jsx(Spinner, { type: "dots" }) : glyphs.assistant, theme.primary, _jsx(AssistantEntry, { text: entry.text, thinking: entry.thinking, thinkingOpen: entry.thinkingOpen, thinkingVisible: thinkingVisible, streaming: entry.streaming, aborted: entry.aborted,
143
158
  // `EntryViewImpl` hands each renderer EXPLICIT SCALAR PROPS, so
@@ -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
+ }
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  /**
3
3
  * FastCard — one fast-model review in the transcript (fast-model-tier §6).
4
4
  *
@@ -76,7 +76,25 @@ function FastCardImpl(props) {
76
76
  const duration = durationMs === undefined ? '' : ` ${glyphs.midDot} ${formatDuration(durationMs)}`;
77
77
  const head = `fast review #${reviewIndex} ${glyphs.midDot} ${model} ${glyphs.midDot} turn ${turn}${duration}`;
78
78
  const showBody = status === 'advice' && text !== undefined && text.trim().length > 0;
79
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: theme.accent, bold: true, children: "fast" }), live && !reducedMotion && caps.unicode ? (_jsxs(Text, { color: color, children: [' ', _jsx(Spinner, { type: "dots" }), " ", head] })) : (_jsxs(Text, { color: theme.muted, wrap: "truncate", children: [' ', live ? `${glyphs.spinnerStill} ` : '', head] }))] }), showBody ? (_jsx(Box, { flexDirection: "column", flexShrink: 0, paddingLeft: 1, ...railBorderProps(glyphs.railVertical, theme.border), children: textRows(text).map((line, index) => (_jsx(Text, { color: theme.primary, children: line }, index))) })) : (_jsx(Box, { flexDirection: "column", flexShrink: 0, paddingLeft: 1, children: _jsx(Text, { color: color, wrap: "truncate", children: statusLine(props) }) }))] }));
79
+ // COLOUR TRACKS `live`, MOTION TRACKS `animate` — one headline, two separate
80
+ // decisions (single-spinner-while-running C-1 / D-7).
81
+ //
82
+ // These used to be two whole `<Text>` elements, so the still branch also
83
+ // demoted the head to `theme.muted`. That was harmless while stillness meant
84
+ // "the user set reducedMotion", but `App` now widens the prop to "this frame
85
+ // does not animate here" for the whole of every run — and under the old shape
86
+ // that would have greyed out every live fast review for the whole run, losing
87
+ // the status colour along with the motion.
88
+ //
89
+ // Three things are load-bearing and all three are silent if dropped: the
90
+ // leading two-space gutter that separates the head from the bold `fast` label,
91
+ // the single space after the marker (the still form bakes it into its template
92
+ // string), and `head` itself. `wrap="truncate"` now applies to the animated
93
+ // path too, which it did not before: it is what the still branch always did,
94
+ // it is the behaviour this card has in every state that is now common, and an
95
+ // unbounded wrap inside a one-row headline is the worse failure.
96
+ const animate = live && !reducedMotion && caps.unicode;
97
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: theme.accent, bold: true, children: "fast" }), _jsxs(Text, { color: live ? color : theme.muted, wrap: "truncate", children: [' ', animate ? (_jsxs(_Fragment, { children: [_jsx(Spinner, { type: "dots" }), ' '] })) : live ? (`${glyphs.spinnerStill} `) : (''), head] })] }), showBody ? (_jsx(Box, { flexDirection: "column", flexShrink: 0, paddingLeft: 1, ...railBorderProps(glyphs.railVertical, theme.border), children: textRows(text).map((line, index) => (_jsx(Text, { color: theme.primary, children: line }, index))) })) : (_jsx(Box, { flexDirection: "column", flexShrink: 0, paddingLeft: 1, children: _jsx(Text, { color: color, wrap: "truncate", children: statusLine(props) }) }))] }));
80
98
  }
81
99
  /**
82
100
  * `React.memo` with the DEFAULT comparator (tui-render-performance L2 / R3).
@@ -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;