@aragon-agent/cli 0.5.12 → 0.6.2

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 (63) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/README.md +322 -6
  3. package/dist/agent/agent-mode.d.ts +39 -0
  4. package/dist/agent/agent-mode.js +36 -0
  5. package/dist/agent/controller.d.ts +32 -0
  6. package/dist/agent/controller.js +25 -0
  7. package/dist/agent/system-prompt.d.ts +16 -0
  8. package/dist/agent/system-prompt.js +5 -0
  9. package/dist/cli.js +143 -2
  10. package/dist/config/ui-state.d.ts +29 -0
  11. package/dist/config/ui-state.js +14 -4
  12. package/dist/diagnostics/cli-commands.d.ts +10 -0
  13. package/dist/diagnostics/cli-commands.js +61 -0
  14. package/dist/diagnostics/doctor.d.ts +29 -0
  15. package/dist/diagnostics/doctor.js +207 -0
  16. package/dist/diagnostics/info.d.ts +30 -0
  17. package/dist/diagnostics/info.js +99 -0
  18. package/dist/exec/cli-commands.d.ts +31 -0
  19. package/dist/exec/cli-commands.js +63 -0
  20. package/dist/exec/emitter.d.ts +80 -0
  21. package/dist/exec/emitter.js +127 -0
  22. package/dist/exec/events.d.ts +226 -0
  23. package/dist/exec/events.js +43 -0
  24. package/dist/exec/index.d.ts +56 -0
  25. package/dist/exec/index.js +439 -0
  26. package/dist/exec/options.d.ts +76 -0
  27. package/dist/exec/options.js +221 -0
  28. package/dist/exec/permission.d.ts +66 -0
  29. package/dist/exec/permission.js +96 -0
  30. package/dist/exec/runner.d.ts +204 -0
  31. package/dist/exec/runner.js +559 -0
  32. package/dist/exec/stdin-stream.d.ts +46 -0
  33. package/dist/exec/stdin-stream.js +144 -0
  34. package/dist/logging/install.d.ts +22 -0
  35. package/dist/logging/install.js +22 -0
  36. package/dist/session/cli-commands.d.ts +30 -0
  37. package/dist/session/cli-commands.js +184 -0
  38. package/dist/session/persist.d.ts +46 -0
  39. package/dist/session/persist.js +8 -0
  40. package/dist/session/store.d.ts +147 -0
  41. package/dist/session/store.js +442 -0
  42. package/dist/team/runtime.d.ts +12 -0
  43. package/dist/team/runtime.js +6 -0
  44. package/dist/team/subagent.d.ts +12 -0
  45. package/dist/team/subagent.js +4 -0
  46. package/dist/tools/index.d.ts +20 -0
  47. package/dist/tools/index.js +26 -0
  48. package/dist/ui/ActivityLine.d.ts +16 -0
  49. package/dist/ui/ActivityLine.js +23 -1
  50. package/dist/ui/App.d.ts +14 -1
  51. package/dist/ui/App.js +61 -7
  52. package/dist/ui/BottomStatusRow.d.ts +33 -1
  53. package/dist/ui/BottomStatusRow.js +20 -6
  54. package/dist/ui/Composer.d.ts +14 -1
  55. package/dist/ui/Composer.js +5 -5
  56. package/dist/ui/overlays/HelpOverlay.js +12 -1
  57. package/dist/ui/use-startup-notices.d.ts +98 -4
  58. package/dist/ui/use-startup-notices.js +108 -2
  59. package/dist/ui/win-vt-force.d.ts +136 -0
  60. package/dist/ui/win-vt-force.js +237 -0
  61. package/dist/ui/win-vt-input.d.ts +57 -0
  62. package/dist/ui/win-vt-input.js +62 -0
  63. package/package.json +7 -3
package/dist/ui/App.js CHANGED
@@ -14,11 +14,11 @@ import { computeCost } from '../agent/usage.js';
14
14
  import { parseMaxTokensInput, DEFAULT_TRANSCRIPT_RETAIN, DEFAULT_TRANSCRIPT_WINDOW, } from '../config/schema.js';
15
15
  import { updatePersistedConfig } from '../config/store.js';
16
16
  import { appendPrompt, loadPromptHistory } from '../config/prompt-history.js';
17
- import { bumpSubmitCount, setMouseNoticeSeen } from '../config/ui-state.js';
17
+ import { bumpSubmitCount, setMouseNoticeSeen, setVtInputNoticeVersion, } from '../config/ui-state.js';
18
18
  import { getLogger } from '../logging/logger.js';
19
19
  import { registerSecret } from '../logging/secret-registry.js';
20
20
  import { detectCapabilities } from './capabilities.js';
21
- import { useStartupNotices } from './use-startup-notices.js';
21
+ import { useStartupNotices, VT_INPUT_NOTICE_VERSION } from './use-startup-notices.js';
22
22
  import { getTheme } from './theme.js';
23
23
  import { pickGlyphs } from './glyphs.js';
24
24
  import { Header } from './Header.js';
@@ -32,7 +32,7 @@ import { secondsLeft } from '../agent/retry-view.js';
32
32
  import { TeamPanel } from './TeamPanel.js';
33
33
  import { TodoPanel } from './TodoPanel.js';
34
34
  import { TodoStrip } from './TodoStrip.js';
35
- import { ActivityLine } from './ActivityLine.js';
35
+ import { ActivityLine, liveSpinner } from './ActivityLine.js';
36
36
  import { BottomStatusRow } from './BottomStatusRow.js';
