@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 +24 -0
- package/README.md +5 -3
- package/dist/agent/controller.d.ts +5 -1
- package/dist/agent/controller.js +9 -4
- package/dist/agent/reducer.js +14 -4
- package/dist/cli.js +93 -0
- package/dist/commands/builtins.js +21 -2
- package/dist/commands/perf.d.ts +5 -1
- package/dist/commands/perf.js +43 -1
- package/dist/config/env.d.ts +1 -0
- package/dist/config/env.js +17 -0
- package/dist/config/load.d.ts +13 -0
- package/dist/config/load.js +31 -0
- package/dist/config/schema.d.ts +35 -0
- package/dist/config/schema.js +26 -0
- package/dist/todo/store.d.ts +12 -6
- package/dist/todo/store.js +12 -6
- package/dist/todo/types.d.ts +2 -1
- package/dist/ui/ActivityLine.d.ts +16 -0
- package/dist/ui/App.js +32 -3
- package/dist/ui/PromptInput.d.ts +10 -0
- package/dist/ui/PromptInput.js +41 -48
- package/dist/ui/Transcript.js +15 -0
- package/dist/ui/editor-reducer.d.ts +62 -0
- package/dist/ui/editor-reducer.js +90 -0
- package/dist/ui/entries/FastCard.js +20 -2
- package/dist/ui/frame-differ.d.ts +95 -0
- package/dist/ui/frame-differ.js +230 -0
- package/dist/ui/overlays/HelpOverlay.js +1 -1
- package/dist/ui/stdout-frame-writer.d.ts +41 -0
- package/dist/ui/stdout-frame-writer.js +102 -0
- package/package.json +2 -2
package/dist/todo/store.js
CHANGED
|
@@ -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
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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';
|
package/dist/todo/types.d.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
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
|
-
|
|
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
|
package/dist/ui/PromptInput.d.ts
CHANGED
|
@@ -7,6 +7,16 @@
|
|
|
7
7
|
* The buffer/cursor transforms (`applyEdit`, `moveVertical`) and the suggestion
|
|
8
8
|
* computations (`slashSuggestions`, `fileTokenAt`) are pure module functions,
|
|
9
9
|
* exported for `input.test.ts`, so the component itself stays thin.
|
|
10
|
+
*
|
|
11
|
+
* THE EDITOR'S FIVE PIECES OF STATE LIVE IN ONE REDUCER, and every branch of the
|
|
12
|
+
* input handler performs AT MOST ONE `dispatch` (tui-input-flicker-fix §3.4).
|
|
13
|
+
* Ink mounts a LEGACY React root, where `setState` outside a React event handler
|
|
14
|
+
* is not batched — and `useInput` runs from a stdin `'data'` listener, which is
|
|
15
|
+
* outside React entirely. The five `setState` calls one keystroke used to make
|
|
16
|
+
* were five commits, five whole-tree Yoga layouts and, through Ink's 32 ms
|
|
17
|
+
* leading+trailing throttle, two full-frame repaints per character. `fileMatches`
|
|
18
|
+
* stays a plain `useState` because it is written from the debounced glob effect
|
|
19
|
+
* rather than from a key.
|
|
10
20
|
*/
|
|
11
21
|
import React from 'react';
|
|
12
22
|
import type { Theme } from './theme.js';
|
package/dist/ui/PromptInput.js
CHANGED
|
@@ -8,13 +8,24 @@ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
|
8
8
|
* The buffer/cursor transforms (`applyEdit`, `moveVertical`) and the suggestion
|
|
9
9
|
* computations (`slashSuggestions`, `fileTokenAt`) are pure module functions,
|
|
10
10
|
* exported for `input.test.ts`, so the component itself stays thin.
|
|
11
|
+
*
|
|
12
|
+
* THE EDITOR'S FIVE PIECES OF STATE LIVE IN ONE REDUCER, and every branch of the
|
|
13
|
+
* input handler performs AT MOST ONE `dispatch` (tui-input-flicker-fix §3.4).
|
|
14
|
+
* Ink mounts a LEGACY React root, where `setState` outside a React event handler
|
|
15
|
+
* is not batched — and `useInput` runs from a stdin `'data'` listener, which is
|
|
16
|
+
* outside React entirely. The five `setState` calls one keystroke used to make
|
|
17
|
+
* were five commits, five whole-tree Yoga layouts and, through Ink's 32 ms
|
|
18
|
+
* leading+trailing throttle, two full-frame repaints per character. `fileMatches`
|
|
19
|
+
* stays a plain `useState` because it is written from the debounced glob effect
|
|
20
|
+
* rather than from a key.
|
|
11
21
|
*/
|
|
12
|
-
import { useEffect, useRef, useState } from 'react';
|
|
22
|
+
import { useEffect, useReducer, useRef, useState } from 'react';
|
|
13
23
|
import { Box, Text, useInput } from 'ink';
|
|
14
24
|
import { glob as tinyGlob } from 'tinyglobby';
|
|
15
25
|
import { pickGlyphs } from './glyphs.js';
|
|
16
26
|
import { recognize } from '../input/keymap.js';
|
|
17
27
|
import { AutocompletePopup } from './AutocompletePopup.js';
|
|
28
|
+
import { editorReducer, INITIAL_EDITOR_STATE } from './editor-reducer.js';
|
|
18
29
|
// ---------------------------------------------------------------------------
|
|
19
30
|
// Pure editing helpers (unit-tested in input.test.ts)
|
|
20
31
|
// ---------------------------------------------------------------------------
|
|
@@ -128,11 +139,8 @@ function isControlSeq(input) {
|
|
|
128
139
|
// Component
|
|
129
140
|
// ---------------------------------------------------------------------------
|
|
130
141
|
export function PromptInput({ isActive, running, history, commands, cwd, theme, caps, popupMaxRows, agentMode, onSubmit, onHelp, bordered, onDraftChange, }) {
|
|
131
|
-
const [
|
|
132
|
-
const
|
|
133
|
-
const [historyIndex, setHistoryIndex] = useState(null);
|
|
134
|
-
const [dismissed, setDismissed] = useState(false);
|
|
135
|
-
const [sel, setSel] = useState(0);
|
|
142
|
+
const [editor, dispatch] = useReducer(editorReducer, INITIAL_EDITOR_STATE);
|
|
143
|
+
const { buffer, cursor, historyIndex, dismissed, sel } = editor;
|
|
136
144
|
const [fileMatches, setFileMatches] = useState([]);
|
|
137
145
|
// --- Derived suggestion state (shared by render + input handler). --------
|
|
138
146
|
const slashSug = dismissed ? null : slashSuggestions(buffer, commands);
|
|
@@ -189,46 +197,36 @@ export function PromptInput({ isActive, running, history, commands, cwd, theme,
|
|
|
189
197
|
onDraftChange(hasDraft);
|
|
190
198
|
}, [hasDraft, onDraftChange]);
|
|
191
199
|
// --- Mutators. -----------------------------------------------------------
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
setSel(0);
|
|
196
|
-
};
|
|
200
|
+
// ONE DISPATCH EACH, and that is the rule §3.4 asks a reviewer to check line
|
|
201
|
+
// by line. Two dispatches from one key would put the legacy root back to two
|
|
202
|
+
// commits and undo the whole of F3 for that branch.
|
|
197
203
|
const insert = (text) => {
|
|
198
|
-
|
|
199
|
-
setCursor(cursor + text.length);
|
|
200
|
-
resetDraftFlags();
|
|
204
|
+
dispatch({ type: 'insert', text });
|
|
201
205
|
};
|
|
202
206
|
const submit = (text) => {
|
|
203
207
|
onSubmit(text);
|
|
204
|
-
|
|
205
|
-
setCursor(0);
|
|
206
|
-
resetDraftFlags();
|
|
208
|
+
dispatch({ type: 'clear' });
|
|
207
209
|
};
|
|
208
210
|
const recallUp = () => {
|
|
209
211
|
if (history.length === 0)
|
|
210
212
|
return;
|
|
211
213
|
const idx = historyIndex === null ? history.length - 1 : Math.max(0, historyIndex - 1);
|
|
212
214
|
const value = history[idx] ?? '';
|
|
213
|
-
|
|
214
|
-
setBuffer(value);
|
|
215
|
-
setCursor(value.length);
|
|
215
|
+
dispatch({ type: 'recall', buffer: value, cursor: value.length, historyIndex: idx });
|
|
216
216
|
};
|
|
217
217
|
const recallDown = () => {
|
|
218
218
|
if (historyIndex === null)
|
|
219
219
|
return;
|
|
220
220
|
const idx = historyIndex + 1;
|
|
221
|
+
// Past the newest entry the walk ends and the draft goes back to empty —
|
|
222
|
+
// `historyIndex: null` is what tells `verticalOrHistory` the user is editing
|
|
223
|
+
// again rather than still stepping.
|
|
221
224
|
if (idx >= history.length) {
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
setCursor(0);
|
|
225
|
-
}
|
|
226
|
-
else {
|
|
227
|
-
const value = history[idx] ?? '';
|
|
228
|
-
setHistoryIndex(idx);
|
|
229
|
-
setBuffer(value);
|
|
230
|
-
setCursor(value.length);
|
|
225
|
+
dispatch({ type: 'recall', buffer: '', cursor: 0, historyIndex: null });
|
|
226
|
+
return;
|
|
231
227
|
}
|
|
228
|
+
const value = history[idx] ?? '';
|
|
229
|
+
dispatch({ type: 'recall', buffer: value, cursor: value.length, historyIndex: idx });
|
|
232
230
|
};
|
|
233
231
|
const completeSelection = () => {
|
|
234
232
|
const chosen = popupItems[clampedSel];
|
|
@@ -236,20 +234,17 @@ export function PromptInput({ isActive, running, history, commands, cwd, theme,
|
|
|
236
234
|
return;
|
|
237
235
|
if (popupKind === 'slash') {
|
|
238
236
|
const next = `${chosen.label} `;
|
|
239
|
-
|
|
240
|
-
setCursor(next.length);
|
|
237
|
+
dispatch({ type: 'replace', buffer: next, cursor: next.length });
|
|
241
238
|
}
|
|
242
239
|
else if (popupKind === 'file' && fileTok) {
|
|
243
240
|
const next = buffer.slice(0, fileTok.start) + chosen.label + buffer.slice(fileTok.end);
|
|
244
|
-
|
|
245
|
-
setCursor(fileTok.start + chosen.label.length);
|
|
241
|
+
dispatch({ type: 'replace', buffer: next, cursor: fileTok.start + chosen.label.length });
|
|
246
242
|
}
|
|
247
|
-
resetDraftFlags();
|
|
248
243
|
};
|
|
249
244
|
const verticalOrHistory = (dir) => {
|
|
250
245
|
const moved = moveVertical(buffer, cursor, dir);
|
|
251
246
|
if (moved) {
|
|
252
|
-
|
|
247
|
+
dispatch({ type: 'moveCursor', cursor: moved.cursor });
|
|
253
248
|
return;
|
|
254
249
|
}
|
|
255
250
|
// At an edge: recall history only from an empty buffer or while already
|
|
@@ -274,11 +269,11 @@ export function PromptInput({ isActive, running, history, commands, cwd, theme,
|
|
|
274
269
|
// Popup navigation owns Up/Down/Tab/→/Enter/Esc while it is open.
|
|
275
270
|
if (popupItems.length > 0) {
|
|
276
271
|
if (key.upArrow) {
|
|
277
|
-
|
|
272
|
+
dispatch({ type: 'select', sel: Math.max(0, clampedSel - 1) });
|
|
278
273
|
return;
|
|
279
274
|
}
|
|
280
275
|
if (key.downArrow) {
|
|
281
|
-
|
|
276
|
+
dispatch({ type: 'select', sel: Math.min(popupItems.length - 1, clampedSel + 1) });
|
|
282
277
|
return;
|
|
283
278
|
}
|
|
284
279
|
if (key.tab || (key.rightArrow && cursor >= buffer.length)) {
|
|
@@ -292,7 +287,7 @@ export function PromptInput({ isActive, running, history, commands, cwd, theme,
|
|
|
292
287
|
return;
|
|
293
288
|
}
|
|
294
289
|
if (key.escape) {
|
|
295
|
-
|
|
290
|
+
dispatch({ type: 'dismiss' });
|
|
296
291
|
return;
|
|
297
292
|
}
|
|
298
293
|
// Any other key falls through so typing keeps filtering the popup.
|
|
@@ -303,9 +298,7 @@ export function PromptInput({ isActive, running, history, commands, cwd, theme,
|
|
|
303
298
|
const intent = recognize(input, key);
|
|
304
299
|
if (intent) {
|
|
305
300
|
const next = applyEdit(buffer, cursor, intent);
|
|
306
|
-
|
|
307
|
-
setCursor(next.cursor);
|
|
308
|
-
resetDraftFlags();
|
|
301
|
+
dispatch({ type: 'replace', buffer: next.buffer, cursor: next.cursor });
|
|
309
302
|
return;
|
|
310
303
|
}
|
|
311
304
|
if (key.ctrl)
|
|
@@ -321,19 +314,19 @@ export function PromptInput({ isActive, running, history, commands, cwd, theme,
|
|
|
321
314
|
return;
|
|
322
315
|
}
|
|
323
316
|
if (key.backspace || key.delete) {
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
}
|
|
317
|
+
// The `cursor > 0` guard lives in the reducer now, so this branch is one
|
|
318
|
+
// unconditional dispatch and a no-op at the left edge returns the same
|
|
319
|
+
// state object — which React bails out on, exactly as the old
|
|
320
|
+
// `if (cursor > 0)` skipped the `setState`s.
|
|
321
|
+
dispatch({ type: 'backspace' });
|
|
329
322
|
return;
|
|
330
323
|
}
|
|
331
324
|
if (key.leftArrow) {
|
|
332
|
-
|
|
325
|
+
dispatch({ type: 'moveCursor', cursor: Math.max(0, cursor - 1) });
|
|
333
326
|
return;
|
|
334
327
|
}
|
|
335
328
|
if (key.rightArrow) {
|
|
336
|
-
|
|
329
|
+
dispatch({ type: 'moveCursor', cursor: Math.min(buffer.length, cursor + 1) });
|
|
337
330
|
return;
|
|
338
331
|
}
|
|
339
332
|
// `!key.shift` is load-bearing: Shift+↑/↓ is the viewport's line-scroll
|
package/dist/ui/Transcript.js
CHANGED
|
@@ -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
|
-
|
|
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;
|