@aragon-agent/cli 0.4.3 → 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.
- package/CHANGELOG.md +131 -0
- package/README.md +227 -3
- package/dist/agent/agent-mode.d.ts +53 -0
- package/dist/agent/agent-mode.js +69 -0
- package/dist/agent/controller.d.ts +69 -0
- package/dist/agent/controller.js +154 -1
- package/dist/agent/plan-prompt.d.ts +18 -0
- package/dist/agent/plan-prompt.js +93 -0
- package/dist/agent/reducer.d.ts +14 -1
- package/dist/agent/reducer.js +9 -0
- package/dist/agent/system-prompt.d.ts +15 -0
- package/dist/agent/system-prompt.js +8 -0
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +183 -20
- package/dist/commands/builtins.js +60 -0
- package/dist/commands/registry.d.ts +17 -0
- package/dist/config/app-paths.d.ts +71 -4
- package/dist/config/app-paths.js +139 -8
- package/dist/config/cli-commands.d.ts +43 -0
- package/dist/config/cli-commands.js +196 -0
- package/dist/config/env.d.ts +7 -1
- package/dist/config/env.js +45 -1
- package/dist/config/load.d.ts +8 -0
- package/dist/config/load.js +59 -2
- package/dist/config/migrate-home.d.ts +63 -0
- package/dist/config/migrate-home.js +165 -0
- package/dist/config/migrate-legacy-state.d.ts +10 -0
- package/dist/config/migrate-legacy-state.js +15 -6
- package/dist/config/schema.d.ts +61 -0
- package/dist/config/schema.js +83 -0
- package/dist/config/store.d.ts +30 -12
- package/dist/config/store.js +47 -35
- package/dist/logging/cli-commands.d.ts +25 -0
- package/dist/logging/cli-commands.js +221 -0
- package/dist/logging/file-sink.d.ts +114 -0
- package/dist/logging/file-sink.js +326 -0
- package/dist/logging/install.d.ts +163 -0
- package/dist/logging/install.js +302 -0
- package/dist/logging/levels.d.ts +40 -0
- package/dist/logging/levels.js +59 -0
- package/dist/logging/logger.d.ts +110 -0
- package/dist/logging/logger.js +237 -0
- package/dist/logging/redact.d.ts +28 -0
- package/dist/logging/redact.js +90 -0
- package/dist/logging/secret-registry.d.ts +30 -0
- package/dist/logging/secret-registry.js +55 -0
- package/dist/skills/paths.d.ts +2 -4
- package/dist/skills/paths.js +7 -9
- package/dist/tools/human-input.d.ts +179 -0
- package/dist/tools/human-input.js +227 -0
- package/dist/tools/index.d.ts +35 -3
- package/dist/tools/index.js +96 -9
- package/dist/tools/plan-tools.d.ts +64 -0
- package/dist/tools/plan-tools.js +198 -0
- package/dist/ui/App.d.ts +4 -1
- package/dist/ui/App.js +182 -11
- package/dist/ui/Composer.d.ts +4 -1
- package/dist/ui/Composer.js +26 -7
- package/dist/ui/ModeChip.d.ts +29 -0
- package/dist/ui/ModeChip.js +15 -0
- package/dist/ui/PromptInput.d.ts +8 -1
- package/dist/ui/PromptInput.js +12 -2
- package/dist/ui/StatusBar.d.ts +15 -0
- package/dist/ui/StatusBar.js +10 -2
- package/dist/ui/entries/ToolPreview.d.ts +2 -0
- package/dist/ui/entries/ToolPreview.js +64 -0
- package/dist/ui/glyphs.d.ts +9 -0
- package/dist/ui/glyphs.js +8 -0
- package/dist/ui/layout/wrap-rows.d.ts +24 -0
- package/dist/ui/layout/wrap-rows.js +74 -0
- package/dist/ui/overlays/HelpOverlay.js +13 -0
- package/dist/ui/overlays/PlanReviewOverlay.d.ts +50 -0
- package/dist/ui/overlays/PlanReviewOverlay.js +117 -0
- package/dist/ui/overlays/QuestionOverlay.d.ts +30 -0
- package/dist/ui/overlays/QuestionOverlay.js +193 -0
- package/dist/ui/overlays/SettingsScreen.d.ts +7 -0
- package/dist/ui/overlays/SettingsScreen.js +8 -1
- package/dist/ui/use-startup-notices.d.ts +15 -0
- package/dist/ui/use-startup-notices.js +38 -0
- package/package.json +3 -2
- package/runtime/insecure-tls-warning.cjs +62 -0
- package/runtime/insecure-tls-warning.d.cts +5 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ask_user` and `submit_plan` — the two tools that only exist because a human
|
|
3
|
+
* is attached (plan-mode §4.1 / §4.2 / §5.3).
|
|
4
|
+
*
|
|
5
|
+
* ASCII ONLY: `tools/` is inside the glyph scanner's scope.
|
|
6
|
+
*
|
|
7
|
+
* THE SCHEMAS DECLARE SHAPE, NOT POLICY (P0-4). `type`, `required` and `default`
|
|
8
|
+
* stay, because a `questions` value that is not an array has nothing for a
|
|
9
|
+
* normalizer to repair and both validator strategies agree on that check.
|
|
10
|
+
* Everything else — how many questions, how many options, how long a header may
|
|
11
|
+
* be — lives in the `description`, which is what the model actually reads, and
|
|
12
|
+
* is enforced by `normalizeQuestions` / `normalizePlan`. Putting `maxItems: 5`
|
|
13
|
+
* in the schema instead had two failure modes at once: with `ajv` present
|
|
14
|
+
* `ToolExecutor` rejected a six-question call outright and the repair rules were
|
|
15
|
+
* dead code; without it (ajv is an OPTIONAL dependency of core) the same call
|
|
16
|
+
* sailed straight through to the repair path. One design, two behaviours,
|
|
17
|
+
* selected by whether an optional install step succeeded.
|
|
18
|
+
*/
|
|
19
|
+
import { errorResult, textResult } from '@aragon-agent/core';
|
|
20
|
+
import { formatAnswersResult, normalizePlan, normalizeQuestions, } from './human-input.js';
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Shared refusal text
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
const NO_INTERACTIVE_USER = 'No interactive user is attached (-p/--print mode). Do not ask questions: ' +
|
|
25
|
+
'state your assumptions explicitly and continue.';
|
|
26
|
+
const NO_INTERACTIVE_REVIEWER = 'No interactive reviewer is attached. Emit the full plan as your final answer ' +
|
|
27
|
+
'in markdown instead.';
|
|
28
|
+
function roundCapRefusal(used) {
|
|
29
|
+
return (`You have already asked ${used} rounds of questions (the configured maximum). ` +
|
|
30
|
+
'Proceed with your best assumptions and state them in the plan.');
|
|
31
|
+
}
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// ask_user
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
const ASK_USER_DESCRIPTION = 'Ask the user 1-5 multiple-choice questions when a decision would change the work. ' +
|
|
36
|
+
'Give each question a stable id, a header of at most 12 characters, and 2-4 options; ' +
|
|
37
|
+
'exactly one option must set recommended:true, with the reason in its description. ' +
|
|
38
|
+
'Extra questions or options beyond these limits are dropped, so keep within them. ' +
|
|
39
|
+
'Do not ask anything you could answer by reading the workspace.';
|
|
40
|
+
function makeAskUser(opts) {
|
|
41
|
+
return {
|
|
42
|
+
name: 'ask_user',
|
|
43
|
+
label: 'Ask the user',
|
|
44
|
+
description: ASK_USER_DESCRIPTION,
|
|
45
|
+
parameters: {
|
|
46
|
+
type: 'object',
|
|
47
|
+
properties: {
|
|
48
|
+
questions: {
|
|
49
|
+
type: 'array',
|
|
50
|
+
description: '1-5 questions, asked in one round.',
|
|
51
|
+
items: {
|
|
52
|
+
type: 'object',
|
|
53
|
+
properties: {
|
|
54
|
+
id: { type: 'string', description: 'Stable key echoed back in the answer.' },
|
|
55
|
+
header: {
|
|
56
|
+
type: 'string',
|
|
57
|
+
description: 'Very short chip label (<= 12 chars), e.g. "Datastore".',
|
|
58
|
+
},
|
|
59
|
+
question: { type: 'string', description: '<= 300 chars.' },
|
|
60
|
+
allowMultiple: { type: 'boolean', default: false },
|
|
61
|
+
options: {
|
|
62
|
+
type: 'array',
|
|
63
|
+
description: '2-4 options. Exactly one must be recommended.',
|
|
64
|
+
items: {
|
|
65
|
+
type: 'object',
|
|
66
|
+
properties: {
|
|
67
|
+
label: { type: 'string', description: '<= 60 chars.' },
|
|
68
|
+
description: {
|
|
69
|
+
type: 'string',
|
|
70
|
+
description: '<= 120 chars. Why this option.',
|
|
71
|
+
},
|
|
72
|
+
recommended: { type: 'boolean', default: false },
|
|
73
|
+
},
|
|
74
|
+
required: ['label'],
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
required: ['id', 'header', 'question', 'options'],
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
required: ['questions'],
|
|
83
|
+
},
|
|
84
|
+
async execute(_id, params, ctx) {
|
|
85
|
+
// Probed BEFORE the work, never discovered afterwards: a missing human
|
|
86
|
+
// channel must mean "refuse with an actionable message", not "wait
|
|
87
|
+
// forever" and not "assume yes".
|
|
88
|
+
if (!opts.gate.canPrompt())
|
|
89
|
+
return errorResult(NO_INTERACTIVE_USER);
|
|
90
|
+
const questions = normalizeQuestions(params.questions);
|
|
91
|
+
if (questions.length === 0) {
|
|
92
|
+
return errorResult('No usable questions: each needs 2-4 distinct options.');
|
|
93
|
+
}
|
|
94
|
+
const ticket = opts.takeAskRound();
|
|
95
|
+
if (!ticket.ok)
|
|
96
|
+
return errorResult(roundCapRefusal(ticket.used));
|
|
97
|
+
const response = await opts.withHumanWait(() => opts.gate.request({ kind: 'questions', questions }, ctx.signal));
|
|
98
|
+
// `null` covers cancel, unmount, abort AND timeout. From the model's point
|
|
99
|
+
// of view an unanswered question and an abandoned one are the same
|
|
100
|
+
// situation and want the same guidance, so they share a shape — and it is
|
|
101
|
+
// a NON-error result on purpose: a cancellation rendered as a tool failure
|
|
102
|
+
// invites a retry loop.
|
|
103
|
+
const cancelled = response === null || response.kind !== 'answers' || response.cancelled;
|
|
104
|
+
const answers = response !== null && response.kind === 'answers' ? response.answers : [];
|
|
105
|
+
return textResult(formatAnswersResult({
|
|
106
|
+
answers,
|
|
107
|
+
cancelled,
|
|
108
|
+
roundsUsed: ticket.used,
|
|
109
|
+
roundsRemaining: ticket.remaining,
|
|
110
|
+
}, questions.length));
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// submit_plan
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
const SUBMIT_PLAN_DESCRIPTION = 'Present the implementation plan for approval. Call this exactly once, after you ' +
|
|
118
|
+
'have read enough of the workspace to be specific. Steps must be ordered and ' +
|
|
119
|
+
'independently verifiable. Limits, enforced by truncation rather than rejection: ' +
|
|
120
|
+
'title <= 80 chars, summary <= 600, at most 20 steps (title <= 100, detail <= 400), ' +
|
|
121
|
+
'at most 30 filesTouched, 10 risks, 10 openQuestions.';
|
|
122
|
+
const APPROVED_RESULT = 'Plan approved. You are now in Build mode - implement the plan step by step, in order.';
|
|
123
|
+
const DISMISSED_RESULT = 'The user dismissed the plan review without a verdict. Ask a clarifying question ' +
|
|
124
|
+
'or refine the plan, then call submit_plan again.';
|
|
125
|
+
function makeSubmitPlan(opts) {
|
|
126
|
+
return {
|
|
127
|
+
name: 'submit_plan',
|
|
128
|
+
label: 'Submit plan',
|
|
129
|
+
description: SUBMIT_PLAN_DESCRIPTION,
|
|
130
|
+
parameters: {
|
|
131
|
+
type: 'object',
|
|
132
|
+
properties: {
|
|
133
|
+
title: { type: 'string' },
|
|
134
|
+
summary: { type: 'string' },
|
|
135
|
+
steps: {
|
|
136
|
+
type: 'array',
|
|
137
|
+
items: {
|
|
138
|
+
type: 'object',
|
|
139
|
+
properties: {
|
|
140
|
+
title: { type: 'string' },
|
|
141
|
+
detail: { type: 'string' },
|
|
142
|
+
},
|
|
143
|
+
required: ['title'],
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
filesTouched: { type: 'array', items: { type: 'string' } },
|
|
147
|
+
risks: { type: 'array', items: { type: 'string' } },
|
|
148
|
+
openQuestions: { type: 'array', items: { type: 'string' } },
|
|
149
|
+
},
|
|
150
|
+
required: ['title', 'summary', 'steps'],
|
|
151
|
+
},
|
|
152
|
+
async execute(_id, params, ctx) {
|
|
153
|
+
// Non-error: under `-p` the read-only gate still applies, so "plan this
|
|
154
|
+
// and do not touch my repository" is a legitimate combination and must not
|
|
155
|
+
// read as a malfunction.
|
|
156
|
+
if (!opts.gate.canPrompt())
|
|
157
|
+
return textResult(NO_INTERACTIVE_REVIEWER);
|
|
158
|
+
const plan = normalizePlan(params);
|
|
159
|
+
if (!plan)
|
|
160
|
+
return errorResult('A plan needs at least one step with a title.');
|
|
161
|
+
const response = await opts.withHumanWait(() => opts.gate.request({ kind: 'plan', plan }, ctx.signal));
|
|
162
|
+
if (response === null || response.kind !== 'planDecision') {
|
|
163
|
+
return textResult(DISMISSED_RESULT);
|
|
164
|
+
}
|
|
165
|
+
if (response.decision === 'approved') {
|
|
166
|
+
opts.onPlanApproved();
|
|
167
|
+
return textResult(APPROVED_RESULT);
|
|
168
|
+
}
|
|
169
|
+
if (response.decision === 'revise') {
|
|
170
|
+
return textResult(`Plan rejected. The user's feedback: "${response.feedback}". ` +
|
|
171
|
+
'Revise and call submit_plan again.');
|
|
172
|
+
}
|
|
173
|
+
return textResult(DISMISSED_RESULT);
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
// Registration
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
/**
|
|
181
|
+
* Build the plan tools, or `[]` for a channel that can never reach a human.
|
|
182
|
+
*
|
|
183
|
+
* KEYED ON `neverPrompts`, NOT `canPrompt()` (P0-3). This function runs inside
|
|
184
|
+
* `makeController()`, and in the interactive path the bridge handler is still
|
|
185
|
+
* null there — the App attaches it later, in an effect. So `canPrompt()` is
|
|
186
|
+
* false for EVERY session at this moment, and gating registration on it would
|
|
187
|
+
* ship plan mode with no `ask_user` and no `submit_plan`, in every interactive
|
|
188
|
+
* session, with nothing anywhere reporting it.
|
|
189
|
+
*
|
|
190
|
+
* Registration is a one-time structural question ("can this channel ever reach a
|
|
191
|
+
* human?"). `canPrompt()` answers a live one ("is one attached right now?") and
|
|
192
|
+
* is checked again inside each tool, where it belongs.
|
|
193
|
+
*/
|
|
194
|
+
export function createPlanTools(opts) {
|
|
195
|
+
if (opts.gate.neverPrompts)
|
|
196
|
+
return [];
|
|
197
|
+
return [makeAskUser(opts), makeSubmitPlan(opts)];
|
|
198
|
+
}
|
package/dist/ui/App.d.ts
CHANGED
|
@@ -9,6 +9,7 @@ import type { AgentController } from '../agent/controller.js';
|
|
|
9
9
|
import type { ConfirmRequest } from '../tools/index.js';
|
|
10
10
|
import { type Overlay } from '../agent/reducer.js';
|
|
11
11
|
import { type RenderMode } from './layout/frame.js';
|
|
12
|
+
import type { HumanInputBridge } from '../tools/human-input.js';
|
|
12
13
|
export interface ConfirmBridge {
|
|
13
14
|
handler: ((req: ConfirmRequest) => Promise<boolean>) | null;
|
|
14
15
|
}
|
|
@@ -20,5 +21,7 @@ export interface AppProps {
|
|
|
20
21
|
initialOverlay?: Overlay;
|
|
21
22
|
initialPrompt?: string;
|
|
22
23
|
confirmBridge?: ConfirmBridge;
|
|
24
|
+
/** The plan-mode human channel; absent in tests that do not exercise it. */
|
|
25
|
+
humanInputBridge?: HumanInputBridge;
|
|
23
26
|
}
|
|
24
|
-
export declare function App({ controller, version, mode, initialOverlay, initialPrompt, confirmBridge, }: AppProps): React.ReactElement;
|
|
27
|
+
export declare function App({ controller, version, mode, initialOverlay, initialPrompt, confirmBridge, humanInputBridge, }: AppProps): React.ReactElement;
|
package/dist/ui/App.js
CHANGED
|
@@ -12,7 +12,10 @@ import { initialViewState, reduceEvent, viewReducer, } from '../agent/reducer.js
|
|
|
12
12
|
import { mergeDeltas } from '../agent/coalesce.js';
|
|
13
13
|
import { coerceMaxTokens, DEFAULT_TRANSCRIPT_WINDOW, PROMPT_HISTORY_CAP, } from '../config/schema.js';
|
|
14
14
|
import { updatePersistedConfig } from '../config/store.js';
|
|
15
|
+
import { getLogger } from '../logging/logger.js';
|
|
16
|
+
import { registerSecret } from '../logging/secret-registry.js';
|
|
15
17
|
import { detectCapabilities } from './capabilities.js';
|
|
18
|
+
import { useStartupNotices } from './use-startup-notices.js';
|
|
16
19
|
import { getTheme } from './theme.js';
|
|
17
20
|
import { pickGlyphs } from './glyphs.js';
|
|
18
21
|
import { Header } from './Header.js';
|
|
@@ -35,14 +38,17 @@ import { helpRows } from './overlays/HelpOverlay.js';
|
|
|
35
38
|
import { ModelPicker } from './overlays/ModelPicker.js';
|
|
36
39
|
import { SettingsScreen } from './overlays/SettingsScreen.js';
|
|
37
40
|
import { ConfirmDialog } from './overlays/ConfirmDialog.js';
|
|
41
|
+
import { QuestionOverlay } from './overlays/QuestionOverlay.js';
|
|
42
|
+
import { PlanReviewOverlay } from './overlays/PlanReviewOverlay.js';
|
|
38
43
|
import { CommandRegistry, runSlashInput } from '../commands/registry.js';
|
|
39
44
|
import { registerBuiltinCommands } from '../commands/builtins.js';
|
|
40
45
|
import { makeSkillsCommand, registerSkillCommands } from '../commands/skills.js';
|
|
46
|
+
import { MODE_LABEL, nextMode } from '../agent/agent-mode.js';
|
|
41
47
|
/** Merge pending delta actions ~33 ms and flush the coalescer buffer. */
|
|
42
48
|
const COALESCE_MS = 33;
|
|
43
49
|
/** Rows a PgUp/PgDn moves inside a controlled overlay. */
|
|
44
50
|
const OVERLAY_PAGE = 8;
|
|
45
|
-
export function App({ controller, version, mode, initialOverlay, initialPrompt, confirmBridge, }) {
|
|
51
|
+
export function App({ controller, version, mode, initialOverlay, initialPrompt, confirmBridge, humanInputBridge, }) {
|
|
46
52
|
const { exit } = useApp();
|
|
47
53
|
const { stdout } = useStdout();
|
|
48
54
|
const fullscreen = mode === 'fullscreen';
|
|
@@ -50,6 +56,8 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
|
|
|
50
56
|
const [state, dispatch] = useReducer(viewReducer, undefined, initialViewState);
|
|
51
57
|
const [elapsedMs, setElapsedMs] = useState(0);
|
|
52
58
|
const [confirmState, setConfirmState] = useState(null);
|
|
59
|
+
/** The request the question / plan overlay is currently rendering. */
|
|
60
|
+
const [humanRequest, setHumanRequest] = useState(null);
|
|
53
61
|
const [promptHistory, setPromptHistory] = useState(() => controller.getConfig().promptHistory ?? []);
|
|
54
62
|
// Scroll plumbing: intent goes down, one derived display number comes back.
|
|
55
63
|
const [scrollIntent, setScrollIntent] = useState();
|
|
@@ -87,6 +95,32 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
|
|
|
87
95
|
const ctrlCTimer = useRef(null);
|
|
88
96
|
const runBaselineOut = useRef(0);
|
|
89
97
|
const toastTimers = useRef(new Map());
|
|
98
|
+
/** Every outstanding `gate.request()`. Held in a ref, never in state: it is
|
|
99
|
+
* bookkeeping for promises, and re-rendering on it would be noise. */
|
|
100
|
+
const pendingHuman = useRef(new Set());
|
|
101
|
+
/**
|
|
102
|
+
* THE SINGLE SETTLE PATH for a human request. Idempotent per entry, and it
|
|
103
|
+
* clears the overlay as its last act.
|
|
104
|
+
*
|
|
105
|
+
* Exactly four callers, and there must be no fifth: the overlay's own submit,
|
|
106
|
+
* `Esc` in `App`, the `ctx.signal` abort listener, and `cancelPending()`.
|
|
107
|
+
* Every one of them can race the others, which is what `done` is for.
|
|
108
|
+
*/
|
|
109
|
+
const settleHuman = useCallback((entry, value) => {
|
|
110
|
+
if (entry.done)
|
|
111
|
+
return;
|
|
112
|
+
entry.done = true;
|
|
113
|
+
pendingHuman.current.delete(entry);
|
|
114
|
+
entry.resolve(value);
|
|
115
|
+
if (pendingHuman.current.size === 0) {
|
|
116
|
+
setHumanRequest(null);
|
|
117
|
+
dispatch({ type: 'setOverlay', overlay: null });
|
|
118
|
+
}
|
|
119
|
+
}, []);
|
|
120
|
+
const cancelPendingHuman = useCallback(() => {
|
|
121
|
+
for (const entry of [...pendingHuman.current])
|
|
122
|
+
settleHuman(entry, null);
|
|
123
|
+
}, [settleHuman]);
|
|
90
124
|
const registry = useMemo(() => {
|
|
91
125
|
const r = new CommandRegistry();
|
|
92
126
|
// Order matters: built-ins first, so `registerSkillCommands` sees them when
|
|
@@ -131,12 +165,31 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
|
|
|
131
165
|
dispatch(action);
|
|
132
166
|
}
|
|
133
167
|
}
|
|
168
|
+
if (event.type !== 'agent_end')
|
|
169
|
+
return;
|
|
170
|
+
// NO OVERLAY OUTLIVES ITS RUN (P1-4). `ctx.signal` already covers the
|
|
171
|
+
// abort path, so this is DELIBERATE REDUNDANCY: it makes "the card cannot
|
|
172
|
+
// survive the run that opened it" a property of the App's lifecycle
|
|
173
|
+
// rather than a consequence of signal plumbing three modules away.
|
|
174
|
+
// Without it, an aborted run leaves a plan card on screen whose `a` key
|
|
175
|
+
// silently flips the session to build, with no visible cause.
|
|
176
|
+
cancelPendingHuman();
|
|
177
|
+
// A deferred `plan -> build` becomes real here and nowhere else (§3.2).
|
|
178
|
+
const applied = controller.applyPendingMode();
|
|
179
|
+
if (applied) {
|
|
180
|
+
dispatch({ type: 'setAgentMode', mode: applied.effective, pending: null });
|
|
181
|
+
dispatch({
|
|
182
|
+
type: 'pushToast',
|
|
183
|
+
level: 'info',
|
|
184
|
+
text: `${MODE_LABEL[applied.effective]} mode.`,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
134
187
|
});
|
|
135
188
|
return () => {
|
|
136
189
|
flushPending();
|
|
137
190
|
unsubscribe();
|
|
138
191
|
};
|
|
139
|
-
}, [controller, flushPending]);
|
|
192
|
+
}, [controller, flushPending, cancelPendingHuman]);
|
|
140
193
|
// --- Elapsed timer + tokens/sec baseline while running. ----------------
|
|
141
194
|
useEffect(() => {
|
|
142
195
|
if (state.status !== 'running') {
|
|
@@ -180,6 +233,7 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
|
|
|
180
233
|
return undefined;
|
|
181
234
|
return installConsoleBridge((level, text) => dispatch({ type: 'notice', level, text }));
|
|
182
235
|
}, [fullscreen]);
|
|
236
|
+
useStartupNotices(dispatch);
|
|
183
237
|
// --- Exit snapshot: the only way state reaches cli.tsx (§4.4). ----------
|
|
184
238
|
useEffect(() => {
|
|
185
239
|
publishExitSnapshot({
|
|
@@ -205,6 +259,42 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
|
|
|
205
259
|
confirmBridge.handler = null;
|
|
206
260
|
};
|
|
207
261
|
}, [confirmBridge]);
|
|
262
|
+
// --- Human-input bridge (ask_user / submit_plan). ----------------------
|
|
263
|
+
useEffect(() => {
|
|
264
|
+
if (!humanInputBridge)
|
|
265
|
+
return undefined;
|
|
266
|
+
const bridge = humanInputBridge;
|
|
267
|
+
bridge.handler = (req, signal) => new Promise((resolve) => {
|
|
268
|
+
const entry = { resolve, done: false };
|
|
269
|
+
pendingHuman.current.add(entry);
|
|
270
|
+
// THE ONLY CEILING THIS WAIT HAS. `ToolExecutor`'s timeout is
|
|
271
|
+
// cooperative — it aborts the context signal and keeps awaiting the
|
|
272
|
+
// tool — and the idle watchdog is deliberately paused for the duration.
|
|
273
|
+
// Without this listener both ceilings are gone at once and the process
|
|
274
|
+
// wedges behind an overlay nobody is going to answer (P0-2).
|
|
275
|
+
signal?.addEventListener('abort', () => settleHuman(entry, null), { once: true });
|
|
276
|
+
setHumanRequest(req);
|
|
277
|
+
dispatch({
|
|
278
|
+
type: 'setOverlay',
|
|
279
|
+
overlay: req.kind === 'plan' ? 'plan' : 'question',
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
bridge.cancelPending = cancelPendingHuman;
|
|
283
|
+
return () => {
|
|
284
|
+
// BEFORE nulling the handler, not after: `ConfirmBridge` nulls its handler
|
|
285
|
+
// and leaves in-flight promises dangling forever, which with a wizard the
|
|
286
|
+
// user abandons would hang the tool until its ceiling (R-P4).
|
|
287
|
+
cancelPendingHuman();
|
|
288
|
+
bridge.handler = null;
|
|
289
|
+
};
|
|
290
|
+
}, [humanInputBridge, settleHuman, cancelPendingHuman]);
|
|
291
|
+
// --- Seed the mode mirror from its owner, once, on mount. --------------
|
|
292
|
+
// `--plan` / `planModeDefault` are resolved before the App exists, so the
|
|
293
|
+
// reducer's `'build'` seed would render one wrong frame without this.
|
|
294
|
+
useEffect(() => {
|
|
295
|
+
const current = controller.getAgentMode();
|
|
296
|
+
dispatch({ type: 'setAgentMode', mode: current, pending: null });
|
|
297
|
+
}, [controller]);
|
|
208
298
|
// --- Let the controller drive command-list rebuilds. -------------------
|
|
209
299
|
useEffect(() => {
|
|
210
300
|
controller.setOnSkillsChanged(() => setSkillsNonce((n) => n + 1));
|
|
@@ -248,15 +338,44 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
|
|
|
248
338
|
const persistConfig = (patch) => {
|
|
249
339
|
try {
|
|
250
340
|
updatePersistedConfig(patch);
|
|
341
|
+
getLogger().info('config', 'config_write', { keys: Object.keys(patch) });
|
|
251
342
|
}
|
|
252
|
-
catch {
|
|
253
|
-
//
|
|
343
|
+
catch (err) {
|
|
344
|
+
// Still best-effort — a failed persist must not break the live session —
|
|
345
|
+
// but no longer SILENT. Swallowing this is the root of "I changed the
|
|
346
|
+
// model in the TUI and it was back to the old one after a restart", a
|
|
347
|
+
// report with nothing anywhere to investigate.
|
|
348
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
349
|
+
getLogger().error('config', 'config_write_failed', { reason });
|
|
350
|
+
toast('warn', `Could not save settings: ${reason}`);
|
|
254
351
|
}
|
|
255
352
|
};
|
|
256
353
|
const doExit = () => {
|
|
257
354
|
controller.abort();
|
|
258
355
|
exit();
|
|
259
356
|
};
|
|
357
|
+
/**
|
|
358
|
+
* THE SINGLE MODE WRITE PATH (§3.1 / R-P7).
|
|
359
|
+
*
|
|
360
|
+
* `Shift+Tab` and `/plan` both come through here, which is what makes them
|
|
361
|
+
* the same code path rather than two that can drift. Note that the value
|
|
362
|
+
* dispatched into the view is what the controller reports it ADOPTED, never
|
|
363
|
+
* what was requested: dispatching the request is the one way to make the badge
|
|
364
|
+
* claim a permission the gate will not honour.
|
|
365
|
+
*/
|
|
366
|
+
const applyMode = (next, opts = {}) => {
|
|
367
|
+
const applied = controller.setAgentMode(next, opts.force ? { force: true } : {});
|
|
368
|
+
dispatch({ type: 'setAgentMode', mode: applied.effective, pending: applied.pending });
|
|
369
|
+
if (opts.silent)
|
|
370
|
+
return applied;
|
|
371
|
+
if (applied.pending) {
|
|
372
|
+
toast('info', `${MODE_LABEL[applied.pending]} mode applies after this run.`);
|
|
373
|
+
}
|
|
374
|
+
else {
|
|
375
|
+
toast('info', `${MODE_LABEL[applied.effective]} mode.`);
|
|
376
|
+
}
|
|
377
|
+
return applied;
|
|
378
|
+
};
|
|
260
379
|
const makeCtx = (args) => ({
|
|
261
380
|
args,
|
|
262
381
|
controller,
|
|
@@ -269,6 +388,7 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
|
|
|
269
388
|
exit: doExit,
|
|
270
389
|
submit: (text) => submitMessage(text),
|
|
271
390
|
refreshSkills: () => setSkillsNonce((n) => n + 1),
|
|
391
|
+
applyAgentMode: applyMode,
|
|
272
392
|
});
|
|
273
393
|
const recordPrompt = (text) => {
|
|
274
394
|
const next = [...promptHistory.filter((p) => p !== text), text].slice(-PROMPT_HISTORY_CAP);
|
|
@@ -322,8 +442,14 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
|
|
|
322
442
|
controller.setThinkingLevel(values.thinkingLevel);
|
|
323
443
|
controller.setMaxTokens(maxTokens);
|
|
324
444
|
const key = values.apiKey.trim();
|
|
325
|
-
if (key.length > 0)
|
|
445
|
+
if (key.length > 0) {
|
|
446
|
+
// Registration site 4 (§4.4.3), and the one that matters most: this is how
|
|
447
|
+
// a key most often enters the process, and it happens long after
|
|
448
|
+
// `installLogging()` ran. A startup snapshot would miss it entirely, and
|
|
449
|
+
// the vendor regexes only cover keys that look like a vendor's.
|
|
450
|
+
registerSecret(key);
|
|
326
451
|
controller.setApiKey(values.provider, key);
|
|
452
|
+
}
|
|
327
453
|
const patch = {
|
|
328
454
|
provider: values.provider,
|
|
329
455
|
model: values.model,
|
|
@@ -333,7 +459,12 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
|
|
|
333
459
|
};
|
|
334
460
|
if (key.length > 0)
|
|
335
461
|
patch.apiKeys = { [values.provider]: key };
|
|
462
|
+
// A PARTIAL `log` section, safe only because `updatePersistedConfig` deep-
|
|
463
|
+
// merges it — a shallow merge here would reset `redactSecrets` every time
|
|
464
|
+
// someone touched the level.
|
|
465
|
+
patch.log = { level: values.logLevel };
|
|
336
466
|
persistConfig(patch);
|
|
467
|
+
getLogger().reconfigure({ ...controller.getConfig().log, level: values.logLevel });
|
|
337
468
|
dispatch({ type: 'setOverlay', overlay: null });
|
|
338
469
|
toast('success', 'Settings saved.');
|
|
339
470
|
};
|
|
@@ -347,6 +478,28 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
|
|
|
347
478
|
setConfirmState(null);
|
|
348
479
|
dispatch({ type: 'setOverlay', overlay: null });
|
|
349
480
|
};
|
|
481
|
+
/** Answer every outstanding request with the same response and close up. */
|
|
482
|
+
const resolveHuman = (response) => {
|
|
483
|
+
for (const entry of [...pendingHuman.current])
|
|
484
|
+
settleHuman(entry, response);
|
|
485
|
+
};
|
|
486
|
+
const handlePlanVerdict = (verdict) => {
|
|
487
|
+
if (verdict.decision === 'approved') {
|
|
488
|
+
// BEFORE resolving, not after. `submit_plan` also calls
|
|
489
|
+
// `setAgentMode('build', { force: true })` when its wait returns — that is
|
|
490
|
+
// the authoritative act, and it stays because it must work even if the App
|
|
491
|
+
// is gone by then. But it runs a microtask later, so reading the mode back
|
|
492
|
+
// here would read `'plan'` and the badge would lag by a frame. Writing it
|
|
493
|
+
// first through the same single path makes the tool's call an idempotent
|
|
494
|
+
// no-op and keeps the mirror exact (§3.1 / R-P7).
|
|
495
|
+
applyMode('build', { force: true, silent: true });
|
|
496
|
+
toast('success', 'Plan approved. Implementing.');
|
|
497
|
+
}
|
|
498
|
+
else {
|
|
499
|
+
toast('info', 'Revision requested.');
|
|
500
|
+
}
|
|
501
|
+
resolveHuman({ kind: 'planDecision', decision: verdict.decision, feedback: verdict.feedback });
|
|
502
|
+
};
|
|
350
503
|
// A fresh overlay always opens at the top; carrying the previous overlay's
|
|
351
504
|
// offset over would open Help mid-list for no reason the user can see.
|
|
352
505
|
useEffect(() => {
|
|
@@ -390,7 +543,7 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
|
|
|
390
543
|
const overlay = stateRef.current.overlay;
|
|
391
544
|
// Modes A only. `model` / `confirm` manage their own keys; registering an
|
|
392
545
|
// arrow handler for them would double every keypress (R-12).
|
|
393
|
-
const controlled = overlay === 'help' || overlay === 'settings';
|
|
546
|
+
const controlled = overlay === 'help' || overlay === 'settings' || overlay === 'plan';
|
|
394
547
|
if (key.pageUp) {
|
|
395
548
|
if (controlled)
|
|
396
549
|
setOverlayScroll((n) => Math.max(0, n - OVERLAY_PAGE));
|
|
@@ -402,12 +555,23 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
|
|
|
402
555
|
return;
|
|
403
556
|
}
|
|
404
557
|
// `settings` keeps Up/Down for field movement — a high-frequency action
|
|
405
|
-
// worth more than scrolling six fields (§4.9).
|
|
406
|
-
|
|
558
|
+
// worth more than scrolling six fields (§4.9). `question` keeps them for
|
|
559
|
+
// its option cursor, for the same reason; `plan` has no cursor of its own
|
|
560
|
+
// and scrolls a line at a time.
|
|
561
|
+
if ((overlay === 'help' || overlay === 'plan') && (key.upArrow || key.downArrow)) {
|
|
407
562
|
setOverlayScroll((n) => Math.max(0, n + (key.upArrow ? -1 : 1)));
|
|
408
563
|
return;
|
|
409
564
|
}
|
|
410
565
|
}
|
|
566
|
+
// AFTER the overlay block, and with an EXPLICIT guard rather than relying on
|
|
567
|
+
// ordering: that block only `return`s for the keys it recognizes, and
|
|
568
|
+
// everything else falls through (AC-P2).
|
|
569
|
+
if (key.tab && key.shift) {
|
|
570
|
+
if (stateRef.current.overlay)
|
|
571
|
+
return;
|
|
572
|
+
applyMode(nextMode(controller.getAgentMode()));
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
411
575
|
if (key.ctrl && (input === 'l' || input === 'L')) {
|
|
412
576
|
if (fullscreen) {
|
|
413
577
|
// Writing \x1B[2J here would erase the screen and then leave it blank:
|
|
@@ -462,6 +626,12 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
|
|
|
462
626
|
confirmRef.current.resolve(false);
|
|
463
627
|
setConfirmState(null);
|
|
464
628
|
}
|
|
629
|
+
// `App` owns Esc for both plan-mode overlays and they register no
|
|
630
|
+
// handler of their own, so this is the only place a cancellation
|
|
631
|
+
// reaches the waiting tool.
|
|
632
|
+
if (overlay === 'question' || overlay === 'plan')
|
|
633
|
+
cancelPendingHuman();
|
|
634
|
+
setHumanRequest(null);
|
|
465
635
|
dispatch({ type: 'setOverlay', overlay: null });
|
|
466
636
|
return;
|
|
467
637
|
}
|
|
@@ -494,7 +664,8 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
|
|
|
494
664
|
thinkingLevel: cfg.thinkingLevel,
|
|
495
665
|
maxTokens: cfg.maxTokens ? String(cfg.maxTokens) : '',
|
|
496
666
|
apiKey: '',
|
|
497
|
-
|
|
667
|
+
logLevel: cfg.log.level,
|
|
668
|
+
}, apiKeys: cfg.apiKeys, maxRows: overlayMaxRows, cols: cols, scrollOffset: overlayScroll, onScrollClamp: setOverlayScroll, theme: theme, caps: caps, onSave: handleSettingsSave })) : overlay === 'confirm' && confirmState ? (_jsx(ConfirmDialog, { state: confirmState, maxRows: overlayMaxRows, cols: cols, theme: theme, caps: caps, onClose: closeConfirm })) : overlay === 'question' && humanRequest?.kind === 'questions' ? (_jsx(QuestionOverlay, { questions: humanRequest.questions, maxRows: overlayMaxRows, cols: cols, theme: theme, caps: caps, onSubmit: (answers) => resolveHuman({ kind: 'answers', answers, cancelled: false }) })) : overlay === 'plan' && humanRequest?.kind === 'plan' ? (_jsx(PlanReviewOverlay, { plan: humanRequest.plan, maxRows: overlayMaxRows, cols: cols, scrollOffset: overlayScroll, onScrollClamp: setOverlayScroll, theme: theme, caps: caps, onVerdict: handlePlanVerdict })) : null;
|
|
498
669
|
// A resize can drop below the usable floor. Do NOT leave the alt-screen —
|
|
499
670
|
// entering and leaving it on every drag is worse than a placeholder — and take
|
|
500
671
|
// the height from `frameHeight()` like everything else, because this is the
|
|
@@ -517,6 +688,6 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
|
|
|
517
688
|
// rows; unbounded, it pushes the status bar past the frame height where
|
|
518
689
|
// `overflow: hidden` clips it away (R-14 / M-14). Cap it against the viewport.
|
|
519
690
|
const popupMaxRows = fullscreen ? Math.max(1, viewportBudget - 4) : undefined;
|
|
520
|
-
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, 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(), theme: theme, caps: caps, onSubmit: (text) => void handleSubmit(text), onHelp: () => dispatch({ type: 'setOverlay', overlay: 'help' }) }) }));
|
|
521
|
-
return (_jsx(AppShell, { mode: mode, rows: rows, cols: cols, header: header, viewport: viewport, toast: _jsx(ToastStack, { toasts: state.toasts, theme: theme, mode: mode }), composer: composer, status: _jsx(StatusBar, { model: cfg.model, provider: cfg.provider, usageTotal: state.usageTotal, contextTokens: state.contextTokens, contextWindow: modelInfo.contextWindow, contextWindowKnown: modelKnown, status: state.status, elapsedMs: elapsedMs, thinkingLevel: cfg.thinkingLevel, tokPerSec: tokPerSec, theme: theme, caps: caps, scrolledLines: scrolledLines, redrawNonce: redrawNonce }) }));
|
|
691
|
+
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' }) }) }));
|
|
692
|
+
return (_jsx(AppShell, { mode: mode, rows: rows, cols: cols, header: header, viewport: viewport, toast: _jsx(ToastStack, { toasts: state.toasts, theme: theme, mode: mode }), composer: composer, status: _jsx(StatusBar, { model: cfg.model, provider: cfg.provider, usageTotal: state.usageTotal, contextTokens: state.contextTokens, contextWindow: modelInfo.contextWindow, contextWindowKnown: modelKnown, status: state.status, elapsedMs: elapsedMs, thinkingLevel: cfg.thinkingLevel, tokPerSec: tokPerSec, theme: theme, caps: caps, scrolledLines: scrolledLines, redrawNonce: redrawNonce, agentMode: state.agentMode, pendingAgentMode: state.pendingAgentMode }) }));
|
|
522
693
|
}
|
package/dist/ui/Composer.d.ts
CHANGED
|
@@ -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,12 @@ 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;
|
|
35
38
|
theme: Theme;
|
|
36
39
|
caps: TermCapabilities;
|
|
37
40
|
onSubmit: (text: string) => void;
|
|
38
41
|
onHelp?: () => void;
|
|
39
42
|
}
|
|
40
|
-
export declare function Composer({ isActive, running, history, commands, cwd, showHint, submitCount, hintsEnabled, popupMaxRows, theme, caps, onSubmit, onHelp, }: ComposerProps): React.ReactElement;
|
|
43
|
+
export declare function Composer({ isActive, running, history, commands, cwd, showHint, submitCount, hintsEnabled, agentMode, popupMaxRows, theme, caps, onSubmit, onHelp, }: ComposerProps): React.ReactElement;
|
package/dist/ui/Composer.js
CHANGED
|
@@ -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, 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
|
-
:
|
|
56
|
-
? theme.
|
|
57
|
-
:
|
|
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 && (
|
|
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
|
+
// 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
|
}
|