37
37
  import { UpdateLine } from './UpdateLine.js';
38
38
  // The updater's two PURE modules. `shouldRenderUpdateLine` decides the row's
@@ -67,7 +67,7 @@ import { PlanReviewOverlay } from './overlays/PlanReviewOverlay.js';
67
67
  import { CommandRegistry, runSlashInput } from '../commands/registry.js';
68
68
  import { registerBuiltinCommands } from '../commands/builtins.js';
69
69
  import { makeSkillsCommand, registerSkillCommands } from '../commands/skills.js';
70
- import { MODE_LABEL, nextMode } from '../agent/agent-mode.js';
70
+ import { MODE_LABEL, MODE_TOGGLE_KEYS, nextMode } from '../agent/agent-mode.js';
71
71
  /**
72
72
  * Decide what the `Max tokens` field means, WITHOUT letting a bad value in it
73
73
  * discard the rest of the form.
@@ -110,7 +110,7 @@ export function resolveSettingsMaxTokens(raw, previous) {
110
110
  function seedViewState(thinkingVisible) {
111
111
  return initialViewState({ thinkingVisible });
112
112
  }
113
- export function App({ controller, version, mode, initialOverlay, initialPrompt, confirmBridge, humanInputBridge, mouseSource, updateBridge, }) {
113
+ export function App({ controller, version, mode, initialOverlay, initialPrompt, confirmBridge, humanInputBridge, mouseSource, vtInputWarning, updateBridge, }) {
114
114
  const { exit } = useApp();
115
115
  const { stdout } = useStdout();
116
116
  const fullscreen = mode === 'fullscreen';
@@ -693,6 +693,8 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
693
693
  }, [fullscreen]);
694
694
  // `enabled` is `mouseSource`, not `cfg.mouse`: a user whose terminal drops
695
695
  // the events must never be shown advice about a mode not in effect (§6.1).
696
+ // `cli.tsx::wantMouse` now folds the platform check into the same expression,
697
+ // so on a console that reports nothing there is no source and no advice.
696
698
  useStartupNotices(dispatch, {
697
699
  enabled: !!mouseSource,
698
700
  // `state.json`, not `config.json`: whether a one-shot notice has been shown
@@ -703,6 +705,23 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
703
705
  // `transcriptRetain` to `transcriptWindow` (tui-render-performance §5.1).
704
706
  cfg.transcriptRetainRequested !== undefined
705
707
  ? { requested: cfg.transcriptRetainRequested, resolved: cfg.transcriptRetain }
708
+ : undefined,
709
+ // Its own one-shot key, NOT `mouseNoticeSeen`: everyone this notice is for
710
+ // has already run aragon at least once and already has that flag set. That
711
+ // key is now a VERSION for the same reason, one turn later — the audience
712
+ // for the text that finally names the fallback key is, again, exactly the
713
+ // set of users who already have the old flag on disk.
714
+ //
715
+ // `fullscreen` goes with it because only half of the damage is mode-
716
+ // independent: `Shift+Tab` is broken either way, but inline never asked for
717
+ // wheel reports and does not own the scroll keys, so it must not be told
718
+ // about either.
719
+ vtInputWarning
720
+ ? {
721
+ nodeVersion: vtInputWarning.nodeVersion,
722
+ fullscreen,
723
+ onSeen: () => setVtInputNoticeVersion(VT_INPUT_NOTICE_VERSION),
724
+ }
706
725
  : undefined);
707
726
  // --- Exit snapshot: the only way state reaches cli.tsx (§4.4). ----------
708
727
  useEffect(() => {
@@ -1180,7 +1199,13 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
1180
1199
  // AFTER the overlay block, and with an EXPLICIT guard rather than relying on
1181
1200
  // ordering: that block only `return`s for the keys it recognizes, and
1182
1201
  // everything else falls through (AC-P2).
1183
- if (key.tab && key.shift) {
1202
+ //
1203
+ // TWO KEYS, ONE BRANCH (shift-tab-mode-toggle-still-dead-on-windows, C2).
1204
+ // `Ctrl+P` is not a Windows-only alias bolted on beside the real binding: it
1205
+ // is the rung that survives when `Shift+Tab` cannot be delivered at all, and
1206
+ // it shares this branch precisely so the two can never drift into different
1207
+ // behaviour. See `MODE_TOGGLE_KEYS` for why this key and not another.
1208
+ if ((key.tab && key.shift) || (key.ctrl && (input === 'p' || input === 'P'))) {
1184
1209
  if (stateRef.current.overlay)
1185
1210
  return;
1186
1211
  applyMode(nextMode(controller.getAgentMode()));
@@ -1434,6 +1459,17 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
1434
1459
  // is suppressed, so keying suppression off `running` would leave the frame
1435
1460
  // with ZERO life signals: the bar shows state, this row shows life. Never
1436
1461
  // trade one duplicate for one absence.
1462
+ //
1463
+ // THE CLAIM ABOVE WAS ONCE FALSE, AND THAT WAS THE WHOLE OF
1464
+ // `activity-spinner-vanishes-behind-toast`. `BottomStatusRow` — not this const
1465
+ // — decides what the row actually shows, and a toast used to take the row
1466
+ // outright, so for its 2.5 s TTL this said "the line is up" while nothing was
1467
+ // mounted and all seven other sites sat still. The answer was NOT to enumerate
1468
+ // `&& !state.toasts.length` here (the next occupant reopens it, and the seven
1469
+ // sites animating out of phase is the defect this whole feature removed):
1470
+ // `BottomStatusRow` now carries the bare spinner alongside the toast, so the
1471
+ // signal is mounted exactly when this const is true and the claim holds by
1472
+ // construction.
1437
1473
  const activityVisible = running && !overlayNode;
1438
1474
  // The FOUR VIEW CONSUMERS (transcript x 2, team panel, todo panel) take this;
1439
1475
  // the activity line keeps the raw config flag (D-5), because it reads that
@@ -1469,7 +1505,17 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
1469
1505
  // rows; unbounded, it pushes the status bar past the frame height where
1470
1506
  // `overflow: hidden` clips it away (R-14 / M-14). Cap it against the viewport.
1471
1507
  const popupMaxRows = fullscreen ? Math.max(1, viewportBudget - 4) : undefined;
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' }) }) }));
1508
+ // WHICH KEY THE HINT ROW NAMES (shift-tab-mode-toggle-still-dead-on-windows,
1509
+ // C3-1). Derived from `vtInputWarning` rather than from a probe of its own,
1510
+ // because that prop is already the single answer to "can this console deliver
1511
+ // `CSI Z`?" — `cli.tsx` computes it AFTER trying to force the bit on, so a
1512
+ // machine we just repaired is told to press `shift+tab` and a machine we could
1513
+ // not repair is told to press the key that provably survives.
1514
+ //
1515
+ // Both keys always WORK (they share one branch in the input handler); this
1516
+ // only decides which one the user is taught.
1517
+ const modeToggleKey = vtInputWarning ? MODE_TOGGLE_KEYS.fallback : MODE_TOGGLE_KEYS.primary;
1518
+ 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, modeToggleKey: modeToggleKey, 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' }) }) }));
1473
1519
  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,
1474
1520
  // `!overlayNode` suppresses the line while an overlay owns the screen:
1475
1521
  // the full-screen branch replaces the whole viewport with the overlay,
@@ -1483,6 +1529,14 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
1483
1529
  // drift apart, and the drift is silent in whichever branch is not
1484
1530
  // edited.
1485
1531
  activity: activityVisible ? (_jsx(ActivityLine, { startedAt: runStartedAt.current, elapsedMs: elapsedMs, reducedMotion: reducedMotion, runningTool: runningTool, theme: theme, caps: caps })) : null,
1532
+ // THE FORM THE LIFE SIGNAL TAKES WHEN A TOAST HAS THE ROW
1533
+ // (`activity-spinner-vanishes-behind-toast`). Passed UNCONDITIONALLY:
1534
+ // `BottomStatusRow` reads it only when `activity` above is non-null,
1535
+ // so `activityVisible` stays the one mount condition and this cannot
1536
+ // become a second copy of it that drifts. `null` here means the
1537
+ // spinner would be static anyway (ASCII tier, or reduced motion), and
1538
+ // those builds must come out unchanged.
1539
+ activityGlyph: liveSpinner(reducedMotion, caps),
1486
1540
  // PRESENCE IS DECIDED HERE, NOT BY `UpdateLine` RETURNING `null`
1487
1541
  // (C-15 / cli-auto-update P0-1 / D-19). `BottomStatusRow` tests this
1488
1542
  // prop for truthiness, and a React element is truthy however it
@@ -33,6 +33,23 @@
33
33
  * end, whereas an activity line deferred is an activity line that never renders.
34
34
  * That also means a user who is actively working never sees the updater at all
35
35
  * until they stop, which is the whole of the silence requirement.
36
+ *
37
+ * THE TOAST TAKES THE ROW BUT NOT THE SPINNER
38
+ * (`activity-spinner-vanishes-behind-toast`). "Toast wins" used to mean the
39
+ * activity line was never mounted while an ack was up — and because `App`
40
+ * suppresses the other seven animated sites for exactly as long as it BELIEVES
41
+ * this row is showing one, a mid-run toast left the whole frame with ZERO
42
+ * animations for its 2.5 s TTL, indistinguishable from a hung process. Steering
43
+ * and plan approval both ack mid-run, so a long run hit it repeatedly.
44
+ *
45
+ * The row still belongs to the toast: the spinner joins it as a bare glyph, no
46
+ * phrase and no clock, so `bottom-status-row.test.tsx`'s "the ack keeps its
47
+ * words" assertion and the toast's own width are both untouched. A row-direction
48
+ * box holding two one-row children is STILL ONE ROW, which is what the note
49
+ * above actually forbids disturbing — `budget.ts` and `AppShell.tsx` stay out of
50
+ * it. And because the glyph is mounted under precisely the condition `App` uses
51
+ * to suppress the others, "the line is up" and "every other site is still" agree
52
+ * again by construction rather than by enumeration of what can pre-empt the row.
36
53
  */
