@aragon-agent/cli 0.5.0 → 0.5.1

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 (82) hide show
  1. package/CHANGELOG.md +131 -0
  2. package/README.md +227 -3
  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 +183 -20
  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 +45 -1
  23. package/dist/config/load.d.ts +8 -0
  24. package/dist/config/load.js +59 -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 +61 -0
  30. package/dist/config/schema.js +83 -0
  31. package/dist/config/store.d.ts +30 -12
  32. package/dist/config/store.js +47 -35
  33. package/dist/logging/cli-commands.d.ts +25 -0
  34. package/dist/logging/cli-commands.js +221 -0
  35. package/dist/logging/file-sink.d.ts +114 -0
  36. package/dist/logging/file-sink.js +326 -0
  37. package/dist/logging/install.d.ts +163 -0
  38. package/dist/logging/install.js +302 -0
  39. package/dist/logging/levels.d.ts +40 -0
  40. package/dist/logging/levels.js +59 -0
  41. package/dist/logging/logger.d.ts +110 -0
  42. package/dist/logging/logger.js +237 -0
  43. package/dist/logging/redact.d.ts +28 -0
  44. package/dist/logging/redact.js +90 -0
  45. package/dist/logging/secret-registry.d.ts +30 -0
  46. package/dist/logging/secret-registry.js +55 -0
  47. package/dist/skills/paths.d.ts +2 -4
  48. package/dist/skills/paths.js +7 -9
  49. package/dist/tools/human-input.d.ts +179 -0
  50. package/dist/tools/human-input.js +227 -0
  51. package/dist/tools/index.d.ts +35 -3
  52. package/dist/tools/index.js +96 -9
  53. package/dist/tools/plan-tools.d.ts +64 -0
  54. package/dist/tools/plan-tools.js +198 -0
  55. package/dist/ui/App.d.ts +4 -1
  56. package/dist/ui/App.js +182 -11
  57. package/dist/ui/Composer.d.ts +4 -1
  58. package/dist/ui/Composer.js +26 -7
  59. package/dist/ui/ModeChip.d.ts +29 -0
  60. package/dist/ui/ModeChip.js +15 -0
  61. package/dist/ui/PromptInput.d.ts +8 -1
  62. package/dist/ui/PromptInput.js +12 -2
  63. package/dist/ui/StatusBar.d.ts +15 -0
  64. package/dist/ui/StatusBar.js +10 -2
  65. package/dist/ui/entries/ToolPreview.d.ts +2 -0
  66. package/dist/ui/entries/ToolPreview.js +64 -0
  67. package/dist/ui/glyphs.d.ts +9 -0
  68. package/dist/ui/glyphs.js +8 -0
  69. package/dist/ui/layout/wrap-rows.d.ts +24 -0
  70. package/dist/ui/layout/wrap-rows.js +74 -0
  71. package/dist/ui/overlays/HelpOverlay.js +13 -0
  72. package/dist/ui/overlays/PlanReviewOverlay.d.ts +50 -0
  73. package/dist/ui/overlays/PlanReviewOverlay.js +117 -0
  74. package/dist/ui/overlays/QuestionOverlay.d.ts +30 -0
  75. package/dist/ui/overlays/QuestionOverlay.js +193 -0
  76. package/dist/ui/overlays/SettingsScreen.d.ts +7 -0
  77. package/dist/ui/overlays/SettingsScreen.js +8 -1
  78. package/dist/ui/use-startup-notices.d.ts +15 -0
  79. package/dist/ui/use-startup-notices.js +38 -0
  80. package/package.json +3 -2
  81. package/runtime/insecure-tls-warning.cjs +62 -0
  82. package/runtime/insecure-tls-warning.d.cts +5 -0
@@ -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)
@@ -77,5 +84,5 @@ export declare function fileTokenAt(buffer: string, cursor: number): {
77
84
  end: number;
78
85
  query: string;
79
86
  } | null;
