@aragon-agent/cli 0.5.0 → 0.5.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 (102) hide show
  1. package/CHANGELOG.md +158 -0
  2. package/README.md +254 -7
  3. package/dist/agent/agent-mode.d.ts +53 -0
  4. package/dist/agent/agent-mode.js +69 -0
  5. package/dist/agent/controller.d.ts +69 -0
  6. package/dist/agent/controller.js +154 -1
  7. package/dist/agent/plan-prompt.d.ts +18 -0
  8. package/dist/agent/plan-prompt.js +93 -0
  9. package/dist/agent/reducer.d.ts +14 -1
  10. package/dist/agent/reducer.js +9 -0
  11. package/dist/agent/system-prompt.d.ts +15 -0
  12. package/dist/agent/system-prompt.js +8 -0
  13. package/dist/cli.d.ts +1 -1
  14. package/dist/cli.js +247 -23
  15. package/dist/commands/builtins.js +60 -0
  16. package/dist/commands/registry.d.ts +17 -0
  17. package/dist/config/app-paths.d.ts +71 -4
  18. package/dist/config/app-paths.js +139 -8
  19. package/dist/config/cli-commands.d.ts +43 -0
  20. package/dist/config/cli-commands.js +196 -0
  21. package/dist/config/env.d.ts +7 -1
  22. package/dist/config/env.js +53 -1
  23. package/dist/config/load.d.ts +10 -0
  24. package/dist/config/load.js +82 -2
  25. package/dist/config/migrate-home.d.ts +63 -0
  26. package/dist/config/migrate-home.js +165 -0
  27. package/dist/config/migrate-legacy-state.d.ts +10 -0
  28. package/dist/config/migrate-legacy-state.js +15 -6
  29. package/dist/config/schema.d.ts +81 -0
  30. package/dist/config/schema.js +85 -0
  31. package/dist/config/store.d.ts +30 -12
  32. package/dist/config/store.js +47 -35
  33. package/dist/input/mouse-events.d.ts +61 -0
  34. package/dist/input/mouse-events.js +153 -0
  35. package/dist/input/stdin-mouse-filter.d.ts +42 -0
  36. package/dist/input/stdin-mouse-filter.js +153 -0
  37. package/dist/logging/cli-commands.d.ts +25 -0
  38. package/dist/logging/cli-commands.js +221 -0
  39. package/dist/logging/file-sink.d.ts +114 -0
  40. package/dist/logging/file-sink.js +326 -0
  41. package/dist/logging/install.d.ts +163 -0
  42. package/dist/logging/install.js +302 -0
  43. package/dist/logging/levels.d.ts +40 -0
  44. package/dist/logging/levels.js +59 -0
  45. package/dist/logging/logger.d.ts +110 -0
  46. package/dist/logging/logger.js +237 -0
  47. package/dist/logging/redact.d.ts +28 -0
  48. package/dist/logging/redact.js +90 -0
  49. package/dist/logging/secret-registry.d.ts +30 -0
  50. package/dist/logging/secret-registry.js +55 -0
  51. package/dist/skills/paths.d.ts +2 -4
  52. package/dist/skills/paths.js +7 -9
  53. package/dist/tools/human-input.d.ts +179 -0
  54. package/dist/tools/human-input.js +227 -0
  55. package/dist/tools/index.d.ts +35 -3
  56. package/dist/tools/index.js +96 -9
  57. package/dist/tools/plan-tools.d.ts +64 -0
  58. package/dist/tools/plan-tools.js +198 -0
  59. package/dist/ui/App.d.ts +12 -1
  60. package/dist/ui/App.js +223 -17
  61. package/dist/ui/Composer.d.ts +9 -1
  62. package/dist/ui/Composer.js +26 -7
  63. package/dist/ui/ModeChip.d.ts +29 -0
  64. package/dist/ui/ModeChip.js +15 -0
  65. package/dist/ui/PromptInput.d.ts +22 -1
  66. package/dist/ui/PromptInput.js +49 -2
  67. package/dist/ui/StatusBar.d.ts +15 -0
  68. package/dist/ui/StatusBar.js +10 -2
  69. package/dist/ui/entries/ToolPreview.d.ts +2 -0
  70. package/dist/ui/entries/ToolPreview.js +64 -0
  71. package/dist/ui/glyphs.d.ts +21 -0
  72. package/dist/ui/glyphs.js +12 -0
  73. package/dist/ui/layout/AppShell.d.ts +10 -1
  74. package/dist/ui/layout/AppShell.js +38 -3
  75. package/dist/ui/layout/ScrollIndicator.d.ts +31 -0
  76. package/dist/ui/layout/ScrollIndicator.js +15 -0
  77. package/dist/ui/layout/ScrollViewport.d.ts +8 -2
  78. package/dist/ui/layout/ScrollViewport.js +24 -4
  79. package/dist/ui/layout/regions.d.ts +55 -0
  80. package/dist/ui/layout/regions.js +52 -0
  81. package/dist/ui/layout/scroll-indicator.d.ts +27 -0
  82. package/dist/ui/layout/scroll-indicator.js +35 -0
  83. package/dist/ui/layout/scroll.d.ts +9 -0
  84. package/dist/ui/layout/scroll.js +23 -0
  85. package/dist/ui/layout/wrap-rows.d.ts +24 -0
  86. package/dist/ui/layout/wrap-rows.js +74 -0
  87. package/dist/ui/overlays/HelpOverlay.js +15 -0
  88. package/dist/ui/overlays/PlanReviewOverlay.d.ts +50 -0
  89. package/dist/ui/overlays/PlanReviewOverlay.js +117 -0
  90. package/dist/ui/overlays/QuestionOverlay.d.ts +30 -0
  91. package/dist/ui/overlays/QuestionOverlay.js +193 -0
  92. package/dist/ui/overlays/SettingsScreen.d.ts +7 -0
  93. package/dist/ui/overlays/SettingsScreen.js +8 -1
  94. package/dist/ui/screen.d.ts +14 -1
  95. package/dist/ui/screen.js +37 -3
  96. package/dist/ui/use-startup-notices.d.ts +25 -0
  97. package/dist/ui/use-startup-notices.js +64 -0
  98. package/dist/ui/use-wheel-routing.d.ts +68 -0
  99. package/dist/ui/use-wheel-routing.js +139 -0
  100. package/package.json +3 -2
  101. package/runtime/insecure-tls-warning.cjs +62 -0
  102. package/runtime/insecure-tls-warning.d.cts +5 -0