37
54
  import React from 'react';
38
55
  import type { Theme } from './theme.js';
@@ -43,6 +60,21 @@ export interface BottomStatusRowProps {
43
60
  toasts: Toast[];
44
61
  /** `<ActivityLine>` while a run owns the screen, else `null`. */
45
62
  activity: React.ReactNode;
63
+ /**
64
+ * The bare animated spinner (`liveSpinner`), or `null` when it would be a
65
+ * STATIC glyph — an ASCII terminal, or `reducedMotion`.
66
+ *
67
+ * IT IS NOT A SECOND MOUNT CONDITION. `activity` alone decides whether a life
68
+ * signal belongs on this row; this prop only says which FORM the signal takes,
69
+ * and it is read only when `activity` is non-null. So a caller cannot make the
70
+ * two disagree, which is the failure mode that produced this prop.
71
+ *
72
+ * `null` means PREFIX NOTHING, not "prefix the still glyph". A static `·` in
73
+ * front of every toast would be a visible change to ASCII terminals and to
74
+ * users who asked for stillness — neither of whom ever had the dead-frame bug,
75
+ * and both of whom must come out byte-identical.
76
+ */
77
+ activityGlyph?: React.ReactElement | null;
46
78
  /**
47
79
  * `<UpdateLine>` when the updater has something to say, else `null` — AND THE
48
80
  * CALLER DECIDES WHICH (C-15 / cli-auto-update P0-1).
@@ -62,4 +94,4 @@ export interface BottomStatusRowProps {
62
94
  update?: React.ReactNode;
63
95
  theme: Theme;
64
96
  }
65
- export declare function BottomStatusRow({ mode, toasts, activity, update, theme, }: BottomStatusRowProps): React.ReactElement | null;
97
+ export declare function BottomStatusRow({ mode, toasts, activity, activityGlyph, update, theme, }: BottomStatusRowProps): React.ReactElement | null;
@@ -1,10 +1,24 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
2
- import { Box } from 'ink';
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
3
  import { ToastStack } from './ToastStack.js';
4
- export function BottomStatusRow({ mode, toasts, activity, update, theme, }) {
5
- // A toast wins the row outright.
6
- if (toasts.length > 0)
7
- return _jsx(ToastStack, { toasts: toasts, theme: theme, mode: mode });
4
+ export function BottomStatusRow({ mode, toasts, activity, activityGlyph, update, theme, }) {
5
+ // A toast wins the row outright — but a run in flight keeps its spinner on it.
6
+ if (toasts.length > 0) {
7
+ const spinner = activity ? activityGlyph : null;
8
+ // Byte-identical to the old branch whenever there is no live spinner to
9
+ // carry: no run, an ASCII terminal, or `reducedMotion`.
10
+ if (!spinner)
11
+ return _jsx(ToastStack, { toasts: toasts, theme: theme, mode: mode });
12
+ const fullscreen = mode === 'fullscreen';
13
+ return (
14
+ // `flexDirection` SPELLED OUT even though `row` is Yoga's default: it is
15
+ // what keeps two children on ONE row, and the header above turns on that
16
+ // being true. Left implicit, it reads as an oversight next to the child
17
+ // below that declares `column` — and "make them match" is a one-word edit
18
+ // that doubles the row, takes it from the transcript, and desynchronises
19
+ // every `viewportRows()` consumer without failing a type or a lint.
20
+ _jsxs(Box, { flexDirection: "row", flexShrink: 0, children: [_jsx(Box, { flexShrink: 0, marginTop: fullscreen ? 0 : 1, children: _jsxs(Text, { color: theme.thinking, children: [fullscreen ? ' ' : '', spinner, fullscreen ? '' : ' '] }) }), _jsx(Box, { flexDirection: "column", flexGrow: 1, flexShrink: 1, overflow: "hidden", children: _jsx(ToastStack, { toasts: toasts, theme: theme, mode: mode }) })] }));
21
+ }
8
22
  // Inline has no frame and therefore nothing to protect: `ToastStack`'s inline
9
23
  // branch is already a conditional 0-3 row stack, and these are simply one more
10
24
  // conditional row above it.
@@ -34,10 +34,23 @@ export interface ComposerProps {
34
34
  hintsEnabled: boolean;
35
35
  /** Mirror of the controller's effective mode; drives the chip and the border. */
36
36
  agentMode: AgentMode;
37
+ /**
38
+ * Which key this hint should NAME for the mode toggle
39
+ * (shift-tab-mode-toggle-still-dead-on-windows, C3-1).
40
+ *
41
+ * A PROP RATHER THAN A CONSTANT because on some consoles the constant is a
42
+ * lie. This row used to render `shift+tab` unconditionally, with no link to
43
+ * the capability check three files away, so the machines where that key is
44
+ * swallowed were the ones being told to press it - permanently, on every
45
+ * frame, while the one warning they got scrolled out of the transcript
46
+ * minutes earlier (analysis section 3.7). Defaults to the primary key, which
47
+ * is what every unaffected machine renders.
48
+ */
49
+ modeToggleKey?: string;
37
50
  popupMaxRows?: number;
38
51
  theme: Theme;
39
52
  caps: TermCapabilities;
40
53
  onSubmit: (text: string) => void;
41
54
  onHelp?: () => void;
42
55
  }
