@aragon-agent/cli 0.5.9 → 0.5.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +1157 -1026
  2. package/README.md +123 -0
  3. package/dist/boot/guard.d.ts +135 -0
  4. package/dist/boot/guard.js +218 -0
  5. package/dist/boot/rollback.d.ts +86 -0
  6. package/dist/boot/rollback.js +185 -0
  7. package/dist/cli.js +376 -2
  8. package/dist/commands/builtins.js +118 -0
  9. package/dist/commands/perf.d.ts +5 -1
  10. package/dist/commands/perf.js +43 -1
  11. package/dist/commands/registry.d.ts +17 -0
  12. package/dist/config/app-paths.d.ts +31 -0
  13. package/dist/config/app-paths.js +37 -0
  14. package/dist/config/cli-commands.d.ts +27 -1
  15. package/dist/config/cli-commands.js +51 -2
  16. package/dist/config/env.d.ts +11 -1
  17. package/dist/config/env.js +58 -1
  18. package/dist/config/load.d.ts +30 -0
  19. package/dist/config/load.js +56 -1
  20. package/dist/config/schema.d.ts +91 -0
  21. package/dist/config/schema.js +107 -0
  22. package/dist/config/store.js +13 -1
  23. package/dist/launcher.d.ts +14 -0
  24. package/dist/launcher.js +43 -0
  25. package/dist/logging/logger.d.ts +1 -1
  26. package/dist/ui/App.d.ts +11 -1
  27. package/dist/ui/App.js +88 -2
  28. package/dist/ui/BottomStatusRow.d.ts +25 -3
  29. package/dist/ui/BottomStatusRow.js +10 -7
  30. package/dist/ui/PromptInput.d.ts +10 -0
  31. package/dist/ui/PromptInput.js +41 -48
  32. package/dist/ui/UpdateLine.d.ts +44 -0
  33. package/dist/ui/UpdateLine.js +129 -0
  34. package/dist/ui/editor-reducer.d.ts +62 -0
  35. package/dist/ui/editor-reducer.js +90 -0
  36. package/dist/ui/frame-differ.d.ts +95 -0
  37. package/dist/ui/frame-differ.js +230 -0
  38. package/dist/ui/stdout-frame-writer.d.ts +41 -0
  39. package/dist/ui/stdout-frame-writer.js +102 -0
  40. package/dist/update/classify-failure.d.ts +32 -0
  41. package/dist/update/classify-failure.js +60 -0
  42. package/dist/update/install-lock.d.ts +55 -0
  43. package/dist/update/install-lock.js +184 -0
  44. package/dist/update/install-source.d.ts +97 -0
  45. package/dist/update/install-source.js +218 -0
  46. package/dist/update/installer.d.ts +108 -0
  47. package/dist/update/installer.js +242 -0
  48. package/dist/update/limits.d.ts +111 -0
  49. package/dist/update/limits.js +111 -0
  50. package/dist/update/npm-view.d.ts +62 -0
  51. package/dist/update/npm-view.js +133 -0
  52. package/dist/update/registry.d.ts +52 -0
  53. package/dist/update/registry.js +161 -0
  54. package/dist/update/semver.d.ts +89 -0
  55. package/dist/update/semver.js +295 -0
  56. package/dist/update/service.d.ts +193 -0
  57. package/dist/update/service.js +636 -0
  58. package/dist/update/state.d.ts +103 -0
  59. package/dist/update/state.js +142 -0
  60. package/dist/update/types.d.ts +169 -0
  61. package/dist/update/types.js +68 -0
  62. package/package.json +3 -3
@@ -8,13 +8,24 @@ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
8
8
  * The buffer/cursor transforms (`applyEdit`, `moveVertical`) and the suggestion
9
9
  * computations (`slashSuggestions`, `fileTokenAt`) are pure module functions,
10
10
  * exported for `input.test.ts`, so the component itself stays thin.
11
+ *
12
+ * THE EDITOR'S FIVE PIECES OF STATE LIVE IN ONE REDUCER, and every branch of the
13
+ * input handler performs AT MOST ONE `dispatch` (tui-input-flicker-fix §3.4).
14
+ * Ink mounts a LEGACY React root, where `setState` outside a React event handler
15
+ * is not batched — and `useInput` runs from a stdin `'data'` listener, which is
16
+ * outside React entirely. The five `setState` calls one keystroke used to make
17
+ * were five commits, five whole-tree Yoga layouts and, through Ink's 32 ms
18
+ * leading+trailing throttle, two full-frame repaints per character. `fileMatches`
19
+ * stays a plain `useState` because it is written from the debounced glob effect
20
+ * rather than from a key.
11
21
  */