@@ -18,6 +18,7 @@ import React from 'react';
18
18
  import type { Theme } from './theme.js';
19
19
  import type { TermCapabilities } from './capabilities.js';
20
20
  import { type CommandOption } from './PromptInput.js';
21
+ import { type AgentMode } from '../agent/agent-mode.js';
21
22
  export interface ComposerProps {
22
23
  /** False while an overlay owns the keyboard (the input is visually blurred). */
23
24
  isActive: boolean;
@@ -31,10 +32,17 @@ export interface ComposerProps {
31
32
  submitCount: number;
32
33
  /** User opt-out (`--no-hints` / `hints: false`). */
33
34
  hintsEnabled: boolean;
35
+ /** Mirror of the controller's effective mode; drives the chip and the border. */
36
+ agentMode: AgentMode;
34
37
  popupMaxRows?: number;
38
+ /** Pure pass-through to `PromptInput` (mouse-wheel-region-routing §5.6). */
39
+ historyIntent?: {
40
+ dir: 'up' | 'down';
41
+ nonce: number;
42
+ };
35
43
  theme: Theme;
36
44
  caps: TermCapabilities;
37
45
  onSubmit: (text: string) => void;
38
46
  onHelp?: () => void;
39
47
  }
40
- export declare function Composer({ isActive, running, history, commands, cwd, showHint, submitCount, hintsEnabled, popupMaxRows, theme, caps, onSubmit, onHelp, }: ComposerProps): React.ReactElement;
48
+ export declare function Composer({ isActive, running, history, commands, cwd, showHint, submitCount, hintsEnabled, agentMode, popupMaxRows, historyIntent, theme, caps, onSubmit, onHelp, }: ComposerProps): React.ReactElement;
@@ -20,6 +20,8 @@ import { Box, Text } from 'ink';
20
20
  import { pickGlyphs } from './glyphs.js';
21
21
  import { HINT_FADE_AFTER } from '../config/schema.js';
22
22
  import { PromptInput } from './PromptInput.js';
23
+ import { ModeChip } from './ModeChip.js';
24
+ import { MODE_LABEL, nextMode } from '../agent/agent-mode.js';
23
25
  /**
24
26
  * Build the hint row.
25
27
  *
@@ -30,32 +32,49 @@ import { PromptInput } from './PromptInput.js';
30
32
  * Progressive disclosure is for teaching discoverable things, not for retiring
31
33
  * an emergency exit (§4.6 / A-14 / M-10b).
32
34
  */
33
- function hintText(running, submitCount, glyphs) {
35
+ function hintText(running, submitCount, glyphs, mode) {
34
36
  const dot = ` ${glyphs.midDot} `;
35
37
  if (running) {
36
38
  return [`${glyphs.enterKey} steer`, 'esc abort', `ctrl+c${glyphs.times}2 exit`].join(dot);
37
39
  }
40
+ // The hint names the DESTINATION, not the current state — the correct label
41
+ // for a toggle affordance, and the reason the mode word here is the OPPOSITE
42
+ // of the one on the chip beside it.
43
+ const toggle = `shift+tab ${MODE_LABEL[nextMode(mode)].toLowerCase()}`;
44
+ // The faded form KEEPS the mode toggle. Progressive disclosure exists to
45
+ // retire things the user has demonstrably learned, and `submitCount` counts
46
+ // submissions — a user can send fifty messages without ever discovering
47
+ // Shift+Tab. Same argument that keeps `esc abort` un-faded above.
38
48
  if (submitCount >= HINT_FADE_AFTER)
39
- return '? help';
49
+ return [toggle, '? help'].join(dot);
40
50
  return [
41
51
  `${glyphs.enterKey} send`,
42
52
  `${glyphs.shiftEnter} newline`,
43
53
  '/ commands',
44
54
  '@ files',
55
+ toggle,
45
56
  '? help',
46
57
  ].join(dot);
47
58
  }
48
- export function Composer({ isActive, running, history, commands, cwd, showHint, submitCount, hintsEnabled, popupMaxRows, theme, caps, onSubmit, onHelp, }) {
59
+ export function Composer({ isActive, running, history, commands, cwd, showHint, submitCount, hintsEnabled, agentMode, popupMaxRows, historyIntent, theme, caps, onSubmit, onHelp, }) {
49
60
  const [hasDraft, setHasDraft] = useState(false);
50
61
  const onDraftChange = useCallback((next) => setHasDraft(next), []);
62
+ // Plan mode inserts ONE branch, above `draft` and below `running`. The
63
+ // ordering is load-bearing: an in-flight run's border must stay `toolRunning`,
64
+ // because "is it running" is more urgent than "which mode".
51
65
  const borderColor = !isActive
52
66
  ? theme.toolPending
53
67
  : running
54
68
  ? theme.toolRunning
55
- : hasDraft
56
- ? theme.focusBorder ?? theme.primary
57
- : theme.idleBorder ?? theme.border;
69
+ : agentMode === 'plan'
70
+ ? theme.accent
71
+ : hasDraft
72
+ ? theme.focusBorder ?? theme.primary
73
+ : theme.idleBorder ?? theme.border;
58
74
  const glyphs = pickGlyphs(caps);
59
75
  const visible = showHint && hintsEnabled;
60
- 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, bordered: { color: borderColor }, onDraftChange: onDraftChange }), visible && (_jsxs(Text, { wrap: "truncate", color: theme.hintFg ?? theme.muted, children: [' ', hintText(running, submitCount, glyphs)] }))] }));
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, historyIntent: historyIntent }), visible && (
77
+ // The chip rides the EXISTING hint row rather than adding one, so it
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) })] }))] }));
61
80
  }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * ModeChip — the `PLAN` badge on the composer's hint row (plan-mode §6.1).