43
- export declare function Composer({ isActive, running, history, commands, cwd, showHint, submitCount, hintsEnabled, agentMode, popupMaxRows, theme, caps, onSubmit, onHelp, }: ComposerProps): React.ReactElement;
56
+ export declare function Composer({ isActive, running, history, commands, cwd, showHint, submitCount, hintsEnabled, agentMode, modeToggleKey, popupMaxRows, theme, caps, onSubmit, onHelp, }: ComposerProps): React.ReactElement;
@@ -21,7 +21,7 @@ import { pickGlyphs } from './glyphs.js';
21
21
  import { HINT_FADE_AFTER } from '../config/schema.js';
22
22
  import { PromptInput } from './PromptInput.js';
23
23
  import { ModeChip } from './ModeChip.js';
24
- import { MODE_LABEL, nextMode } from '../agent/agent-mode.js';
24
+ import { MODE_LABEL, MODE_TOGGLE_KEYS, nextMode } from '../agent/agent-mode.js';
25
25
  /**
26
26
  * Build the hint row.
27
27
  *
@@ -32,7 +32,7 @@ import { MODE_LABEL, nextMode } from '../agent/agent-mode.js';
32
32
  * Progressive disclosure is for teaching discoverable things, not for retiring
33
33
  * an emergency exit (§4.6 / A-14 / M-10b).
34
34
  */