80
- export declare function PromptInput({ isActive, running, history, commands, cwd, theme, caps, popupMaxRows, onSubmit, onHelp, bordered, onDraftChange, }: PromptInputProps): React.ReactElement;
87
+ export declare function PromptInput({ isActive, running, history, commands, cwd, theme, caps, popupMaxRows, agentMode, onSubmit, onHelp, bordered, onDraftChange, }: PromptInputProps): React.ReactElement;
81
88
  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, }) {
131
131
  const [buffer, setBuffer] = useState('');
132
132
  const [cursor, setCursor] = useState(0);
133
133
  const [historyIndex, setHistoryIndex] = useState(null);
@@ -263,6 +263,14 @@ export function PromptInput({ isActive, running, history, commands, cwd, theme,
263
263
  };
264
264
  // --- Input handling. -----------------------------------------------------
265
265
  useInput((input, key) => {
266
+ // FIRST STATEMENT, BEFORE THE POPUP BRANCH, AND IT HAS TO STAY THERE.
267
+ // `App` owns the Shift+Tab mode toggle. The popup branch below treats
268
+ // `key.tab` as "accept the highlighted completion", so without this guard
269
+ // pressing Shift+Tab with the `/` palette or an `@` file popup open would
270
+ // BOTH rewrite the user's draft and change the mode — a bug that only
271
+ // appears with a popup open and is therefore easy to ship (AC-P3 / R-P2).
272
+ if (key.tab && key.shift)
273
+ return;
266
274
  // Popup navigation owns Up/Down/Tab/→/Enter/Esc while it is open.
267
275
  if (popupItems.length > 0) {
268
276
  if (key.upArrow) {
@@ -353,7 +361,9 @@ export function PromptInput({ isActive, running, history, commands, cwd, theme,
353
361
  const markerColor = running ? theme.toolRunning : theme.primary;
354
362
  const placeholder = running
355
363
  ? `Type to steer the run, Esc to abort${glyphs.ellipsis}`
356
- : `Send a message (/ for commands, @ for files)${glyphs.ellipsis}`;
364
+ : agentMode === 'plan'
365
+ ? `Describe what you want to build; I'll research and plan it first${glyphs.ellipsis}`
366
+ : `Send a message (/ for commands, @ for files)${glyphs.ellipsis}`;
357
367
  const lines = renderWithCursor(buffer, cursor, isActive);
358
368
  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
369
  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;
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: '—',
@@ -63,6 +65,8 @@ const UNICODE_GLYPHS = {
63
65
  glob: '⌕',
64
66
  grep: '⌕',
65
67
  list_dir: '▤',
68
+ ask_user: '?',
69
+ submit_plan: '◈',
66
70
  },
67
71
  toolDefault: '•',
68
72
  };
@@ -83,6 +87,8 @@ const ASCII_GLYPHS = {
83
87
  gaugeEmpty: '-',
84
88
  keyOn: '*',
85
89
  keyOff: 'o',
90
+ boxChecked: '[x]',
91
+ boxEmpty: '[ ]',
86
92
  wordmark: '<>',
87
93
  ellipsis: '...',
88
94
  emDash: '--',
@@ -111,6 +117,8 @@ const ASCII_GLYPHS = {
111
117
  glob: '/',
112
118
  grep: '/',
113
119
  list_dir: '=',
120
+ ask_user: '?',
121
+ submit_plan: '#',
114
122
  },
115
123
  toolDefault: '*',
116
124
  };
@@ -0,0 +1,24 @@
1
+ /**
2
+ * `wrapToRows` — the prose wrapper for `OverlayFrame`'s controlled (mode A)
3
+ * content (plan-mode §6.6).
4
+ *
5
+ * Mode A's contract is *one element per row, each already `wrap="truncate"`*,
6
+ * and `sliceWindow` counts ELEMENTS, not lines. Help and Settings satisfy that
7
+ * naturally because their content is a list of short rows. A plan is not: its
8
+ * summary runs to 600 characters and a step detail to 400, so handing either
9
+ * over as a single `<Text>` renders the whole paragraph as one truncated line —
10
+ * the single worst failure available to a plan review card, and one that also
11
+ * makes the frame's `1-14/22` position indicator a false statement.
12
+ *
13
+ * The repo had no wrapper before this (no `wrapText`, no `wordWrap`), so here is
14
+ * one: pure, no Ink, unit-tested.
15
+ */
16
+ /**
17
+ * Break `text` into lines of at most `width` columns.
18
+ *
19
+ * Prefers spaces; hard-breaks any single token longer than `width` (a URL or a
20
+ * long path, which must not be dropped and cannot be wrapped politely).
21
+ * Existing newlines are honoured as paragraph breaks. Returns `[]` for empty
22
+ * input so callers can splice the result without an emptiness branch.
23
+ */
24
+ export declare function wrapToRows(text: string, width: number): string[];
@@ -0,0 +1,74 @@
1
+ /**
2
+ * `wrapToRows` — the prose wrapper for `OverlayFrame`'s controlled (mode A)
3
+ * content (plan-mode §6.6).
4
+ *
5
+ * Mode A's contract is *one element per row, each already `wrap="truncate"`*,
6
+ * and `sliceWindow` counts ELEMENTS, not lines. Help and Settings satisfy that
7
+ * naturally because their content is a list of short rows. A plan is not: its
8
+ * summary runs to 600 characters and a step detail to 400, so handing either
9
+ * over as a single `<Text>` renders the whole paragraph as one truncated line —
10
+ * the single worst failure available to a plan review card, and one that also
11
+ * makes the frame's `1-14/22` position indicator a false statement.
12
+ *
13
+ * The repo had no wrapper before this (no `wrapText`, no `wordWrap`), so here is
14
+ * one: pure, no Ink, unit-tested.
15
+ */
16
+ /**
17
+ * Break `text` into lines of at most `width` columns.
18
+ *
19
+ * Prefers spaces; hard-breaks any single token longer than `width` (a URL or a
20
+ * long path, which must not be dropped and cannot be wrapped politely).
21
+ * Existing newlines are honoured as paragraph breaks. Returns `[]` for empty
22
+ * input so callers can splice the result without an emptiness branch.
23
+ */
24
+ export function wrapToRows(text, width) {
25
+ if (typeof text !== 'string' || text.length === 0)
26
+ return [];
27
+ const limit = Math.max(1, Math.floor(width));
28
+ const rows = [];
29
+ for (const paragraph of text.split('\n')) {
30
+ if (paragraph.trim().length === 0) {
31
+ // A blank line in the source is a deliberate separator; keep it as a row
32
+ // rather than collapsing it, or the summary and the steps run together.
33
+ rows.push('');
34
+ continue;
35
+ }
36
+ rows.push(...wrapParagraph(paragraph, limit));
37
+ }
38
+ return rows;
39
+ }
40
+ function wrapParagraph(paragraph, limit) {
41
+ const rows = [];
42
+ let current = '';
43
+ for (const word of paragraph.split(/\s+/).filter((w) => w.length > 0)) {
44
+ if (word.length > limit) {
45
+ // Flush what we have, then chop the over-long token into full-width
46
+ // pieces. Its tail becomes the new `current` so the next word can still
47
+ // join it.
48
+ if (current.length > 0) {
49
+ rows.push(current);
50
+ current = '';
51
+ }
52
+ let rest = word;
53
+ while (rest.length > limit) {
54
+ rows.push(rest.slice(0, limit));
55
+ rest = rest.slice(limit);
56
+ }
57
+ current = rest;
58
+ continue;
59
+ }
60
+ if (current.length === 0) {
61
+ current = word;
62
+ }
63
+ else if (current.length + 1 + word.length <= limit) {
64
+ current = `${current} ${word}`;
65
+ }
66
+ else {
67
+ rows.push(current);
68
+ current = word;
69
+ }
70
+ }
71
+ if (current.length > 0)
72
+ rows.push(current);
73
+ return rows;
74
+ }
@@ -7,6 +7,7 @@ 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
11
  [`Ctrl+C ${times}2`, 'Exit'],
11
12
  ['Ctrl+L', 'Redraw the frame'],
12
13
  ['Ctrl+T', 'Toggle thinking blocks'],
@@ -19,6 +20,7 @@ const COMMANDS = [
19
20
  ['/help', 'Show this help'],
20
21
  ['/model', 'Open the model picker'],
21
22
  ['/settings', 'Open the settings screen'],
23
+ ['/plan [on|off|status]', 'Toggle plan mode (same as Shift+Tab)'],
22
24
  ['/theme <name>', 'auto | warm | cool | light'],
23
25
  ['/thinking <level>', 'Set thinking level'],
24
26
  ['/tools', 'List active tools'],
@@ -41,6 +43,14 @@ const SKILL_ROWS = [
41
43
  ['/<skill-name> [args]', 'Run a skill directly ($ARGUMENTS / $1..$9)'],
42
44
  ['skill / skill_install', 'Tools the model uses to load and install skills'],
43
45
  ];
46
+ const PLAN_ROWS = [
47
+ ['Shift+Tab / /plan', 'Switch between BUILD (do it now) and PLAN (research first)'],
48
+ ['In PLAN mode', 'write_file, edit_file, bash, skill_install and skill_create are refused'],
49
+ ['bash', 'Refused in full, including git status - use read_file / glob / grep'],
50
+ ['ask_user', 'The agent asks 1-5 multiple-choice questions; Enter takes the recommendation'],
51
+ ['submit_plan', 'The agent submits a plan: a approve, r revise, Esc dismiss'],
52
+ ['--plan / --no-plan', 'Start a session in a given mode (also ARAGON_PLAN=1)'],
53
+ ];
44
54
  /** Build the overlay's rows. Exported so `app.test.tsx` can count them. */
45
55
  export function helpRows(theme, caps) {
46
56
  const glyphs = pickGlyphs(caps);
@@ -53,6 +63,9 @@ export function helpRows(theme, caps) {
53
63
  rows.push(section('Slash commands'));
54
64
  for (const [c, d] of COMMANDS)
55
65
  rows.push(pair(c, d));
66
+ rows.push(section('Plan mode'));
67
+ for (const [c, d] of PLAN_ROWS)
68
+ rows.push(pair(c, d));
56
69
  rows.push(section('Skills'));
57
70
  for (const [c, d] of SKILL_ROWS)
58
71
  rows.push(pair(c, d));
@@ -0,0 +1,50 @@
1
+ /**
2
+ * PlanReviewOverlay — the reviewable card `submit_plan` renders (plan-mode §6.6).
3
+ *
4
+ * `OverlayFrame` MODE A (`rows`), because a 20-step plan must scroll and mode A
5
+ * is the only mode with a position indicator.
6
+ *
7
+ * MODE A SLICES BY ELEMENT, SO THE PROSE IS PRE-WRAPPED. `sliceWindow` counts
8
+ * elements, not lines, and each element must be exactly one row. The summary
9
+ * runs to 600 characters and a step detail to 400 — handing either over as a
10
+ * single `<Text>` would render the whole paragraph as one truncated line and
11
+ * make the frame's `1-14/22` a false statement. `wrapToRows` is what keeps the
12
+ * count honest.
13
+ *
14
+ * ACTIONS LIVE IN THE FRAME HINT, NOT IN `rows`. Mode A slices `rows`; an action
15
+ * row placed there would scroll off exactly when a long plan needs it most.
16
+ * Single-key actions also avoid a second selection cursor competing with the
17
+ * scroll cursor.
18
+ *
19
+ * `Esc` IS NOT HANDLED HERE, and neither is any resolution of the pending
20
+ * request: `App` owns Esc, and the bridge's `settle()` is the only place a human
21
+ * request is answered (R-P12). That holds in the revise sub-state too, which is
22
+ * why the way back from the feedback field is `<-` rather than `Esc` (R2-P1-2).
23
+ */
24
+ import React from 'react';
25
+ import type { Theme } from '../theme.js';
26
+ import type { TermCapabilities } from '../capabilities.js';
27
+ import type { NormalizedPlan } from '../../tools/human-input.js';
28
+ export type PlanVerdict = {
29
+ decision: 'approved' | 'revise';
30
+ feedback: string;
31
+ };
32
+ interface PlanReviewOverlayProps {
33
+ plan: NormalizedPlan;
34
+ maxRows: number;
35
+ cols: number;
36
+ scrollOffset: number;
37
+ onScrollClamp: (offset: number) => void;
38
+ theme: Theme;
39
+ caps: TermCapabilities;
40
+ onVerdict: (verdict: PlanVerdict) => void;
41
+ }
42
+ export declare function PlanReviewOverlay({ plan, maxRows, cols, scrollOffset, onScrollClamp, theme, caps, onVerdict, }: PlanReviewOverlayProps): React.ReactElement;
43
+ /**
44
+ * Flatten the plan into one-row elements.
45
+ *
46
+ * Exported for `plan-mode.test.ts`, which asserts that a 600-character summary
47
+ * really does become many rows rather than one truncated line (P1-3).
48
+ */
49
+ export declare function buildPlanRows(plan: NormalizedPlan, width: number, theme: Theme, bullet: string): React.ReactElement[];
50
+ export {};
@@ -0,0 +1,117 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * PlanReviewOverlay — the reviewable card `submit_plan` renders (plan-mode §6.6).
4
+ *
5
+ * `OverlayFrame` MODE A (`rows`), because a 20-step plan must scroll and mode A
6
+ * is the only mode with a position indicator.
7
+ *
8
+ * MODE A SLICES BY ELEMENT, SO THE PROSE IS PRE-WRAPPED. `sliceWindow` counts
9
+ * elements, not lines, and each element must be exactly one row. The summary
10
+ * runs to 600 characters and a step detail to 400 — handing either over as a
11
+ * single `<Text>` would render the whole paragraph as one truncated line and
12
+ * make the frame's `1-14/22` a false statement. `wrapToRows` is what keeps the
13
+ * count honest.
14
+ *
15
+ * ACTIONS LIVE IN THE FRAME HINT, NOT IN `rows`. Mode A slices `rows`; an action
16
+ * row placed there would scroll off exactly when a long plan needs it most.
17
+ * Single-key actions also avoid a second selection cursor competing with the
18
+ * scroll cursor.
19
+ *
20
+ * `Esc` IS NOT HANDLED HERE, and neither is any resolution of the pending
21
+ * request: `App` owns Esc, and the bridge's `settle()` is the only place a human
22
+ * request is answered (R-P12). That holds in the revise sub-state too, which is
23
+ * why the way back from the feedback field is `<-` rather than `Esc` (R2-P1-2).
24
+ */
25
+ import { useState } from 'react';
26
+ import { Text, useInput } from 'ink';
27
+ import { pickGlyphs } from '../glyphs.js';
28
+ import { OverlayFrame } from '../layout/OverlayFrame.js';
29
+ import { wrapToRows } from '../layout/wrap-rows.js';
30
+ /** The frame's border plus its one-column padding on each side. */
31
+ const FRAME_CHROME_COLS = 4;
32
+ export function PlanReviewOverlay({ plan, maxRows, cols, scrollOffset, onScrollClamp, theme, caps, onVerdict, }) {
33
+ const glyphs = pickGlyphs(caps);
34
+ const [feedback, setFeedback] = useState(null);
35
+ useInput((input, key) => {
36
+ if (key.escape)
37
+ return; // App owns dismiss.
38
+ if (feedback !== null) {
39
+ if (key.return) {
40
+ onVerdict({ decision: 'revise', feedback: feedback.trim() });
41
+ return;
42
+ }
43
+ // THE WAY BACK TO THE CARD, and the reason it is `<-` and not `Esc`:
44
+ // `App` owns Esc here and settles the pending request with it, so a second
45
+ // Esc consumer in this overlay would be the `ConfirmDialog` double-resolve
46
+ // pattern R-P12 exists to keep out. `QuestionOverlay` already spends `<-`
47
+ // on "back", so this costs no key ownership. Without it the field is a
48
+ // one-way door — it consumes printable input and Backspace, and the only
49
+ // other exit throws the whole review away (R2-P1-2).
50
+ if (key.leftArrow) {
51
+ setFeedback(null);
52
+ return;
53
+ }
54
+ if (key.backspace || key.delete) {
55
+ setFeedback(feedback.slice(0, -1));
56
+ return;
57
+ }
58
+ if (input && !key.ctrl && !key.meta && !key.tab)
59
+ setFeedback(feedback + input);
60
+ return;
61
+ }
62
+ if (input === 'a' || input === 'A')
63
+ onVerdict({ decision: 'approved', feedback: '' });
64
+ else if (input === 'r' || input === 'R')
65
+ setFeedback('');
66
+ });
67
+ const width = Math.max(20, cols - FRAME_CHROME_COLS);
68
+ const rows = buildPlanRows(plan, width, theme, glyphs.bullet);
69
+ if (feedback !== null) {
70
+ // `esc dismiss`, NOT "esc back to the plan": `App` owns Esc for this overlay
71
+ // (R-P12) and settles the pending request with `null`, so Esc throws the
72
+ // whole review away from either sub-state. `<-` is the route back to the
73
+ // card, which is why it is named here too.
74
+ const feedbackHint = `${glyphs.enterKey} send ${glyphs.midDot} ${glyphs.arrowLeft} back ${glyphs.midDot} esc dismiss`;
75
+ return (_jsxs(OverlayFrame, { title: "What should change?", hint: feedbackHint, maxRows: maxRows, cols: cols, theme: theme, caps: caps, children: [_jsx(Text, { wrap: "truncate", color: theme.muted, children: "The agent revises the plan and submits it again." }), _jsxs(Text, { wrap: "truncate", children: [_jsxs(Text, { color: theme.primary, bold: true, children: [glyphs.caret, ' '] }), feedback.length > 0 ? (_jsx(Text, { children: feedback })) : (_jsx(Text, { color: theme.muted, children: "e.g. use the existing session store instead" })), _jsx(Text, { inverse: true, children: " " })] })] }));
76
+ }
77
+ return (_jsx(OverlayFrame, { title: "Review plan", hint: `a approve ${glyphs.midDot} r revise ${glyphs.midDot} esc dismiss ${glyphs.midDot} ${glyphs.arrowUp}${glyphs.arrowDown} scroll`, maxRows: maxRows, cols: cols, rows: rows, scrollOffset: scrollOffset, onScrollClamp: onScrollClamp, theme: theme, caps: caps }));
78
+ }
79
+ /**
80
+ * Flatten the plan into one-row elements.
81
+ *
82
+ * Exported for `plan-mode.test.ts`, which asserts that a 600-character summary
83
+ * really does become many rows rather than one truncated line (P1-3).
84
+ */
85
+ export function buildPlanRows(plan, width, theme, bullet) {
86
+ const rows = [];
87
+ let key = 0;
88
+ const push = (node, color, bold) => {
89
+ rows.push(_jsx(Text, { wrap: "truncate", color: color, bold: bold, children: node }, `p${key++}`));
90
+ };
91
+ push(plan.title, theme.assistant, true);
92
+ for (const line of wrapToRows(plan.summary, width))
93
+ push(line || ' ', theme.muted);
94
+ push(' ');
95
+ push('Steps', theme.accent, true);
96
+ plan.steps.forEach((step, i) => {
97
+ const number = String(i + 1).padStart(2, ' ');
98
+ push(`${number} ${step.title}`);
99
+ for (const line of wrapToRows(step.detail, Math.max(10, width - 4))) {
100
+ push(` ${line}`, theme.muted);
101
+ }
102
+ });
103
+ const section = (label, items) => {
104
+ if (items.length === 0)
105
+ return;
106
+ push(' ');
107
+ push(label, theme.accent, true);
108
+ for (const item of items) {
109
+ for (const line of wrapToRows(`${bullet} ${item}`, width))
110
+ push(line, theme.muted);
111
+ }
112
+ };
113
+ section('Files', plan.filesTouched);
114
+ section('Risks', plan.risks);
115
+ section('Open questions', plan.openQuestions);
116
+ return rows;
117
+ }