3
+ *
4
+ * `theme.chip = { fg, bg }` exists in every palette and was, until now, unused.
5
+ * A mode badge is what it was for.
6
+ *
7
+ * SHOWN ONLY IN PLAN MODE. `BUILD` is the default, and a badge that is always on
8
+ * is furniture rather than signal; it also means the Build-mode composer renders
9
+ * exactly as it did before this feature existed.
10
+ *
11
+ * THE CHIP IS AN ENHANCEMENT, NOT THE GUARANTEE (P1-6). It rides the hint row,
12
+ * which disappears on a short terminal (`showHint`), under `hints: false`, and
13
+ * in inline render mode (which mounts a bare `PromptInput` and never mounts
14
+ * `Composer` at all). The GUARANTEED carrier is the status-bar word, which
15
+ * renders in both `AppShell` branches and has no opt-out. If you ever move the
16
+ * mode indicator, that is the constraint to preserve: the status bar must always
17
+ * name a non-default mode; the chip may.
18
+ */
19
+ import React from 'react';
20
+ import type { Theme } from './theme.js';
21
+ import type { TermCapabilities } from './capabilities.js';
22
+ import { type AgentMode } from '../agent/agent-mode.js';
23
+ interface ModeChipProps {
24
+ mode: AgentMode;
25
+ theme: Theme;
26
+ caps: TermCapabilities;
27
+ }
28
+ export declare function ModeChip({ mode, theme, caps }: ModeChipProps): React.ReactElement | null;
29
+ export {};
@@ -0,0 +1,15 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { MODE_LABEL } from '../agent/agent-mode.js';
4
+ export function ModeChip({ mode, theme, caps }) {
5
+ if (mode !== 'plan')
6
+ return null;
7
+ const label = MODE_LABEL[mode];
8
+ // `Box backgroundColor` degrades to plain text at colorLevel 0, where the chip
9
+ // would become invisible against the hint row. Brackets carry the same
10
+ // "this is a badge" reading with no color at all.
11
+ if (caps.colorLevel === 0) {
12
+ return (_jsxs(Text, { wrap: "truncate", bold: true, children: ["[", label, "]", ' '] }));
13
+ }
14
+ return (_jsx(Box, { flexShrink: 0, marginRight: 1, children: _jsxs(Text, { backgroundColor: theme.chip.bg, color: theme.chip.fg, bold: true, children: [' ', label, ' '] }) }));
15
+ }
@@ -13,6 +13,7 @@ import type { Theme } from './theme.js';
13
13
  import type { TermCapabilities } from './capabilities.js';
14
14
  import { type EditIntent } from '../input/keymap.js';
15
15
  import { type Suggestion } from './AutocompletePopup.js';