35
- function hintText(running, submitCount, glyphs, mode) {
35
+ function hintText(running, submitCount, glyphs, mode, toggleKey) {
36
36
  const dot = ` ${glyphs.midDot} `;
37
37
  if (running) {
38
38
  return [`${glyphs.enterKey} steer`, 'esc abort', `ctrl+c${glyphs.times}2 exit`].join(dot);
@@ -40,7 +40,7 @@ function hintText(running, submitCount, glyphs, mode) {
40
40
  // The hint names the DESTINATION, not the current state — the correct label
41
41
  // for a toggle affordance, and the reason the mode word here is the OPPOSITE
42
42
  // of the one on the chip beside it.
43
- const toggle = `shift+tab ${MODE_LABEL[nextMode(mode)].toLowerCase()}`;
43
+ const toggle = `${toggleKey} ${MODE_LABEL[nextMode(mode)].toLowerCase()}`;
44
44
  // The faded form KEEPS the mode toggle. Progressive disclosure exists to
45
45
  // retire things the user has demonstrably learned, and `submitCount` counts
46
46
  // submissions — a user can send fifty messages without ever discovering
@@ -56,7 +56,7 @@ function hintText(running, submitCount, glyphs, mode) {
56
56
  '? help',
57
57
  ].join(dot);
58
58
  }
59
- export function Composer({ isActive, running, history, commands, cwd, showHint, submitCount, hintsEnabled, agentMode, popupMaxRows, theme, caps, onSubmit, onHelp, }) {
59
+ export function Composer({ isActive, running, history, commands, cwd, showHint, submitCount, hintsEnabled, agentMode, modeToggleKey = MODE_TOGGLE_KEYS.primary, popupMaxRows, theme, caps, onSubmit, onHelp, }) {
60
60
  const [hasDraft, setHasDraft] = useState(false);
61
61
  const onDraftChange = useCallback((next) => setHasDraft(next), []);
62
62
  // Plan mode inserts ONE branch, above `draft` and below `running`. The
@@ -76,5 +76,5 @@ export function Composer({ isActive, running, history, commands, cwd, showHint,
76
76
  return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [_jsx(PromptInput, { isActive: isActive, running: running, history: history, commands: commands, cwd: cwd, theme: theme, caps: caps, popupMaxRows: popupMaxRows, onSubmit: onSubmit, onHelp: onHelp, agentMode: agentMode, bordered: { color: borderColor }, onDraftChange: onDraftChange }), visible && (
77
77
  // The chip rides the EXISTING hint row rather than adding one, so it
78
78
  // costs zero rows on a 24-row terminal.
79
- _jsxs(Box, { flexDirection: "row", flexShrink: 0, children: [_jsx(Text, { children: ' ' }), _jsx(ModeChip, { mode: agentMode, theme: theme, caps: caps }), _jsx(Text, { wrap: "truncate", color: theme.hintFg ?? theme.muted, children: hintText(running, submitCount, glyphs, agentMode) })] }))] }));
79
+ _jsxs(Box, { flexDirection: "row", flexShrink: 0, children: [_jsx(Text, { children: ' ' }), _jsx(ModeChip, { mode: agentMode, theme: theme, caps: caps }), _jsx(Text, { wrap: "truncate", color: theme.hintFg ?? theme.muted, children: hintText(running, submitCount, glyphs, agentMode, modeToggleKey) })] }))] }));
80
80
  }