12
- import { useEffect, useRef, useState } from 'react';
22
+ import { useEffect, useReducer, useRef, useState } from 'react';
13
23
  import { Box, Text, useInput } from 'ink';
14
24
  import { glob as tinyGlob } from 'tinyglobby';
15
25
  import { pickGlyphs } from './glyphs.js';
16
26
  import { recognize } from '../input/keymap.js';
17
27
  import { AutocompletePopup } from './AutocompletePopup.js';
28
+ import { editorReducer, INITIAL_EDITOR_STATE } from './editor-reducer.js';
18
29
  // ---------------------------------------------------------------------------
19
30
  // Pure editing helpers (unit-tested in input.test.ts)
20
31
  // ---------------------------------------------------------------------------
@@ -128,11 +139,8 @@ function isControlSeq(input) {
128
139
  // Component
129
140
  // ---------------------------------------------------------------------------
130
141
  export function PromptInput({ isActive, running, history, commands, cwd, theme, caps, popupMaxRows, agentMode, onSubmit, onHelp, bordered, onDraftChange, }) {
131
- const [buffer, setBuffer] = useState('');
132
- const [cursor, setCursor] = useState(0);
133
- const [historyIndex, setHistoryIndex] = useState(null);
134
- const [dismissed, setDismissed] = useState(false);
135
- const [sel, setSel] = useState(0);
142
+ const [editor, dispatch] = useReducer(editorReducer, INITIAL_EDITOR_STATE);
143
+ const { buffer, cursor, historyIndex, dismissed, sel } = editor;
136
144
  const [fileMatches, setFileMatches] = useState([]);
137
145
  // --- Derived suggestion state (shared by render + input handler). --------
138
146
  const slashSug = dismissed ? null : slashSuggestions(buffer, commands);
@@ -189,46 +197,36 @@ export function PromptInput({ isActive, running, history, commands, cwd, theme,
189
197
  onDraftChange(hasDraft);
190
198
  }, [hasDraft, onDraftChange]);
191
199
  // --- Mutators. -----------------------------------------------------------
192
- const resetDraftFlags = () => {
193
- setHistoryIndex(null);
194
- setDismissed(false);
195
- setSel(0);
196
- };
200
+ // ONE DISPATCH EACH, and that is the rule §3.4 asks a reviewer to check line
201
+ // by line. Two dispatches from one key would put the legacy root back to two
202
+ // commits and undo the whole of F3 for that branch.
197
203
  const insert = (text) => {
198
- setBuffer(buffer.slice(0, cursor) + text + buffer.slice(cursor));
199
- setCursor(cursor + text.length);
200
- resetDraftFlags();
204
+ dispatch({ type: 'insert', text });
201
205
  };
202
206
  const submit = (text) => {
203
207
  onSubmit(text);
204
- setBuffer('');
205
- setCursor(0);
206
- resetDraftFlags();
208
+ dispatch({ type: 'clear' });
207
209
  };
208
210
  const recallUp = () => {
209
211
  if (history.length === 0)
210
212
  return;
211
213
  const idx = historyIndex === null ? history.length - 1 : Math.max(0, historyIndex - 1);
212
214
  const value = history[idx] ?? '';
213
- setHistoryIndex(idx);
214
- setBuffer(value);
215
- setCursor(value.length);
215
+ dispatch({ type: 'recall', buffer: value, cursor: value.length, historyIndex: idx });
216
216
  };
217
217
  const recallDown = () => {
218
218
  if (historyIndex === null)
219
219
  return;
220
220
  const idx = historyIndex + 1;
221
+ // Past the newest entry the walk ends and the draft goes back to empty —
222
+ // `historyIndex: null` is what tells `verticalOrHistory` the user is editing
223
+ // again rather than still stepping.
221
224
  if (idx >= history.length) {
222
- setHistoryIndex(null);
223
- setBuffer('');
224
- setCursor(0);
225
- }
226
- else {
227
- const value = history[idx] ?? '';
228
- setHistoryIndex(idx);
229
- setBuffer(value);
230
- setCursor(value.length);
225
+ dispatch({ type: 'recall', buffer: '', cursor: 0, historyIndex: null });
226
+ return;
231
227
  }
228
+ const value = history[idx] ?? '';
229
+ dispatch({ type: 'recall', buffer: value, cursor: value.length, historyIndex: idx });
232
230
  };
233
231
  const completeSelection = () => {
234
232
  const chosen = popupItems[clampedSel];
@@ -236,20 +234,17 @@ export function PromptInput({ isActive, running, history, commands, cwd, theme,
236
234
  return;
237
235
  if (popupKind === 'slash') {
238
236
  const next = `${chosen.label} `;
239
- setBuffer(next);
240
- setCursor(next.length);
237
+ dispatch({ type: 'replace', buffer: next, cursor: next.length });
241
238
  }
242
239
  else if (popupKind === 'file' && fileTok) {
243
240
  const next = buffer.slice(0, fileTok.start) + chosen.label + buffer.slice(fileTok.end);
244
- setBuffer(next);
245
- setCursor(fileTok.start + chosen.label.length);
241
+ dispatch({ type: 'replace', buffer: next, cursor: fileTok.start + chosen.label.length });
246
242
  }
247
- resetDraftFlags();
248
243
  };
249
244
  const verticalOrHistory = (dir) => {
250
245
  const moved = moveVertical(buffer, cursor, dir);
251
246
  if (moved) {
252
- setCursor(moved.cursor);
247
+ dispatch({ type: 'moveCursor', cursor: moved.cursor });
253
248
  return;
254
249
  }
255
250
  // At an edge: recall history only from an empty buffer or while already
@@ -274,11 +269,11 @@ export function PromptInput({ isActive, running, history, commands, cwd, theme,
274
269
  // Popup navigation owns Up/Down/Tab/→/Enter/Esc while it is open.
275
270
  if (popupItems.length > 0) {
276
271
  if (key.upArrow) {
277
- setSel(Math.max(0, clampedSel - 1));
272
+ dispatch({ type: 'select', sel: Math.max(0, clampedSel - 1) });
278
273
  return;
279
274
  }
280
275
  if (key.downArrow) {
281
- setSel(Math.min(popupItems.length - 1, clampedSel + 1));
276
+ dispatch({ type: 'select', sel: Math.min(popupItems.length - 1, clampedSel + 1) });
282
277
  return;
283
278
  }
284
279
  if (key.tab || (key.rightArrow && cursor >= buffer.length)) {
@@ -292,7 +287,7 @@ export function PromptInput({ isActive, running, history, commands, cwd, theme,
292
287
  return;
293
288
  }
294
289
  if (key.escape) {
295
- setDismissed(true);
290
+ dispatch({ type: 'dismiss' });
296
291
  return;
297
292
  }
298
293
  // Any other key falls through so typing keeps filtering the popup.
@@ -303,9 +298,7 @@ export function PromptInput({ isActive, running, history, commands, cwd, theme,
303
298
  const intent = recognize(input, key);
304
299
  if (intent) {
305
300
  const next = applyEdit(buffer, cursor, intent);
306
- setBuffer(next.buffer);
307
- setCursor(next.cursor);
308
- resetDraftFlags();
301
+ dispatch({ type: 'replace', buffer: next.buffer, cursor: next.cursor });
309
302
  return;
310
303
  }
311
304
  if (key.ctrl)
@@ -321,19 +314,19 @@ export function PromptInput({ isActive, running, history, commands, cwd, theme,
321
314
  return;
322
315
  }
323
316
  if (key.backspace || key.delete) {
324
- if (cursor > 0) {
325
- setBuffer(buffer.slice(0, cursor - 1) + buffer.slice(cursor));
326
- setCursor(cursor - 1);
327
- resetDraftFlags();
328
- }
317
+ // The `cursor > 0` guard lives in the reducer now, so this branch is one
318
+ // unconditional dispatch and a no-op at the left edge returns the same
319
+ // state object — which React bails out on, exactly as the old
320
+ // `if (cursor > 0)` skipped the `setState`s.
321
+ dispatch({ type: 'backspace' });
329
322
  return;
330
323
  }
331
324
  if (key.leftArrow) {
332
- setCursor(Math.max(0, cursor - 1));
325
+ dispatch({ type: 'moveCursor', cursor: Math.max(0, cursor - 1) });
333
326
  return;
334
327
  }
335
328
  if (key.rightArrow) {
336
- setCursor(Math.min(buffer.length, cursor + 1));
329
+ dispatch({ type: 'moveCursor', cursor: Math.min(buffer.length, cursor + 1) });
337
330
  return;
338
331
  }
339
332
  // `!key.shift` is load-bearing: Shift+↑/↓ is the viewport's line-scroll
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The update line (cli-auto-update section 6.2) - the THIRD occupant of the
3
+ * already-budgeted bottom row, after the toast stack and the activity line.
4
+ *
5
+ * ONE ROW, ALWAYS, AND NEVER `null` (C-15 / P0-1 / D-19). The return type is
6
+ * `React.ReactElement` rather than `React.ReactElement | null`, so the compiler
7
+ * refuses the shortcut: `BottomStatusRow` decides presence with `if (update)`,
8
+ * which tests a REACT ELEMENT, and an element whose render returns `null` is
9
+ * still truthy. Taking that branch would emit a box of ZERO rows - the frame
10
+ * keeps its fixed height, Yoga hands the row to the transcript, and
11
+ * `viewportRows()` goes on reporting the old number to `ScrollViewport`,
12
+ * `selectWindow`, `overlayMaxRows`, `popupMaxRows` and `todoRailRows`. The
13
+ * transcript would gain a row while the updater is idle and lose it the moment
14
+ * it has news. Presence is the CALLER's decision, through
15
+ * `shouldRenderUpdateLine`, exactly as `App.tsx` already decides the activity
16
+ * line's with `running && !overlayNode`.
17
+ *
18
+ * PURE: a function of `snapshot + compact + theme + caps`. No state, no timer,
19
+ * no clock. `<Text wrap="truncate">` so a narrow terminal degrades by truncation
20
+ * rather than by wrapping into a second row the fixed frame cannot afford.
21
+ *
22
+ * ASCII ONLY, and every user-visible character comes from `pickGlyphs(caps)`
23
+ * (C-3). No literal `...`, `+` or `~` may be spelled inline - `ActivityLine`'s
24
+ * routing of even the ellipsis through `pickGlyphs` is the precedent.
25
+ *
26
+ * `compact` RATHER THAN `cols` (IF-1). Section 6.2 describes the width tiers in
27
+ * terms of `cols`, but resolving them here would mean importing
28
+ * `UPDATE_LIMITS.statusCompactCols` - a VALUE - from `update/`, and section 3.1
29
+ * rule 1 requires this module's only edge into that tree to be erasable by tsc.
30
+ * The caller owns `UPDATE_LIMITS` already (it computes presence from it), so it
31
+ * resolves the threshold too. Same rule as presence: the call site decides.
32
+ */
33
+ import React from 'react';
34
+ import type { Theme } from './theme.js';
35
+ import type { TermCapabilities } from './capabilities.js';
36
+ import type { UpdateSnapshot } from '../update/types.js';
37
+ export interface UpdateLineProps {
38
+ snapshot: UpdateSnapshot;
39
+ /** `cols < UPDATE_LIMITS.statusCompactCols`, resolved by the caller. */
40
+ compact: boolean;
41
+ theme: Theme;
42
+ caps: TermCapabilities;
43
+ }
44
+ export declare function UpdateLine({ snapshot, compact, theme, caps }: UpdateLineProps): React.ReactElement;
@@ -0,0 +1,129 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { pickGlyphs } from './glyphs.js';
4
+ export function UpdateLine({ snapshot, compact, theme, caps }) {
5
+ const glyphs = pickGlyphs(caps);
6
+ const version = snapshot.latestVersion ?? snapshot.currentVersion;
7
+ const advice = snapshot.advice ?? '';
8
+ const dot = glyphs.midDot;
9
+ const line = (() => {
10
+ if (snapshot.phase === 'installing') {
11
+ return {
12
+ glyph: glyphs.arrowUp,
13
+ text: compact
14
+ ? `${version}${glyphs.ellipsis}`
15
+ : `updating to ${version}${glyphs.ellipsis}`,
16
+ color: theme.muted,
17
+ };
18
+ }
19
+ if (snapshot.phase === 'ready') {
20
+ return {
21
+ glyph: glyphs.check,
22
+ text: compact ? `${version} ready` : `${version} installed ${dot} restart aragon to apply`,
23
+ color: theme.toast.success,
24
+ };
25
+ }
26
+ if (snapshot.phase === 'failed') {
27
+ // H2's three classified failures (cli-auto-update-hardening section 8).
28
+ // Each names the REAL obstacle rather than the generic one, because each
29
+ // is allowed to break the three-strike silence and a notice that breaks
30
+ // silence must be worth the interruption.
31
+ if (snapshot.reason === 'blocked-by-os') {
32
+ // `npm i -g` FROM THE SAME SHELL HITS THE SAME HANDLE. On Windows the
33
+ // shim that launched us is being read by the `cmd.exe` running it for
34
+ // as long as our process lives, and npm's install rewrites that shim -
35
+ // so the generic advice is not merely unhelpful here, it is a wrong
36
+ // instruction (R-3 / section 5.3.1).
37
+ return {
38
+ glyph: glyphs.warn,
39
+ text: compact || !advice
40
+ ? 'update blocked'
41
+ : `update blocked ${dot} close other aragon windows, then: ${advice}`,
42
+ color: theme.noticeWarn,
43
+ };
44
+ }
45
+ if (snapshot.reason === 'no-space') {
46
+ return {
47
+ glyph: glyphs.warn,
48
+ text: compact ? 'no disk space' : `update failed ${dot} no disk space`,
49
+ color: theme.noticeWarn,
50
+ };
51
+ }
52
+ if (snapshot.reason === 'not-writable') {
53
+ return {
54
+ glyph: glyphs.warn,
55
+ text: compact || !advice
56
+ ? 'update blocked'
57
+ : `update needs write access ${dot} ${advice}`,
58
+ color: theme.noticeWarn,
59
+ };
60
+ }
61
+ return {
62
+ glyph: glyphs.warn,
63
+ text: compact || !advice ? 'update failed' : `update failed ${dot} ${advice}`,
64
+ color: theme.noticeWarn,
65
+ };
66
+ }
67
+ if (snapshot.phase === 'available') {
68
+ if (snapshot.reason === 'node-too-old') {
69
+ // Both numbers or neither: "needs a newer Node" without saying which is
70
+ // a notice the user cannot act on.
71
+ const detail = snapshot.requiredNode && snapshot.runningNode
72
+ ? `${version} needs Node ${snapshot.requiredNode} (running ${snapshot.runningNode})`
73
+ : `${version} needs a newer Node`;
74
+ return {
75
+ glyph: glyphs.warn,
76
+ text: compact ? 'needs newer Node' : detail,
77
+ color: theme.noticeWarn,
78
+ };
79
+ }
80
+ if (snapshot.reason === 'install-ineffective') {
81
+ return {
82
+ glyph: glyphs.warn,
83
+ text: compact
84
+ ? 'check npm prefix'
85
+ : `${version} installed elsewhere${advice ? ` ${dot} ${advice}` : ''}`,
86
+ color: theme.noticeWarn,
87
+ };
88
+ }
89
+ return {
90
+ glyph: glyphs.arrowUp,
91
+ text: compact
92
+ ? version
93
+ : `${version} available${advice ? ` ${dot} ${advice}` : ''}`,
94
+ color: theme.muted,
95
+ };
96
+ }
97
+ // H1's rollback notice, and it goes HERE rather than in a phase branch
98
+ // (cli-auto-update-hardening section 8 / P0-1). It is true for a whole
99
+ // SESSION rather than for a phase, so `shouldRenderUpdateLine` decides its
100
+ // presence from `snapshot.rolledBackFrom` outside `phase` entirely - and the
101
+ // phases that leaves are exactly `idle` / `checking`, which is this
102
+ // fallthrough. An earlier draft put it in the `failed` / `available`
103
+ // branches and keyed it on phase `idle`, a phase neither branch handles;
104
+ // three independent suppressions, so the one line telling a user their CLI
105
+ // had been silently downgraded could never have appeared.
106
+ //
107
+ // NOT `version` ABOVE: that is `latestVersion ?? currentVersion`, and after
108
+ // a rollback `latestVersion` may well be the BAD release we just escaped.
109
+ if (snapshot.rolledBackFrom) {
110
+ const to = snapshot.rolledBackTo ?? snapshot.currentVersion;
111
+ return {
112
+ glyph: glyphs.warn,
113
+ text: compact
114
+ ? 'rolled back'
115
+ : `rolled back to ${to} after ${snapshot.rolledBackFrom} failed to start`,
116
+ color: theme.noticeWarn,
117
+ };
118
+ }
119
+ // `idle` / `checking`, and a `failed` below the notice threshold. The CALLER
120
+ // never mounts us here (`shouldRenderUpdateLine` is false), but the row must
121
+ // still be exactly one row if it ever is - the same blank-row discipline
122
+ // `ToastStack` applies to its own budgeted row.
123
+ return null;
124
+ })();
125
+ if (!line) {
126
+ return (_jsx(Box, { flexShrink: 0, children: _jsx(Text, { children: " " }) }));
127
+ }
128
+ return (_jsx(Box, { flexShrink: 0, children: _jsxs(Text, { wrap: "truncate", color: line.color, children: [' ', line.glyph, " ", line.text] }) }));
129
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Composer editor state — F3 of tui-input-flicker-fix (§3.4).
3
+ *
4
+ * Ink creates its container with tag `0` — a LEGACY React root
5
+ * (`ink/build/ink.js:59-61`). In a legacy root, `setState` called outside a
6
+ * React event handler is NOT batched: each call schedules and immediately
7
+ * flushes a synchronous render. `useInput`'s callback runs from a stdin
8
+ * `'data'` listener, which is outside React entirely, so `PromptInput.insert`'s
9
+ * five `setState` calls became five commits, five whole-tree Yoga layouts, and
10
+ * — through Ink's 32 ms leading+trailing throttle — TWO full-frame repaints per
11
+ * printable character (§2 R3). The flicker got worse the faster the user typed,
12
+ * which is exactly what the report described.
13
+ *
14
+ * Collapsing the five always-together pieces of state into one reducer makes a
15
+ * keystroke cost one commit. `fileMatches` deliberately stays a separate
16
+ * `useState` in the component: it is written from the debounced async glob
17
+ * effect, not from the key handler, so folding it in here would couple two
18
+ * unrelated update sources.
19
+ *
20
+ * THE RULE A REVIEWER SHOULD CHECK LINE BY LINE: every branch of the `useInput`
21
+ * callback performs at most ONE `dispatch` and then returns.
22
+ *
23
+ * Extracted from the component so it is unit-testable without mounting Ink.
24
+ */
25
+ export interface EditorState {
26
+ buffer: string;
27
+ cursor: number;
28
+ historyIndex: number | null;
29
+ dismissed: boolean;
30
+ sel: number;
31
+ }
32
+ /**
33
+ * A `type` and not an `interface` because it is a discriminated union — the one
34
+ * documented exception to this package's interface-first convention.
35
+ */
36
+ export type EditorAction = {
37
+ type: 'insert';
38
+ text: string;
39
+ } | {
40
+ type: 'replace';
41
+ buffer: string;
42
+ cursor: number;
43
+ } | {
44
+ type: 'backspace';
45
+ } | {
46
+ type: 'moveCursor';
47
+ cursor: number;
48
+ } | {
49
+ type: 'recall';
50
+ buffer: string;
51
+ cursor: number;
52
+ historyIndex: number | null;
53
+ } | {
54
+ type: 'select';
55
+ sel: number;
56
+ } | {
57
+ type: 'dismiss';
58
+ } | {
59
+ type: 'clear';
60
+ };
61
+ export declare const INITIAL_EDITOR_STATE: EditorState;
62
+ export declare function editorReducer(state: EditorState, action: EditorAction): EditorState;
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Composer editor state — F3 of tui-input-flicker-fix (§3.4).
3
+ *
4
+ * Ink creates its container with tag `0` — a LEGACY React root
5
+ * (`ink/build/ink.js:59-61`). In a legacy root, `setState` called outside a
6
+ * React event handler is NOT batched: each call schedules and immediately
7
+ * flushes a synchronous render. `useInput`'s callback runs from a stdin
8
+ * `'data'` listener, which is outside React entirely, so `PromptInput.insert`'s
9
+ * five `setState` calls became five commits, five whole-tree Yoga layouts, and
10
+ * — through Ink's 32 ms leading+trailing throttle — TWO full-frame repaints per
11
+ * printable character (§2 R3). The flicker got worse the faster the user typed,
12
+ * which is exactly what the report described.
13
+ *
14
+ * Collapsing the five always-together pieces of state into one reducer makes a
15
+ * keystroke cost one commit. `fileMatches` deliberately stays a separate
16
+ * `useState` in the component: it is written from the debounced async glob
17
+ * effect, not from the key handler, so folding it in here would couple two
18
+ * unrelated update sources.
19
+ *
20
+ * THE RULE A REVIEWER SHOULD CHECK LINE BY LINE: every branch of the `useInput`
21
+ * callback performs at most ONE `dispatch` and then returns.
22
+ *
23
+ * Extracted from the component so it is unit-testable without mounting Ink.
24
+ */
25
+ export const INITIAL_EDITOR_STATE = {
26
+ buffer: '',
27
+ cursor: 0,
28
+ historyIndex: null,
29
+ dismissed: false,
30
+ sel: 0,
31
+ };
32
+ /**
33
+ * The three flags `resetDraftFlags()` used to clear with three separate
34
+ * `setState` calls.
35
+ *
36
+ * Every action that CHANGES THE BUFFER resets them, and that is the pre-existing
37
+ * semantics rather than a new rule: an edit invalidates the history cursor (the
38
+ * draft is no longer the recalled entry), un-dismisses the popup (the user is
39
+ * typing again, so suggestions should come back), and re-homes the selection
40
+ * (the candidate list is about to be recomputed).
41
+ */
42
+ const DRAFT_FLAGS = { historyIndex: null, dismissed: false, sel: 0 };
43
+ export function editorReducer(state, action) {
44
+ switch (action.type) {
45
+ case 'insert':
46
+ return {
47
+ ...state,
48
+ ...DRAFT_FLAGS,
49
+ buffer: state.buffer.slice(0, state.cursor) + action.text + state.buffer.slice(state.cursor),
50
+ cursor: state.cursor + action.text.length,
51
+ };
52
+ case 'replace':
53
+ return { ...state, ...DRAFT_FLAGS, buffer: action.buffer, cursor: action.cursor };
54
+ case 'backspace': {
55
+ if (state.cursor <= 0)
56
+ return state;
57
+ return {
58
+ ...state,
59
+ ...DRAFT_FLAGS,
60
+ buffer: state.buffer.slice(0, state.cursor - 1) + state.buffer.slice(state.cursor),
61
+ cursor: state.cursor - 1,
62
+ };
63
+ }
64
+ case 'moveCursor':
65
+ // NO DRAFT-FLAG RESET. Moving the caret is not an edit: clearing
66
+ // `historyIndex` here would end a history walk the moment the user pressed
67
+ // ← to look at what they recalled.
68
+ return state.cursor === action.cursor ? state : { ...state, cursor: action.cursor };
69
+ case 'recall':
70
+ // `historyIndex` is SET, not reset, which is why recall cannot reuse
71
+ // `replace` — the two differ in exactly the field that keeps the walk
72
+ // alive. `dismissed` and `sel` are LEFT ALONE, which is not an oversight:
73
+ // the three `setState` calls this replaces did not touch them either, and
74
+ // an Esc-dismissed popup that reopened when the user stepped through their
75
+ // history would be a behaviour change smuggled in by a refactor whose whole
76
+ // claim is that input semantics are identical (K-7).
77
+ return {
78
+ ...state,
79
+ buffer: action.buffer,
80
+ cursor: action.cursor,
81
+ historyIndex: action.historyIndex,
82
+ };
83
+ case 'select':
84
+ return state.sel === action.sel ? state : { ...state, sel: action.sel };
85
+ case 'dismiss':
86
+ return state.dismissed ? state : { ...state, dismissed: true };
87
+ case 'clear':
88
+ return INITIAL_EDITOR_STATE;
89
+ }
90
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Frame differ — F1 of tui-input-flicker-fix (§3.2).
3
+ *
4
+ * Ink 5.2.1 has exactly one repaint strategy: on every commit it serialises the
5
+ * WHOLE frame and hands it to `log-update`, which writes
6
+ * `eraseLines(previousLineCount) + frame` in a single `stdout.write`
7
+ * (`ink/build/log-update.js:17`). `ansiEscapes.eraseLines` walks BOTTOM-UP, so
8
+ * the composer — the last row of the frame — is erased near the start of every
9
+ * repaint and rewritten at the very end of it. During a run that happens 15–30
10
+ * times a second with the composer's bytes unchanged, and the user sees the
11
+ * bottom of their screen flicker while they type (§2 R1/R2).
12
+ *
13
+ * This module recognises that write shape, diffs the new frame against the
14
+ * previous one line by line, and emits only the rows that actually changed, each
15
+ * addressed absolutely. It is PURE: no I/O, no Node globals, no event emitter —
16
+ * `stdout-frame-writer.ts` is what binds it to a real stream. That is what keeps
17
+ * it unit-testable with plain string literals and no TTY.
18
+ *
19
+ * THREE INVARIANTS LIVE HERE AND EACH ONE FAILS SILENTLY IF DROPPED:
20
+ *
21
+ * - I-4 · An emitted line is `CUP · SGR0 · text · SGR0 · CSI K`, IN THAT ORDER,
22
+ * and it never contains `CSI 2K`. Erasing first would blank the row and refill
23
+ * it, which is R1's defect at 1/H the scale but on the one row the user is
24
+ * watching. Overwriting in place means no cell is ever presentably empty; the
25
+ * trailing `CSI K` removes only the residue a longer previous line left, and
26
+ * the trailing SGR reset before it stops a BCE terminal painting that residue
27
+ * in whatever background colour the line ended inside.
28
+ * - I-5 · Every emitted batch ends parked on row `H+1`. Ink's own
29
+ * `previousLineCount` is computed from the string IT produced and is
30
+ * unaffected by what we actually wrote, so a later pass-through's
31
+ * `eraseLines(H+1)` must find the cursor exactly where log-update left it last
32
+ * time. Drop this and one fallback shifts the frame up a row for the rest of
33
+ * the session.
34
+ * - I-6 · Any unrecognised write invalidates the cache. Absolute addressing is
35
+ * valid only while nothing else writes to stdout. Full-screen mode already
36
+ * forbids that, but "forbidden" is not "impossible", and the failure mode of
37
+ * the guard being absent is a permanently misaligned screen while the failure
38
+ * mode of it firing spuriously is one extra full repaint.
39
+ */
40
+ /**
41
+ * Rebuild `ansiEscapes.eraseLines(count)` byte for byte.
42
+ *
43
+ * Exported for the upgrade tripwire (`frame-differ-ink-shape.test.ts`), which
44
+ * drives the real `ink/build/log-update.js` and asserts this reconstruction is
45
+ * byte-equal to what it produced. If a future Ink changes the shape, that test
46
+ * fails loudly instead of this module silently degrading to pass-through for the
47
+ * rest of the package's life (K-2).
48
+ */
49
+ export declare function eraseLinesPrefix(count: number): string;
50
+ export interface FrameDifferOptions {
51
+ /** Emit the DEC 2026 envelope around each batch. */
52
+ sync: boolean;
53
+ /**
54
+ * Live terminal height. Read on every `transform`, NEVER cached: it is the
55
+ * only input to the I-9 geometry guard and it changes under the differ's feet
56
+ * on resize. `undefined` ⇒ stand down and pass through.
57
+ */
58
+ rows: () => number | undefined;
59
+ /** Raised once, on the 0→1 edge of `fallbacks` (§5.6). */
60
+ onFirstFallback?: () => void;
61
+ }
62
+ export interface FrameWriterStats {
63
+ /** Frames the differ EMITTED — `framesDiffed + framesFull`, never pass-throughs. */
64
+ framesTotal: number;
65
+ framesDiffed: number;
66
+ framesFull: number;
67
+ linesWritten: number;
68
+ bytesWritten: number;
69
+ /**
70
+ * Pass-throughs caused by a write the differ did not understand.
71
+ *
72
+ * NOT INVALIDATIONS (AC-11 / K-14). A resize legitimately invalidates and shows
73
+ * up as `framesFull`; counting invalidations here would put AC-7 and AC-11 in
74
+ * direct contradiction and one of them would end up quietly relaxed. It also
75
+ * excludes the two pass-throughs this module inflicts on ITSELF and already
76
+ * knows are benign — the session's seed write and the degenerate-geometry
77
+ * stand-down — because `fallbacks > 0` is documented to mean "something wrote
78
+ * to stdout behind Ink's back" and is what raises the §5.6 notice.
79
+ */
80
+ fallbacks: number;
81
+ }
82
+ export interface FrameDiffer {
83
+ /**
84
+ * Bytes to write instead of `chunk`; `''` = write nothing; `null` = write
85
+ * `chunk`. The parameter is `string` and only `string` (P1-4): decoding, and
86
+ * the decision about what is even diffable, belong to the writer — the only
87
+ * place untyped input arrives.
88
+ */
89
+ transform(chunk: string): string | null;
90
+ /** Force the next frame to be a full repaint. */
91
+ invalidate(): void;
92
+ /** Single owner of the counters (P2-2); the writer re-exports this. */
93
+ stats(): FrameWriterStats;
94
+ }
95
+ export declare function createFrameDiffer(options: FrameDifferOptions): FrameDiffer;