16
+ import type { AgentMode } from '../agent/agent-mode.js';
16
17
  export interface CommandOption {
17
18
  name: string;
18
19
  description: string;
@@ -37,6 +38,12 @@ interface PromptInputProps {
37
38
  popupMaxRows?: number;
38
39
  onSubmit: (text: string) => void;
39
40
  onHelp?: () => void;
41
+ /**
42
+ * Mirror of the session mode. Read for the placeholder only — the toggle
43
+ * itself belongs to `App`, and this component's job is to get out of its way
44
+ * (see the first statement of the input handler).
45
+ */
46
+ agentMode?: AgentMode;
40
47
  /**
41
48
  * Set by `Composer` in full-screen mode: draw the rounded frame around the
42
49
  * input row only, so the autocomplete popup lands OUTSIDE it (spec §4.7)
@@ -48,6 +55,20 @@ interface PromptInputProps {
48
55
  };
49
56
  /** Fires only when the draft flips empty ⇄ non-empty, for the border color. */
50
57
  onDraftChange?: (hasDraft: boolean) => void;
58
+ /**
59
+ * One prompt-history step, pushed by the wheel router when a notch lands on
60
+ * the composer band (mouse-wheel-region-routing §5.6). `nonce` is monotonic
61
+ * so two notches in the same direction are two steps.
62
+ *
63
+ * Applied through the SAME `verticalOrHistory` / popup-selection branches the
64
+ * keyboard uses, deliberately: it means the wheel and `↑`/`↓` cannot drift
65
+ * apart, and the "never clobber an in-progress draft" guard protects the
66
+ * wheel for free.
67
+ */
68
+ historyIntent?: {
69
+ dir: 'up' | 'down';
70
+ nonce: number;
71
+ };
51
72
  }
52
73
  /** Apply an edit intent as a pure buffer/cursor transform. */
53
74
  export declare function applyEdit(buffer: string, cursor: number, intent: EditIntent): {
@@ -77,5 +98,5 @@ export declare function fileTokenAt(buffer: string, cursor: number): {
77
98
  end: number;
78
99
  query: string;
79
100
  } | null;
80
- export declare function PromptInput({ isActive, running, history, commands, cwd, theme, caps, popupMaxRows, onSubmit, onHelp, bordered, onDraftChange, }: PromptInputProps): React.ReactElement;
101
+ export declare function PromptInput({ isActive, running, history, commands, cwd, theme, caps, popupMaxRows, agentMode, onSubmit, onHelp, bordered, onDraftChange, historyIntent, }: PromptInputProps): React.ReactElement;
81
102
  export {};
@@ -127,7 +127,7 @@ function isControlSeq(input) {
127
127
  // ---------------------------------------------------------------------------
128
128
  // Component
129
129
  // ---------------------------------------------------------------------------
130
- export function PromptInput({ isActive, running, history, commands, cwd, theme, caps, popupMaxRows, onSubmit, onHelp, bordered, onDraftChange, }) {
130
+ export function PromptInput({ isActive, running, history, commands, cwd, theme, caps, popupMaxRows, agentMode, onSubmit, onHelp, bordered, onDraftChange, historyIntent, }) {
131
131
  const [buffer, setBuffer] = useState('');
132
132
  const [cursor, setCursor] = useState(0);
133
133
  const [historyIndex, setHistoryIndex] = useState(null);
@@ -261,8 +261,53 @@ export function PromptInput({ isActive, running, history, commands, cwd, theme,
261
261
  recallDown();
262
262
  }
263
263
  };
264
+ // --- Wheel-driven history step (mouse-wheel-region-routing §5.6). ---------
265
+ // The applier closes over the buffer, the cursor and the popup state, so it
266
+ // is re-created on every render and reached through a ref: depending on it
267
+ // directly would re-fire the effect on every keystroke.
268
+ const applyHistoryIntent = useRef(() => { });
269
+ applyHistoryIntent.current = (dir) => {
270
+ // Mirrors the keyboard branch exactly — with the completion popup open,
271
+ // Up/Down move the selection rather than recalling history.
272
+ if (popupItems.length > 0) {
273
+ setSel(dir === 'up'
274
+ ? Math.max(0, clampedSel - 1)
275
+ : Math.min(popupItems.length - 1, clampedSel + 1));
276
+ return;
277
+ }
278
+ verticalOrHistory(dir);
279
+ };
280
+ const historyIntentRef = useRef(historyIntent);
281
+ historyIntentRef.current = historyIntent;
282
+ // I-9: seeded from the incoming nonce during the FIRST render, so a mount
283
+ // that inherits a nonce cannot replay it. This component really does remount
284
+ // — `App` returns the terminal-too-small placeholder before `AppShell` —
285
+ // so without the guard, shrinking the window mid-wheel and growing it back
286
+ // recalls a history entry with no gesture behind it.
287
+ const lastHistoryNonce = useRef(historyIntent?.nonce);
288
+ const historyNonce = historyIntent?.nonce ?? 0;
289
+ useEffect(() => {
290
+ const current = historyIntentRef.current;
291
+ if (!current || !isActive)
292
+ return;
293
+ if (current.nonce === lastHistoryNonce.current)
294
+ return;
295
+ lastHistoryNonce.current = current.nonce;
296
+ applyHistoryIntent.current(current.dir);
297
+ // Keyed on the nonce alone, same reasoning as `ScrollViewport`'s intent
298
+ // effect: `historyIntent` is a fresh object on every parent render.
299
+ // eslint-disable-next-line react-hooks/exhaustive-deps
300
+ }, [historyNonce]);
264
301
  // --- Input handling. -----------------------------------------------------
265
302
  useInput((input, key) => {
303
+ // FIRST STATEMENT, BEFORE THE POPUP BRANCH, AND IT HAS TO STAY THERE.
304
+ // `App` owns the Shift+Tab mode toggle. The popup branch below treats
305
+ // `key.tab` as "accept the highlighted completion", so without this guard
306
+ // pressing Shift+Tab with the `/` palette or an `@` file popup open would
307
+ // BOTH rewrite the user's draft and change the mode — a bug that only
308
+ // appears with a popup open and is therefore easy to ship (AC-P3 / R-P2).
309
+ if (key.tab && key.shift)
310
+ return;
266
311
  // Popup navigation owns Up/Down/Tab/→/Enter/Esc while it is open.
267
312
  if (popupItems.length > 0) {
268
313
  if (key.upArrow) {
@@ -353,7 +398,9 @@ export function PromptInput({ isActive, running, history, commands, cwd, theme,
353
398
  const markerColor = running ? theme.toolRunning : theme.primary;
354
399
  const placeholder = running
355
400
  ? `Type to steer the run, Esc to abort${glyphs.ellipsis}`
356
- : `Send a message (/ for commands, @ for files)${glyphs.ellipsis}`;
401
+ : agentMode === 'plan'
402
+ ? `Describe what you want to build; I'll research and plan it first${glyphs.ellipsis}`
403
+ : `Send a message (/ for commands, @ for files)${glyphs.ellipsis}`;
357
404
  const lines = renderWithCursor(buffer, cursor, isActive);
358
405
  const inputRow = (_jsxs(Box, { flexDirection: "row", children: [_jsxs(Text, { color: markerColor, bold: true, children: [marker, ' '] }), _jsx(Box, { flexDirection: "column", children: buffer.length === 0 ? (_jsx(Text, { color: theme.muted, children: placeholder })) : (lines.map((line, i) => _jsx(Text, { children: line }, i))) })] }));
359
406
  return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [popupItems.length > 0 && (_jsx(AutocompletePopup, { items: popupItems, selected: clampedSel, theme: theme, caps: caps, maxRows: popupMaxRows })), bordered ? (_jsx(Box, { flexDirection: "column", flexShrink: 0, borderStyle: glyphs.boxStyle, borderColor: bordered.color, paddingX: 1, children: inputRow })) : (inputRow)] }));
@@ -11,6 +11,7 @@ import React from 'react';
11
11
  import type { Theme } from './theme.js';
12
12
  import type { TermCapabilities } from './capabilities.js';
13
13
  import type { UsageTotal } from '../agent/reducer.js';
14
+ import { type AgentMode } from '../agent/agent-mode.js';
14
15
  interface StatusBarProps {
15
16
  model: string;
16
17
  provider: string;
@@ -34,6 +35,20 @@ interface StatusBarProps {
34
35
  * Ctrl+L redraw carrier — invariant I-5 (spec §4.13). See `redrawChar`.
35
36
  */
36
37
  redrawNonce?: number;
38
+ /**
39
+ * The session mode. THIS IS THE GUARANTEED MODE INDICATOR (plan-mode §6.4 /
40
+ * P1-6): the composer chip disappears on a short terminal, under
41
+ * `hints: false`, and in inline render mode, but `StatusBar` renders in both
42
+ * `AppShell` branches and its left cluster has no opt-out and no width gate.
43
+ *
44
+ * Design intent, stated here so it survives a later refactor: the status bar
45
+ * must ALWAYS name a non-default mode; the chip may. Do not give this a
46
+ * `cols >= N` breakpoint, and do not move it into the right cluster — those
47
+ * children are flexible and drop characters under pressure.
48
+ */
49
+ agentMode?: AgentMode;
50
+ /** Non-null while a deferred `plan -> build` waits for `agent_end`. */
51
+ pendingAgentMode?: AgentMode | null;
37
52
  }
38
53
  export declare function StatusBar(props: StatusBarProps): React.ReactElement;
39
54
  export {};
@@ -3,6 +3,7 @@ import { Box, Text, useStdout } from 'ink';
3
3
  import { pickGlyphs } from './glyphs.js';
4
4
  import { formatCost, formatTokens, formatDuration } from '../agent/usage.js';
5
5
  import { buildGauge } from './gauge.js';
6
+ import { MODE_LABEL } from '../agent/agent-mode.js';
6
7
  /**
7
8
  * I-5 — DO NOT "CLEAN THIS UP". Ink short-circuits identical output at TWO
8
9
  * gates (`ink.js:132` and `log-update.js:13`), so a fixed frame whose React
@@ -22,7 +23,7 @@ function redrawChar(nonce) {
22
23
  return nonce % 2 === 0 ? ' ' : NBSP;
23
24
  }
24
25
  export function StatusBar(props) {
25
- const { model, provider, usageTotal, contextTokens, contextWindow, contextWindowKnown, status, elapsedMs, thinkingLevel, tokPerSec, theme, caps, scrolledLines = 0, redrawNonce = 0, } = props;
26
+ const { model, provider, usageTotal, contextTokens, contextWindow, contextWindowKnown, status, elapsedMs, thinkingLevel, tokPerSec, theme, caps, scrolledLines = 0, redrawNonce = 0, agentMode = 'build', pendingAgentMode = null, } = props;
26
27
  const { stdout } = useStdout();
27
28
  const cols = stdout?.columns ?? 80;
28
29
  const running = status === 'running';
@@ -32,6 +33,13 @@ export function StatusBar(props) {
32
33
  const pctLabel = `${contextWindowKnown ? '' : '~'}${gauge.pct}%`;
33
34
  const tokens = `${formatTokens(usageTotal.inputTokens)}${glyphs.arrowUp} ${formatTokens(usageTotal.outputTokens)}${glyphs.arrowDown}`;
34
35
  const statusGlyph = running ? theme.symbols.toolRunning : theme.symbols.toolPending;
36
+ // Nothing at all in build mode with nothing pending: the default posture is
37
+ // not news, and a permanent word here would be furniture.
38
+ const modeWord = agentMode === 'build' && !pendingAgentMode
39
+ ? ''
40
+ : pendingAgentMode
41
+ ? `${MODE_LABEL[agentMode]} ${glyphs.arrowRight} ${MODE_LABEL[pendingAgentMode]}`
42
+ : MODE_LABEL[agentMode];
35
43
  return (
36
44
  // `flexShrink={0}` on the left cluster is not cosmetic. When the two clusters
37
45
  // over-subscribe the row, yoga shrinks the flexible children and Ink's text
@@ -39,5 +47,5 @@ export function StatusBar(props) {
39
47
  // and "? hel" rather than truncating cleanly at one end. Pinning the left
40
48
  // cluster keeps the run status and model intact and makes the degradation
41
49
  // land in one predictable place.
42
- _jsxs(Box, { flexDirection: "row", justifyContent: "space-between", flexShrink: 0, children: [_jsxs(Box, { flexDirection: "row", flexShrink: 0, children: [_jsxs(Text, { color: running ? theme.toolRunning : theme.toolDone, children: [redrawChar(redrawNonce), statusGlyph, " ", running ? 'running' : 'idle'] }), cols >= 60 && (_jsxs(Text, { color: theme.muted, children: [' ', provider, ":", model] })), thinkingLevel !== 'off' && cols >= 72 && (_jsxs(Text, { color: theme.accent, children: [" think:", thinkingLevel] }))] }), _jsxs(Box, { flexDirection: "row", children: [scrolledLines > 0 && (_jsxs(Text, { color: theme.noticeWarn, children: [glyphs.arrowUp, scrolledLines, ' '] })), cols >= 60 && (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: "[" }), _jsx(Text, { color: gauge.fillColor, children: gauge.filled }), _jsx(Text, { color: gauge.trackColor, children: gauge.empty }), _jsx(Text, { color: theme.muted, children: "] " })] })), _jsx(Text, { color: theme.muted, children: pctLabel }), cols >= 72 && _jsxs(Text, { color: theme.muted, children: [" ", tokens] }), _jsxs(Text, { color: theme.muted, children: [" ", formatCost(usageTotal.costUsd)] }), running && (_jsxs(Text, { color: theme.muted, children: [tokPerSec > 0 && cols >= 72 ? ` ${tokPerSec} tok/s` : '', " ", formatDuration(elapsedMs)] }))] })] }));
50
+ _jsxs(Box, { flexDirection: "row", justifyContent: "space-between", flexShrink: 0, children: [_jsxs(Box, { flexDirection: "row", flexShrink: 0, children: [_jsxs(Text, { color: running ? theme.toolRunning : theme.toolDone, children: [redrawChar(redrawNonce), statusGlyph, " ", running ? 'running' : 'idle'] }), modeWord && (_jsxs(Text, { color: theme.accent, bold: true, children: [' ', modeWord] })), cols >= 60 && (_jsxs(Text, { color: theme.muted, children: [' ', provider, ":", model] })), thinkingLevel !== 'off' && cols >= 72 && (_jsxs(Text, { color: theme.accent, children: [" think:", thinkingLevel] }))] }), _jsxs(Box, { flexDirection: "row", children: [scrolledLines > 0 && (_jsxs(Text, { color: theme.noticeWarn, children: [glyphs.arrowUp, scrolledLines, ' '] })), cols >= 60 && (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: "[" }), _jsx(Text, { color: gauge.fillColor, children: gauge.filled }), _jsx(Text, { color: gauge.trackColor, children: gauge.empty }), _jsx(Text, { color: theme.muted, children: "] " })] })), _jsx(Text, { color: theme.muted, children: pctLabel }), cols >= 72 && _jsxs(Text, { color: theme.muted, children: [" ", tokens] }), _jsxs(Text, { color: theme.muted, children: [" ", formatCost(usageTotal.costUsd)] }), running && (_jsxs(Text, { color: theme.muted, children: [tokPerSec > 0 && cols >= 72 ? ` ${tokPerSec} tok/s` : '', " ", formatDuration(elapsedMs)] }))] })] }));
43
51
  }
@@ -34,6 +34,8 @@ export declare function parseWriteFile(text: string): {
34
34
  bytes: number;
35
35
  path: string;
36
36
  } | null;
37
+ /** `3 questions · Postgres · REST` — or `dismissed`. */
38
+ export declare function summarizeAskUser(lines: string[]): string | null;
37
39
  export interface ListRow {
38
40
  kind: 'dir' | 'file';
39
41
  name: string;
@@ -44,6 +44,40 @@ export function parseWriteFile(text) {
44
44
  return null;
45
45
  return { bytes: Number.parseInt(m[1], 10), path: m[2] };
46
46
  }
47
+ /**
48
+ * Parse the JSON tail of a tool result whose first line is prose.
49
+ *
50
+ * FAILS SOFT, ALWAYS. This runs inside a render pass, and the stored preview may
51
+ * have been truncated at `STORED_PREVIEW_CHARS` or restored from a session file
52
+ * written by another build. `null` means "fall through to the generic
53
+ * renderer", never "throw while drawing the transcript".
54
+ */
55
+ function parseJsonTail(lines) {
56
+ const start = lines.findIndex((l) => l.trimStart().startsWith('{'));
57
+ if (start < 0)
58
+ return null;
59
+ try {
60
+ return JSON.parse(lines.slice(start).join('\n'));
61
+ }
62
+ catch {
63
+ return null;
64
+ }
65
+ }
66
+ /** `3 questions · Postgres · REST` — or `dismissed`. */
67
+ export function summarizeAskUser(lines) {
68
+ const parsed = parseJsonTail(lines);
69
+ if (!parsed || !Array.isArray(parsed.answers))
70
+ return null;
71
+ if (parsed.cancelled)
72
+ return 'dismissed';
73
+ const count = parsed.answers.length;
74
+ const picks = parsed.answers
75
+ .map((a) => (typeof a.custom === 'string' && a.custom.length > 0
76
+ ? a.custom
77
+ : Array.isArray(a.selected) ? a.selected.join('/') : ''))
78
+ .filter((s) => s.length > 0);
79
+ return [`${count} question${count === 1 ? '' : 's'}`, ...picks].join(' - ');
80
+ }
47
81
  /** Parse a real `list_dir` row (`dir <name>/` / `file <name> (<n>b)`) (P2-9). */
48
82
  export function parseListRow(line) {
49
83
  const dir = /^dir\s+(.*?)\/?\s*$/.exec(line);
@@ -113,6 +147,30 @@ function renderGlob(lines, theme) {
113
147
  return (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: line.slice(0, slash + 1) }), line.slice(slash + 1)] }, i));
114
148
  });
115
149
  }
150
+ function renderAskUser(lines, theme) {
151
+ const summary = summarizeAskUser(lines);
152
+ // An unparseable preview falls through to the generic renderer rather than
153
+ // rendering nothing: a truncated result is still information.
154
+ if (!summary)
155
+ return renderPlain(lines, theme, false);
156
+ return [
157
+ _jsx(Text, { color: theme.muted, children: summary }, "q"),
158
+ ];
159
+ }
160
+ /**
161
+ * `Plan approved.` / `Plan rejected. ...` — the verdict is the whole content of
162
+ * a `submit_plan` result, and it is the first line, so this needs no parsing.
163
+ */
164
+ function renderSubmitPlan(lines, theme) {
165
+ const first = lines.find((l) => l.trim().length > 0) ?? '';
166
+ const approved = /^Plan approved/.test(first);
167
+ const rejected = /^Plan rejected/.test(first);
168
+ if (!approved && !rejected)
169
+ return renderPlain(lines, theme, false);
170
+ return [
171
+ _jsx(Text, { color: approved ? theme.toolDone : theme.noticeWarn, children: first }, "v"),
172
+ ];
173
+ }
116
174
  function renderPlain(lines, theme, error) {
117
175
  return lines.map((line, i) => (_jsx(Text, { color: error ? theme.toolError : theme.muted, children: line.length > 0 ? line : ' ' }, i)));
118
176
  }
@@ -137,6 +195,12 @@ export function ToolPreview({ name, lines, theme, isError }) {
137
195
  case 'glob':
138
196
  body = renderGlob(lines, theme);
139
197
  break;
198
+ case 'ask_user':
199
+ body = renderAskUser(lines, theme);
200
+ break;
201
+ case 'submit_plan':
202
+ body = renderSubmitPlan(lines, theme);
203
+ break;
140
204
  default:
141
205
  body = renderPlain(lines, theme, isError);
142
206
  }
@@ -37,6 +37,15 @@ export interface Glyphs {
37
37
  gaugeEmpty: string;
38
38
  keyOn: string;
39
39
  keyOff: string;
40
+ /**
41
+ * Checkbox pair for a multi-select question (`allowMultiple`).
42
+ *
43
+ * Distinct from `keyOn` / `keyOff`, which are radios: a user looking at a list
44
+ * has to be able to tell "pick one" from "pick several" without reading the
45
+ * footer, and that distinction is exactly what the two shapes carry.
46
+ */
47
+ boxChecked: string;
48
+ boxEmpty: string;
40
49
  wordmark: string;
41
50
  ellipsis: string;
42
51
  emDash: string;
@@ -63,6 +72,18 @@ export interface Glyphs {
63
72
  railVertical: string;
64
73
  railBranch: string;
65
74
  railEnd: string;
75
+ /**
76
+ * Scroll-position indicator on the viewport's right edge (mouse-wheel §4.8).
77
+ *
78
+ * These are byte-for-byte identical to `railVertical` / `gaugeFull` in both
79
+ * tiers, and that is DELIBERATE (P2-2). This file's header mandates "add a
80
+ * field here instead" of a new exception, and semantic field names are what
81
+ * make the ASCII tier reviewable at a glance. Reusing `gaugeFull` would
82
+ * couple the scroll thumb to the context-window gauge, so that restyling one
83
+ * silently restyles the other — do not "deduplicate" them.
84
+ */
85
+ scrollTrack: string;
86
+ scrollThumb: string;
66
87
  /** Redaction dot for `maskSecret` in the settings screen (§4.1 tier B). */
67
88
  maskDot: string;
68
89
  /**
package/dist/ui/glyphs.js CHANGED
@@ -35,6 +35,8 @@ const UNICODE_GLYPHS = {
35
35
  gaugeEmpty: '░',
36
36
  keyOn: '●',
37
37
  keyOff: '○',
38
+ boxChecked: '☑',
39
+ boxEmpty: '☐',
38
40
  wordmark: '◇',
39
41
  ellipsis: '…',
40
42
  emDash: '—',
@@ -53,6 +55,8 @@ const UNICODE_GLYPHS = {
53
55
  railVertical: '│',
54
56
  railBranch: '├',
55
57
  railEnd: '╰',
58
+ scrollTrack: '│',
59
+ scrollThumb: '█',
56
60
  maskDot: '•',
57
61
  boxStyle: 'round',
58
62
  tool: {
@@ -63,6 +67,8 @@ const UNICODE_GLYPHS = {
63
67
  glob: '⌕',
64
68
  grep: '⌕',
65
69
  list_dir: '▤',
70
+ ask_user: '?',
71
+ submit_plan: '◈',
66
72
  },
67
73
  toolDefault: '•',
68
74
  };
@@ -83,6 +89,8 @@ const ASCII_GLYPHS = {
83
89
  gaugeEmpty: '-',
84
90
  keyOn: '*',
85
91
  keyOff: 'o',
92
+ boxChecked: '[x]',
93
+ boxEmpty: '[ ]',
86
94
  wordmark: '<>',
87
95
  ellipsis: '...',
88
96
  emDash: '--',
@@ -101,6 +109,8 @@ const ASCII_GLYPHS = {
101
109
  railVertical: '|',
102
110
  railBranch: '|',
103
111
  railEnd: '\\',
112
+ scrollTrack: '|',
113
+ scrollThumb: '#',
104
114
  maskDot: '*',
105
115
  boxStyle: 'classic',
106
116
  tool: {
@@ -111,6 +121,8 @@ const ASCII_GLYPHS = {
111
121
  glob: '/',
112
122
  grep: '/',
113
123
  list_dir: '=',
124
+ ask_user: '?',
125
+ submit_plan: '#',
114
126
  },
115
127
  toolDefault: '*',
116
128
  };
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import React from 'react';
13
13
  import { type RenderMode } from './frame.js';
14
+ import { type FrameGeometry } from './regions.js';
14
15
  export interface AppShellProps {
15
16
  mode: RenderMode;
16
17
  rows: number;
@@ -20,5 +21,13 @@ export interface AppShellProps {
20
21
  toast: React.ReactNode;
21
22
  composer: React.ReactNode;
22
23
  status: React.ReactNode;
24
+ /**
25
+ * Where the wheel router reads the composer band boundary from (mouse-wheel
26
+ * §4.5). Written in a LAYOUT EFFECT to a MUTABLE REF, never to state: the
27
+ * measurement happens on every commit, and routing it through `setState`
28
+ * would add a re-render to the streaming path for a value nothing renders
29
+ * (R-7).
30
+ */
31
+ geometryRef?: React.MutableRefObject<FrameGeometry>;
23
32
  }
24
- export declare function AppShell({ mode, rows, cols, header, viewport, toast, composer, status, }: AppShellProps): React.ReactElement;
33
+ export declare function AppShell({ mode, rows, cols, header, viewport, toast, composer, status, geometryRef, }: AppShellProps): React.ReactElement;
@@ -1,9 +1,44 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
- import { Box } from 'ink';
2
+ /**
3
+ * AppShell (spec §4.3) — the frame that makes R2 true.
4
+ *
5
+ * In `fullscreen` the root box has a FIXED height of `frameHeight(rows)` and the
6
+ * middle viewport is the only `flexGrow` child, so the bottom chrome (toast /
7
+ * composer / status bar) is physically the last thing on the screen from the
8
+ * very first frame — empty session, long session, mid-scroll, overlay open.
9
+ *
10
+ * In `inline` the same children are emitted in the same order with no height
11
+ * constraint, i.e. byte-for-byte the v0.2.0 document-flow behavior.
12
+ */
13
+ import { useLayoutEffect, useRef } from 'react';
14
+ import { Box, measureElement } from 'ink';
3
15
  import { frameHeight } from './frame.js';
4
- export function AppShell({ mode, rows, cols, header, viewport, toast, composer, status, }) {
16
+ import { UNMEASURED_GEOMETRY } from './regions.js';
17
+ export function AppShell({ mode, rows, cols, header, viewport, toast, composer, status, geometryRef, }) {
18
+ const bottomRef = useRef(null);
19
+ // DECLARED ABOVE THE INLINE EARLY RETURN, and it has to stay there: React
20
+ // keys hooks by call order, and a hook that only runs in one mode changes
21
+ // that order between modes.
22
+ //
23
+ // Ink computes yoga layout in `onRender`, which React runs BEFORE layout
24
+ // effects, so this reflects the commit that just happened — the same
25
+ // mechanism `ScrollViewport` already relies on.
26
+ useLayoutEffect(() => {
27
+ if (!geometryRef)
28
+ return;
29
+ if (mode === 'inline' || !bottomRef.current) {
30
+ geometryRef.current = UNMEASURED_GEOMETRY;
31
+ return;
32
+ }
33
+ geometryRef.current = {
34
+ // 1 in the alternate screen — see the note on `FrameGeometry.top`.
35
+ top: 1,
36
+ height: frameHeight(rows),
37
+ bottom: measureElement(bottomRef.current).height,
38
+ };
39
+ });
5
40
  if (mode === 'inline') {
6
41
  return (_jsxs(Box, { flexDirection: "column", children: [header, viewport, toast, composer, status] }));
7
42
  }
8
- return (_jsxs(Box, { flexDirection: "column", height: frameHeight(rows), width: cols, overflow: "hidden", children: [_jsx(Box, { flexDirection: "column", flexShrink: 0, children: header }), _jsx(Box, { flexDirection: "column", flexGrow: 1, flexShrink: 1, overflow: "hidden", children: viewport }), _jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [toast, composer, status] })] }));
43
+ return (_jsxs(Box, { flexDirection: "column", height: frameHeight(rows), width: cols, overflow: "hidden", children: [_jsx(Box, { flexDirection: "column", flexShrink: 0, children: header }), _jsx(Box, { flexDirection: "column", flexGrow: 1, flexShrink: 1, overflow: "hidden", children: viewport }), _jsxs(Box, { ref: bottomRef, flexDirection: "column", flexShrink: 0, children: [toast, composer, status] })] }));
9
44
  }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * ScrollIndicator (mouse-wheel-region-routing §4.8) — the one-column scroll
3
+ * position rail on the viewport's right edge.
4
+ *
5
+ * NAMING (P2-1): this is NOT `layout/Gutter.tsx`. That one is the two-column
6
+ * left rail every transcript entry sits in, and it carries an invariant of its
7
+ * own. Two components called a gutter, on opposite edges, for unrelated
8
+ * purposes, is how a codebase stops being able to say what it means.
9
+ *
10
+ * THE COLUMN IS RESERVED UNCONDITIONALLY, track glyph and all. Showing it only
11
+ * while scrolled would change the content width, re-wrap every line, change the
12
+ * content height, and feed that back into the measurement that decides whether
13
+ * to show it — a shimmer at the exact moment the user is reading. Constant
14
+ * width, no feedback loop (D-9); `width={1}` and `flexShrink={0}` hold the
15
+ * column even on the first frame, before anything has been measured.
16
+ */
17
+ import React from 'react';
18
+ import type { Theme } from '../theme.js';
19
+ import type { TermCapabilities } from '../capabilities.js';
20
+ import type { ThumbRange } from './scroll-indicator.js';
21
+ /** Below this width a column of content is worth more than the affordance. */
22
+ export declare const MIN_INDICATOR_COLS = 50;
23
+ export interface ScrollIndicatorProps {
24
+ /** Viewport height in rows, as measured by `ScrollViewport`. */
25
+ rows: number;
26
+ /** `null` when the content fits — the rail is drawn as bare track. */
27
+ thumb: ThumbRange | null;
28
+ theme: Theme;
29
+ caps: TermCapabilities;
30
+ }
31
+ export declare function ScrollIndicator({ rows, thumb, theme, caps, }: ScrollIndicatorProps): React.ReactElement;
@@ -0,0 +1,15 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { pickGlyphs } from '../glyphs.js';
4
+ /** Below this width a column of content is worth more than the affordance. */
5
+ export const MIN_INDICATOR_COLS = 50;
6
+ export function ScrollIndicator({ rows, thumb, theme, caps, }) {
7
+ const glyphs = pickGlyphs(caps);
8
+ const count = Math.max(0, Math.floor(rows) || 0);
9
+ const cells = [];
10
+ for (let i = 0; i < count; i += 1) {
11
+ const onThumb = !!thumb && i >= thumb.start && i < thumb.start + thumb.size;
12
+ cells.push(_jsx(Text, { color: onThumb ? theme.primary : theme.border, children: onThumb ? glyphs.scrollThumb : glyphs.scrollTrack }, i));
13
+ }
14
+ return (_jsx(Box, { flexDirection: "column", flexShrink: 0, width: 1, children: cells }));
15
+ }