@@ -7,7 +7,13 @@ function keyRows(times) {
7
7
  ['Enter', 'Submit (idle) / queue steering (running); re-pins to the newest output'],
8
8
  ['Alt+Enter / Shift+Enter', 'Insert newline'],
9
9
  ['Esc', 'Abort run / close overlay'],
10
- ['Shift+Tab', 'Toggle plan mode (same as /plan)'],
10
+ // BOTH KEYS ARE LISTED HERE, and this row is why (shift-tab-mode-toggle-
11
+ // still-dead-on-windows, review R-7). The hint row above the composer is the
12
+ // other place the fallback is named, and it has two off-switches - a short
13
+ // terminal (`showHint`) and `hints: false`. A key that only exists behind
14
+ // either of them is a key the affected user never learns about, which is the
15
+ // one-shot notice's failure repeated with a different switch.
16
+ ['Shift+Tab / Ctrl+P', 'Toggle plan mode (same as /plan)'],
11
17
  [`Ctrl+C ${times}2`, 'Exit'],
12
18
  ['Ctrl+L', 'Redraw the frame'],
13
19
  ['Ctrl+T', 'Show/hide thinking (off by default)'],
@@ -54,6 +60,11 @@ const SKILL_ROWS = [
54
60
  ];
55
61
  const PLAN_ROWS = [
56
62
  ['Shift+Tab / /plan', 'Switch between BUILD (do it now) and PLAN (research first)'],
63
+ // A ROW OF ITS OWN, not a third name on the line above: the reader who needs
64
+ // this one is the reader whose `Shift+Tab` does nothing, and what they need is
65
+ // the reason, not another synonym. Listing the key in both places instead just
66
+ // says it twice and explains it nowhere.
67
+ ['Ctrl+P', 'The same toggle, for Windows consoles that deliver Shift+Tab as a plain Tab'],
57
68
  ['In PLAN mode', 'write_file, edit_file, bash, skill_install and skill_create are refused'],
58
69
  ['bash', 'Refused in full, including git status - use read_file / glob / grep'],
59
70
  ['ask_user', 'The agent asks 1-5 multiple-choice questions; Enter takes the recommendation'],
@@ -12,6 +12,69 @@
12
12
  * not otherwise see — rather than belonging to the view.
13
13
  */
14
14
  import type { ViewAction } from '../agent/reducer.js';
15
+ /**
16
+ * The revision of the text below that a user must have seen for this notice to
17
+ * stay quiet (`state.json::vtInputNoticeVersion`, `0` when absent).
18
+ *
19
+ * BUMP IT WHENEVER THE ADVICE CHANGES, and never for a rewording that leaves
20
+ * the recommended actions alone. The two failure modes sit on either side of
21
+ * this constant: leaving it alone after changing what the user is told to press
22
+ * makes the change inert for everyone who already ran the old build — which is
23
+ * the entire affected population, since the notice only appears on machines
24
+ * where the key has been broken all along — while dropping the gate altogether
25
+ * turns a one-shot into a banner on every launch.
26
+ *
27
+ * `1` = the first text naming `MODE_TOGGLE_KEYS.fallback`. `0` = the 0.6.1 text
28
+ * that offered only `/plan`, recorded back when this was a boolean.
29
+ */
30
+ export declare const VT_INPUT_NOTICE_VERSION = 1;
31
+ /**
32
+ * The `shift-tab-and-mouse-wheel-dead-on-some-terminals` notice (F1'). Shown at
33
+ * most once per `VT_INPUT_NOTICE_VERSION`, and only on a Windows console that
34
+ * cannot deliver either input — see `ui/win-vt-input.ts` for why that is a
35
+ * property of the Node version rather than of the terminal.
36
+ *
37
+ * IT NAMES WHAT THE KEY DOES NOW, not merely that it is unavailable, and that
38
+ * is the load-bearing half of the text. `Shift+Tab` on these machines is not
39
+ * inert: the modifier is lost inside libuv, so Ink sees an ordinary `Tab` and
40
+ * the `/` palette or an `@` popup completes its selection over the user's
41
+ * draft. A user told only "this key does not work" keeps pressing it and keeps
42
+ * losing text without ever connecting the two.
43
+ *
44
+ * THE FALLBACK KEY COMES BEFORE `/plan` IN BOTH BRANCHES, and it is named from
45
+ * `MODE_TOGGLE_KEYS` rather than spelled out, so the four consumers of that
46
+ * constant cannot drift apart. This notice is the last one of them to be wired
47
+ * up, and it is also the only channel that reaches the affected user without
48
+ * being asked to: the composer hint does not exist at all in inline mode and is
49
+ * switched off by a short terminal or `hints: false` in full-screen, and
50
+ * `/help` has to be typed by someone whose current belief is "the key does
51
+ * nothing" rather than "I should read the help". Ordering follows cost — one
52
+ * keystroke before four and a return.
53
+ *
54
+ * `Shift+Up` / `Shift+Down` is the scroll workaround named here because it is
55
+ * the only one PROVEN to survive: both Node versions emit `\x1b[1;2A` for it,
56
+ * byte for byte, in the probe data. `PgUp` / `PgDn` is expected to work too but
57
+ * was never injected, so it is left out rather than promised.
58
+ *
59
+ * THE WHEEL HALF IS FULL-SCREEN ONLY, and dropping it inline is not a detail.
60
+ * `Shift+Tab` is broken in both modes — the modifier dies inside libuv, well
61
+ * below anything this app chooses — but the wheel is only ours to lose in
62
+ * full-screen: inline never asks the console to report it (`mode` is a conjunct
63
+ * of `cli.tsx::wantMouse`), so the wheel goes on scrolling the terminal's own
64
+ * scrollback exactly as it always has. And `Shift+Up` / `Shift+Down` are handled
65
+ * inside `App`'s `if (fullscreen)` branch, so recommending them inline names a
66
+ * key that does nothing. Telling an inline user their working wheel is dead and
67
+ * then handing them an inert workaround is the same failure this notice exists
68
+ * to end — advice about a mode that is not running — merely pointed the other
69
+ * way.
70
+ *
71
+ * EXPORTED FOR ITS TEST, unlike the two notices above it. Asserting this text
72
+ * through a rendered frame does not work: the transcript wraps a notice at two
73
+ * columns wider than it displays it, so any phrase landing on a wrap boundary
74
+ * loses a character or two on the way to `lastFrame()` and the assertion fails
75
+ * for a reason that has nothing to do with the text.
76
+ */
77
+ export declare function vtInputDeadNotice(nodeVersion: string, fullscreen: boolean): string;
15
78
  /**
16
79
  * `{ requested, resolved }` when the raise happened, `undefined` otherwise —
17
80
  * i.e. `cfg.transcriptRetainRequested` and `cfg.transcriptRetain`.
@@ -22,12 +85,43 @@ export interface RetainNoticeOptions {
22
85
  }
23
86
  export interface MouseNoticeOptions {
24
87
  /**
25
- * Whether reporting is ACTUALLY in effect — i.e. a filter was installed, not
26
- * merely that the config asked for one. A user on a terminal where R-1 fires
27
- * must never be given advice about a mode that is not running.
88
+ * Whether reporting is ACTUALLY in effect: a filter was installed AND the
89
+ * platform can deliver the reports it would filter. A user on a terminal
90
+ * where R-1 fires must never be given advice about a mode that is not
91
+ * running.
92
+ *
93
+ * THE TWO HALVES ARE NOT THE SAME CLAIM, and this comment used to define the
94
+ * flag as only the first of them ("i.e. a filter was installed") — which is
95
+ * how the second sentence came to be violated by an implementation that
96
+ * satisfied the first. Constructing the filter proves nothing about whether
97
+ * the terminal will ever send a report; on a Windows console without VT
98
+ * input it succeeds and then sits in front of a stream no report can reach.
99
+ * The caller now derives both from one expression (`cli.tsx::wantMouse`), so
100
+ * re-deriving either one here would put them back out of step.
28
101
  */
29
102
  enabled: boolean;
30
103
  /** Record that it was shown (`state.json`). Called once, after dispatch. */
31
104
  onSeen: () => void;
32
105
  }
33
- export declare function useStartupNotices(dispatch: (action: ViewAction) => void, mouseNotice?: MouseNoticeOptions, retainNotice?: RetainNoticeOptions): void;
106
+ export interface VtInputNoticeOptions {
107
+ /** `process.versions.node`, quoted back so the user can act on it. */
108
+ nodeVersion: string;
109
+ /**
110
+ * Whether this run owns the alternate screen, i.e. `App`'s own `fullscreen`.
111
+ *
112
+ * Selects which half of the text applies: inline keeps the wheel and loses the
113
+ * full-screen scroll keys, so it is told about `Shift+Tab` and nothing else
114
+ * (see `vtInputDeadNotice`). Taken from the caller rather than re-derived here
115
+ * for the same reason the version is — one source, no chance of the notice and
116
+ * the renderer disagreeing about which mode is running.
117
+ */
118
+ fullscreen: boolean;
119
+ /**
120
+ * Record that `VT_INPUT_NOTICE_VERSION` was shown (`state.json`). Called once,
121
+ * after dispatch — and it must write that constant, not `true`: the gate
122
+ * below compares against it, so a caller storing anything else either replays
123
+ * the notice forever or silences a future revision of the text.
124
+ */
125
+ onSeen: () => void;
126
+ }
127
+ export declare function useStartupNotices(dispatch: (action: ViewAction) => void, mouseNotice?: MouseNoticeOptions, retainNotice?: RetainNoticeOptions, vtInputNotice?: VtInputNoticeOptions): void;
@@ -12,8 +12,9 @@
12
12
  * not otherwise see — rather than belonging to the view.
13
13
  */
14
14
  import { useEffect, useRef } from 'react';
15
+ import { MODE_TOGGLE_KEYS } from '../agent/agent-mode.js';
15
16
  import { readConfigFile } from '../config/store.js';
16
- import { getMouseNoticeSeen } from '../config/ui-state.js';
17
+ import { getMouseNoticeSeen, getVtInputNoticeVersion } from '../config/ui-state.js';
17
18
  import { getLogger } from '../logging/logger.js';
18
19
  /**
19
20
  * The §6.1 notice (mouse-wheel-region-routing). Shown at most once, ever.
@@ -39,7 +40,82 @@ function retainRaisedNotice(requested, resolved) {
39
40
  'so the whole scroll horizon stays reachable. Lower transcriptWindow too if you meant ' +
40
41
  'to cap memory.');
41
42
  }
42
- export function useStartupNotices(dispatch, mouseNotice, retainNotice) {
43
+ /**
44
+ * The revision of the text below that a user must have seen for this notice to
45
+ * stay quiet (`state.json::vtInputNoticeVersion`, `0` when absent).
46
+ *
47
+ * BUMP IT WHENEVER THE ADVICE CHANGES, and never for a rewording that leaves
48
+ * the recommended actions alone. The two failure modes sit on either side of
49
+ * this constant: leaving it alone after changing what the user is told to press
50
+ * makes the change inert for everyone who already ran the old build — which is
51
+ * the entire affected population, since the notice only appears on machines
52
+ * where the key has been broken all along — while dropping the gate altogether
53
+ * turns a one-shot into a banner on every launch.
54
+ *
55
+ * `1` = the first text naming `MODE_TOGGLE_KEYS.fallback`. `0` = the 0.6.1 text
56
+ * that offered only `/plan`, recorded back when this was a boolean.
57
+ */
58
+ export const VT_INPUT_NOTICE_VERSION = 1;
59
+ /**
60
+ * The `shift-tab-and-mouse-wheel-dead-on-some-terminals` notice (F1'). Shown at
61
+ * most once per `VT_INPUT_NOTICE_VERSION`, and only on a Windows console that
62
+ * cannot deliver either input — see `ui/win-vt-input.ts` for why that is a
63
+ * property of the Node version rather than of the terminal.
64
+ *
65
+ * IT NAMES WHAT THE KEY DOES NOW, not merely that it is unavailable, and that
66
+ * is the load-bearing half of the text. `Shift+Tab` on these machines is not
67
+ * inert: the modifier is lost inside libuv, so Ink sees an ordinary `Tab` and
68
+ * the `/` palette or an `@` popup completes its selection over the user's
69
+ * draft. A user told only "this key does not work" keeps pressing it and keeps
70
+ * losing text without ever connecting the two.
71
+ *
72
+ * THE FALLBACK KEY COMES BEFORE `/plan` IN BOTH BRANCHES, and it is named from
73
+ * `MODE_TOGGLE_KEYS` rather than spelled out, so the four consumers of that
74
+ * constant cannot drift apart. This notice is the last one of them to be wired
75
+ * up, and it is also the only channel that reaches the affected user without
76
+ * being asked to: the composer hint does not exist at all in inline mode and is
77
+ * switched off by a short terminal or `hints: false` in full-screen, and
78
+ * `/help` has to be typed by someone whose current belief is "the key does
79
+ * nothing" rather than "I should read the help". Ordering follows cost — one
80
+ * keystroke before four and a return.
81
+ *
82
+ * `Shift+Up` / `Shift+Down` is the scroll workaround named here because it is
83
+ * the only one PROVEN to survive: both Node versions emit `\x1b[1;2A` for it,
84
+ * byte for byte, in the probe data. `PgUp` / `PgDn` is expected to work too but
85
+ * was never injected, so it is left out rather than promised.
86
+ *
87
+ * THE WHEEL HALF IS FULL-SCREEN ONLY, and dropping it inline is not a detail.
88
+ * `Shift+Tab` is broken in both modes — the modifier dies inside libuv, well
89
+ * below anything this app chooses — but the wheel is only ours to lose in
90
+ * full-screen: inline never asks the console to report it (`mode` is a conjunct
91
+ * of `cli.tsx::wantMouse`), so the wheel goes on scrolling the terminal's own
92
+ * scrollback exactly as it always has. And `Shift+Up` / `Shift+Down` are handled
93
+ * inside `App`'s `if (fullscreen)` branch, so recommending them inline names a
94
+ * key that does nothing. Telling an inline user their working wheel is dead and
95
+ * then handing them an inert workaround is the same failure this notice exists
96
+ * to end — advice about a mode that is not running — merely pointed the other
97
+ * way.
98
+ *
99
+ * EXPORTED FOR ITS TEST, unlike the two notices above it. Asserting this text
100
+ * through a rendered frame does not work: the transcript wraps a notice at two
101
+ * columns wider than it displays it, so any phrase landing on a wrap boundary
102
+ * loses a character or two on the way to `lastFrame()` and the assertion fails
103
+ * for a reason that has nothing to do with the text.
104
+ */
105
+ export function vtInputDeadNotice(nodeVersion, fullscreen) {
106
+ const cause = `Node ${nodeVersion} on Windows does not turn on the console's VT input mode, so `;
107
+ const cost = 'it will not switch mode, and with the / palette or an @ popup open it completes that ' +
108
+ 'instead, overwriting your draft. ';
109
+ const toggle = `Press ${MODE_TOGGLE_KEYS.fallback} to switch mode instead (or /plan)`;
110
+ if (!fullscreen) {
111
+ return (`${cause}Shift+Tab never arrives: it comes through as a PLAIN TAB - ${cost}` +
112
+ `${toggle}. Node 22.17.0+ (or 24.2.0+) restores it.`);
113
+ }
114
+ return (`${cause}two keys never arrive: the wheel sends nothing at all, and Shift+Tab arrives ` +
115
+ `as a PLAIN TAB - ${cost}${toggle}, and Shift+Up / Shift+Down to scroll. ` +
116
+ 'Node 22.17.0+ (or 24.2.0+) restores both.');
117
+ }
118
+ export function useStartupNotices(dispatch, mouseNotice, retainNotice, vtInputNotice) {
43
119
  // The sink gives up after repeated write failures (a read-only log directory,
44
120
  // a full disk). Silently logging nothing is exactly the state a user must not
45
121
  // discover later, from an empty file, while trying to explain a bug.
@@ -76,6 +152,36 @@ export function useStartupNotices(dispatch, mouseNotice, retainNotice) {
76
152
  dispatch({ type: 'notice', level: 'info', text: MOUSE_SELECTION_NOTICE });
77
153
  noticeRef.current?.onSeen();
78
154
  }, [mouseEnabled, dispatch]);
155
+ // The R-1 notice. `warn`, not `info`: unlike the one above it reports a
156
+ // capability the user was promised and does not have, plus a key that is
157
+ // actively editing their draft.
158
+ //
159
+ // DEPENDS ON THE SCALAR, never on the options object, for the reason spelled
160
+ // out on the retain effect below — `App` builds that literal during render.
161
+ // Its one-shot key is checked only AFTER the presence test, so a machine that
162
+ // is not affected never touches `state.json` at all.
163
+ //
164
+ // `>=`, not `!==`: a state file written by a NEWER build (a downgrade, or a
165
+ // shared home directory) has already told this user everything the text below
166
+ // says, so re-showing it would be noise rather than news.
167
+ const vtNoticeRef = useRef(vtInputNotice);
168
+ vtNoticeRef.current = vtInputNotice;
169
+ const vtNodeVersion = vtInputNotice?.nodeVersion;
170
+ const vtFullscreen = vtInputNotice?.fullscreen ?? false;
171
+ const vtNoticeShown = useRef(false);
172
+ useEffect(() => {
173
+ if (vtNodeVersion === undefined || vtNoticeShown.current)
174
+ return;
175
+ if (getVtInputNoticeVersion() >= VT_INPUT_NOTICE_VERSION)
176
+ return;
177
+ vtNoticeShown.current = true;
178
+ dispatch({
179
+ type: 'notice',
180
+ level: 'warn',
181
+ text: vtInputDeadNotice(vtNodeVersion, vtFullscreen),
182
+ });
183
+ vtNoticeRef.current?.onSeen();
184
+ }, [vtNodeVersion, vtFullscreen, dispatch]);
79
185
  // A `warn` notice rather than a toast: it is about a value the user typed, so
80
186
  // it belongs in the transcript where it can be scrolled back to, not in a row
81
187
  // that expires. DEPENDS ON THE TWO SCALARS, never on the options object —