codemax-cli 0.1.3 → 0.1.5
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/README.md +5 -5
- package/package.json +1 -1
- package/src/adapters/detect.ts +38 -9
- package/src/adapters/index.ts +15 -9
- package/src/adapters/simple.ts +7 -6
- package/src/adapters/types.ts +3 -1
- package/src/chat/app.ts +459 -138
- package/src/chat/models.ts +58 -0
- package/src/chat/notes.ts +101 -0
- package/src/chat/palette.ts +28 -7
- package/src/chat/question.ts +192 -0
- package/src/chat/subagents.ts +107 -0
- package/src/chat/theme.ts +3 -1
- package/src/chat/transcript.ts +5 -2
- package/src/cli.ts +8 -6
- package/src/config.ts +17 -4
- package/src/core/brief.ts +2 -1
- package/src/core/quota.ts +2 -1
- package/src/core/termstatus.ts +124 -0
- package/src/core/time.ts +9 -0
- package/src/core/usage.ts +4 -2
- package/src/engines/acp.ts +133 -11
- package/src/engines/antigravity.ts +95 -0
- package/src/engines/codex.ts +122 -32
- package/src/engines/elicitation.ts +60 -0
- package/src/engines/index.ts +6 -3
- package/src/engines/types.ts +56 -0
- package/src/tui/app.ts +13 -4
package/src/chat/app.ts
CHANGED
|
@@ -10,11 +10,12 @@ import { diffStat } from "../core/git.ts";
|
|
|
10
10
|
import { isExhausted, quotaLabel } from "../core/quota.ts";
|
|
11
11
|
import { findHotkey, parseHotkeys, type HotkeySpec } from "../core/hotkey.ts";
|
|
12
12
|
import { createEngine, hasEngine, NO_PERMISSION_PROMPTS, engineFactories, grokPermissionNote } from "../engines/index.ts";
|
|
13
|
-
import type { Engine, EngineEvent, PermissionRequest } from "../engines/types.ts";
|
|
13
|
+
import type { Engine, EngineEvent, PermissionRequest, QuestionRequest } from "../engines/types.ts";
|
|
14
14
|
import { strippedKeys } from "../engines/env.ts";
|
|
15
15
|
import { blockers } from "../core/doctor.ts";
|
|
16
16
|
import { Transcript, type AgentBlock } from "./transcript.ts";
|
|
17
17
|
import { PermissionDialog } from "./permission.ts";
|
|
18
|
+
import { QuestionDialog } from "./question.ts";
|
|
18
19
|
import { Palette, type PaletteItem } from "./palette.ts";
|
|
19
20
|
import { C, DONE_VERBS, G, TIPS, VERBS, applyTheme, fmtDuration, fmtTime, modeLabel, pick } from "./theme.ts";
|
|
20
21
|
import { priceRecord, recordCost, renderUsageTable, sumCost } from "../core/usage.ts";
|
|
@@ -31,18 +32,29 @@ import { Awake, defaultAwakeDeps, recoverAwake } from "../core/awake.ts";
|
|
|
31
32
|
import { buildReviewPrompt, forwardMessage, isForwardedNote, parseNote } from "../core/review.ts";
|
|
32
33
|
import { logoLines } from "./logo.ts";
|
|
33
34
|
import { checkForUpdate, updatePlan } from "../core/update.ts";
|
|
35
|
+
import { displayModelName, sortModels } from "./models.ts";
|
|
36
|
+
import { fmtDateTime12 } from "../core/time.ts";
|
|
37
|
+
import { TerminalStatus } from "../core/termstatus.ts";
|
|
38
|
+
import { NotePanel, splitNote } from "./notes.ts";
|
|
39
|
+
import { SubagentPanel } from "./subagents.ts";
|
|
40
|
+
import { antigravityInstallPlan, installAntigravityServer, type InstallPlan } from "../engines/antigravity.ts";
|
|
34
41
|
|
|
35
42
|
const HOTKEY_NAMES: Record<string, string[]> = { "/": ["/", "_", "?"], "^": ["^", "6"] };
|
|
36
43
|
const VERSION = pkg.version;
|
|
37
44
|
const MODE_CYCLE = ["default", "acceptEdits", "plan"];
|
|
38
|
-
/**
|
|
45
|
+
/** How long a lone 0 / 1 / 2 must sit in the prompt before it acts on the side agent's note. */
|
|
46
|
+
const NOTE_ARM_MS = 800;
|
|
47
|
+
/** A question left unanswered this long is skipped when `questions` is "timeout". */
|
|
48
|
+
const QUESTION_TIMEOUT_MS = 5 * 60_000;
|
|
49
|
+
/** Most side-agent notes sent automatically per task; more wait above the prompt. */
|
|
39
50
|
const AUTO_FORWARD_CAP = 2;
|
|
40
51
|
|
|
41
52
|
interface Active {
|
|
42
53
|
agent: AgentId;
|
|
43
54
|
name: string;
|
|
44
55
|
engine: Engine;
|
|
45
|
-
|
|
56
|
+
/** `model`: the model the agent reported for this turn (usage), when it does. */
|
|
57
|
+
turn: { block: AgentBlock; tools: Set<string>; startedAt: number; tokens: number; chars: number; verb: string; rec?: TurnRecorder; model?: string } | null;
|
|
46
58
|
replay: AgentBlock | null;
|
|
47
59
|
unsubscribe: () => void;
|
|
48
60
|
mode?: string;
|
|
@@ -64,9 +76,11 @@ interface Builtin {
|
|
|
64
76
|
export class ChatApp {
|
|
65
77
|
private active: Active | null = null;
|
|
66
78
|
private palette: Palette | null = null;
|
|
67
|
-
|
|
68
|
-
private
|
|
69
|
-
private
|
|
79
|
+
/** The open dialog (a permission prompt or an agent's question); one at a time, the rest queue. */
|
|
80
|
+
private permission: PermissionDialog | QuestionDialog | null = null;
|
|
81
|
+
private permissionQueue: ({ request: PermissionRequest; engine: Engine } | { question: QuestionRequest; engine: Engine })[] = [];
|
|
82
|
+
/** `engine` implies `installed`: every check on `engine` also means the CLI really runs. */
|
|
83
|
+
private detect = new Map<AgentId, { installed: boolean; version?: string; engine: boolean; problem?: string }>();
|
|
70
84
|
private quota = new Map<AgentId, QuotaStatus>();
|
|
71
85
|
private hotkeys: HotkeySpec[];
|
|
72
86
|
private transcript: Transcript;
|
|
@@ -74,6 +88,7 @@ export class ChatApp {
|
|
|
74
88
|
private input: TextareaRenderable;
|
|
75
89
|
private inputWrap: BoxRenderable;
|
|
76
90
|
private dialogHost: BoxRenderable;
|
|
91
|
+
private queueBox!: BoxRenderable;
|
|
77
92
|
private footerText: TextRenderable;
|
|
78
93
|
private shortcuts: BoxRenderable;
|
|
79
94
|
private flash = "";
|
|
@@ -90,15 +105,23 @@ export class ChatApp {
|
|
|
90
105
|
private side = new Set<Active>();
|
|
91
106
|
private sessionTokens = 0;
|
|
92
107
|
private historyIdx = -1;
|
|
108
|
+
/** A queued message pulled back into the prompt with ↑: where it sat, and its text before editing. */
|
|
109
|
+
private queueEdit: { index: number; original: string } | null = null;
|
|
93
110
|
private draft = "";
|
|
94
111
|
private queue: string[] = [];
|
|
95
112
|
private lastCtrlC = 0;
|
|
96
113
|
private switching = false;
|
|
97
114
|
/** Set by quit(); async work that resumes afterwards (a handoff being built) stops. */
|
|
98
115
|
private closed = false;
|
|
99
|
-
/** Side agent (/
|
|
116
|
+
/** Side agent (set up in /config): reviews the main agent's work after each turn. */
|
|
100
117
|
private reviewer: { agent: AgentId; name: string; engine: Engine; busy: boolean; pending: boolean; since: number; primed: boolean } | null = null;
|
|
101
118
|
private notes: { ts: number; agent: AgentId; name: string; text: string }[] = [];
|
|
119
|
+
private notePanel: NotePanel;
|
|
120
|
+
private subagents: SubagentPanel;
|
|
121
|
+
private samePlanNoted = false;
|
|
122
|
+
private noteArmTimer: ReturnType<typeof setTimeout> | null = null;
|
|
123
|
+
/** Plan limits each agent reported, by window ("five_hour", "seven_day"…). */
|
|
124
|
+
private planLimits = new Map<AgentId, Map<string, { usedPercent?: number; resetsAt?: number; status?: string; at: number }>>();
|
|
102
125
|
/** Working turns since the side agent last reviewed (for `sideAgent.every`). */
|
|
103
126
|
private turnsSinceReview = 0;
|
|
104
127
|
/** Notes sent automatically per task (auto mode is capped so the two agents cannot loop). */
|
|
@@ -109,6 +132,8 @@ export class ChatApp {
|
|
|
109
132
|
updateChecker: (current: string) => Promise<string | null> = (current) => (process.env.NODE_ENV === "test" ? Promise.resolve(null) : checkForUpdate(current));
|
|
110
133
|
/** Keep-awake (/awake). Tests swap its system runner. */
|
|
111
134
|
awake = new Awake({ ...defaultAwakeDeps(), interactive: (fn) => this.withTerminal(fn) });
|
|
135
|
+
/** Terminal tab status (Warp badge, OSC 7501) and desktop notifications; swapped in tests. */
|
|
136
|
+
term: TerminalStatus;
|
|
112
137
|
private resolveDone!: () => void;
|
|
113
138
|
readonly done: Promise<void>;
|
|
114
139
|
/** Test hooks. */
|
|
@@ -135,6 +160,13 @@ export class ChatApp {
|
|
|
135
160
|
root.add(this.working);
|
|
136
161
|
this.dialogHost = new BoxRenderable(renderer, { id: "dialog-host", width: "100%", flexShrink: 0, flexDirection: "column" });
|
|
137
162
|
root.add(this.dialogHost);
|
|
163
|
+
// Side-agent notes wait here, above the queue, until you expand, discuss or dismiss them.
|
|
164
|
+
this.notePanel = new NotePanel(renderer, () => this.active?.name ?? "the main agent");
|
|
165
|
+
root.add(this.notePanel.box);
|
|
166
|
+
// Queued messages wait here, pinned just above the prompt, until they are sent.
|
|
167
|
+
this.queueBox = new BoxRenderable(renderer, { id: "queue", width: "100%", flexShrink: 0, flexDirection: "column", paddingX: 1 });
|
|
168
|
+
this.queueBox.visible = false;
|
|
169
|
+
root.add(this.queueBox);
|
|
138
170
|
this.inputWrap = new BoxRenderable(renderer, { id: "input-wrap", width: "100%", flexShrink: 0, flexDirection: "column", border: ["top", "bottom"], borderStyle: "single", borderColor: C.accent });
|
|
139
171
|
const inputRow = new BoxRenderable(renderer, { id: "input-row", width: "100%", flexDirection: "row" });
|
|
140
172
|
inputRow.add(new TextRenderable(renderer, { id: "input-prompt", content: `${G.prompt} `, width: 2, fg: C.accent }));
|
|
@@ -155,11 +187,14 @@ export class ChatApp {
|
|
|
155
187
|
inputRow.add(this.input);
|
|
156
188
|
this.inputWrap.add(inputRow);
|
|
157
189
|
root.add(this.inputWrap);
|
|
190
|
+
// Subagents the main agent spawned, under the prompt like Claude Code's agent list.
|
|
191
|
+
this.subagents = new SubagentPanel(renderer);
|
|
192
|
+
root.add(this.subagents.box);
|
|
158
193
|
this.shortcuts = new BoxRenderable(renderer, { id: "shortcuts", width: "100%", flexShrink: 0, flexDirection: "column", paddingX: 2 });
|
|
159
194
|
this.shortcuts.visible = false;
|
|
160
195
|
for (const l of [
|
|
161
196
|
"/ for commands shift + tab to cycle permission mode esc to interrupt a turn",
|
|
162
|
-
"/switch to change agent ↑ for history
|
|
197
|
+
"/switch to change agent ↑ for history (or to edit the queue) ctrl + c twice to quit",
|
|
163
198
|
"/mode to set permissions shift + ⏎ for newline ? to toggle this help",
|
|
164
199
|
"! to run a shell command /model to pick a model /compact for a handoff summary",
|
|
165
200
|
"@ to attach a file /undo reverts the last turn's edits /ask <agent> <msg> for a one-off",
|
|
@@ -179,8 +214,15 @@ export class ChatApp {
|
|
|
179
214
|
void this.copyText(text).then((ok) => this.setFlash(ok ? `copied ${text.length} chars` : "could not copy: no clipboard available"));
|
|
180
215
|
});
|
|
181
216
|
|
|
217
|
+
this.term = new TerminalStatus(
|
|
218
|
+
{ status: (st) => void this.renderer.setProgramStatus(st as any), notify: (m, t) => void this.renderer.triggerNotification(m, t) },
|
|
219
|
+
{ cwd, enabled: () => this.config.terminalStatus !== false, notify: () => this.config.notifications !== false },
|
|
220
|
+
);
|
|
221
|
+
renderer.on("focus", () => (this.term.focused = true));
|
|
222
|
+
renderer.on("blur", () => (this.term.focused = false));
|
|
182
223
|
renderer.keyInput.on("keypress", this.onKey);
|
|
183
224
|
renderer.on("resize", () => this.safely("resize", () => {
|
|
225
|
+
this.fitInput();
|
|
184
226
|
this.palette?.render();
|
|
185
227
|
this.renderFooter();
|
|
186
228
|
}));
|
|
@@ -188,7 +230,8 @@ export class ChatApp {
|
|
|
188
230
|
this.renderFooter();
|
|
189
231
|
}
|
|
190
232
|
|
|
191
|
-
|
|
233
|
+
/** `explicit`: the user named this agent (--agent), so never start a different one. */
|
|
234
|
+
async start(agent: AgentId, fresh: boolean, o: { pick?: boolean; explicit?: boolean } = {}) {
|
|
192
235
|
try {
|
|
193
236
|
this.today = spendToday();
|
|
194
237
|
} catch {
|
|
@@ -196,8 +239,9 @@ export class ChatApp {
|
|
|
196
239
|
}
|
|
197
240
|
await this.refreshDetect();
|
|
198
241
|
const ok = (a: AgentId) => this.detect.get(a)?.installed && this.detect.get(a)?.engine;
|
|
242
|
+
if (!ok(agent) && o.explicit) throw new Error(`${agent} is ${this.detect.get(agent)?.installed ? "installed but has no protocol engine (try codemax --tui --agent " + agent + ")" : "not installed"}. Available: ${listAdapters(this.config.order).filter((a) => ok(a.id)).map((a) => a.id).join(", ") || "none"}`);
|
|
199
243
|
const first = ok(agent) ? agent : listAdapters(this.config.order).find((a) => ok(a.id))?.id;
|
|
200
|
-
if (!first) throw new Error("no agent with a protocol engine found (need claude, codex, opencode, grok or
|
|
244
|
+
if (!first) throw new Error("no agent with a protocol engine found (need claude, codex, opencode, cursor-agent, grok or agy on PATH)");
|
|
201
245
|
const notes: string[] = [];
|
|
202
246
|
if (first !== agent) notes.push(`${agent} has no protocol engine here; starting ${first}`);
|
|
203
247
|
for (const n of await recoverAwake({ ...defaultAwakeDeps(), interactive: (fn) => this.withTerminal(fn) }).catch(() => [])) notes.push(n);
|
|
@@ -267,6 +311,17 @@ export class ChatApp {
|
|
|
267
311
|
void this.cycleMode();
|
|
268
312
|
return;
|
|
269
313
|
}
|
|
314
|
+
if (key.name === "escape" && this.queueEdit) {
|
|
315
|
+
// Cancel the edit: the queued message goes back exactly as it was.
|
|
316
|
+
key.stopPropagation();
|
|
317
|
+
const { index, original } = this.queueEdit;
|
|
318
|
+
this.queueEdit = null;
|
|
319
|
+
this.queue.splice(index, 0, original);
|
|
320
|
+
this.input.clear();
|
|
321
|
+
this.renderQueue();
|
|
322
|
+
this.renderFooter();
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
270
325
|
if (key.name === "escape") {
|
|
271
326
|
if (this.active?.turn) {
|
|
272
327
|
key.stopPropagation();
|
|
@@ -300,6 +355,11 @@ export class ChatApp {
|
|
|
300
355
|
this.lastCtrlC = now;
|
|
301
356
|
return;
|
|
302
357
|
}
|
|
358
|
+
if (key.name === "up" && !this.palette && this.historyIdx < 0 && (this.input.plainText === "" || this.queueEdit) && (this.queue.length || this.queueEdit)) {
|
|
359
|
+
key.stopPropagation();
|
|
360
|
+
this.editQueued();
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
303
363
|
if (key.name === "up" && !this.input.plainText.includes("\n") && (this.history.length || this.draft)) {
|
|
304
364
|
if (this.input.plainText === "" || this.historyIdx >= 0) {
|
|
305
365
|
key.stopPropagation();
|
|
@@ -351,8 +411,26 @@ export class ChatApp {
|
|
|
351
411
|
}
|
|
352
412
|
private lastDraft = "";
|
|
353
413
|
|
|
414
|
+
/**
|
|
415
|
+
* The prompt grows with what you type (wrapped lines and newlines), up to 10
|
|
416
|
+
* lines or a third of the screen, and scrolls after that. A fixed one-line box
|
|
417
|
+
* made a full line look like a fresh, empty prompt.
|
|
418
|
+
*/
|
|
419
|
+
private fitInput() {
|
|
420
|
+
if (this.closed) return;
|
|
421
|
+
const max = Math.max(1, Math.min(10, Math.floor(this.renderer.height / 3)));
|
|
422
|
+
// Counted from the text: the textarea's own virtual line count lags behind typing.
|
|
423
|
+
const width = Math.max(1, this.renderer.width - 2);
|
|
424
|
+
const lines = Math.max(1, Math.min(max, wrappedLines(this.input.plainText, width)));
|
|
425
|
+
if (this.input.height !== lines) this.input.height = lines;
|
|
426
|
+
}
|
|
427
|
+
|
|
354
428
|
private onInputChanged() {
|
|
429
|
+
this.fitInput();
|
|
430
|
+
// Wrapping is measured at layout: check again once the frame has the new text.
|
|
431
|
+
setTimeout(() => this.safely("input size", () => this.fitInput()), 0);
|
|
355
432
|
const text = this.input.plainText;
|
|
433
|
+
this.armNote(text);
|
|
356
434
|
if (this.historyIdx >= 0 && text !== this.history[this.historyIdx]) this.historyIdx = -1;
|
|
357
435
|
const mention = text.match(MENTION_RE);
|
|
358
436
|
if (mention && !text.startsWith("/")) {
|
|
@@ -376,7 +454,19 @@ export class ChatApp {
|
|
|
376
454
|
|
|
377
455
|
private async submit() {
|
|
378
456
|
const text = this.input.plainText.trim();
|
|
457
|
+
if (!text && this.queueEdit) {
|
|
458
|
+
const { original } = this.queueEdit;
|
|
459
|
+
this.queueEdit = null;
|
|
460
|
+
this.renderQueue();
|
|
461
|
+
this.renderFooter();
|
|
462
|
+
return this.transcript.note(`removed from the queue: ${original.replace(/\s+/g, " ").slice(0, 80)}`, C.dim);
|
|
463
|
+
}
|
|
379
464
|
if (!text) return;
|
|
465
|
+
// The agent finished meanwhile: the edited message is sent now, not queued.
|
|
466
|
+
if (this.queueEdit && !this.active?.turn) {
|
|
467
|
+
this.queueEdit = null;
|
|
468
|
+
this.renderQueue();
|
|
469
|
+
}
|
|
380
470
|
if (this.palette && this.paletteKind === "commands") {
|
|
381
471
|
// Enter on an exact or highlighted command runs it; Enter on a typo does nothing dangerous.
|
|
382
472
|
const cur = this.palette.current;
|
|
@@ -393,9 +483,7 @@ export class ChatApp {
|
|
|
393
483
|
const busy = !!this.active?.turn;
|
|
394
484
|
const immediate = /^\/(quit|exit|q|help|status|usage|cost|diff|clear|switch|agents)\b/.test(text);
|
|
395
485
|
if (busy && !immediate && (text.startsWith("/") || text.startsWith("!"))) {
|
|
396
|
-
this.
|
|
397
|
-
this.transcript.note(`queued until the turn ends: ${text.slice(0, 60)}`, C.dim);
|
|
398
|
-
this.renderFooter();
|
|
486
|
+
this.enqueue(text);
|
|
399
487
|
return;
|
|
400
488
|
}
|
|
401
489
|
if (text.startsWith("/")) return this.command(text);
|
|
@@ -407,8 +495,7 @@ export class ChatApp {
|
|
|
407
495
|
const route = this.routeFor(text);
|
|
408
496
|
if (route && route !== this.active.agent && !this.active.turn) return this.askAgent(route, text, `routed to ${route} by rule`);
|
|
409
497
|
if (this.active.turn) {
|
|
410
|
-
this.
|
|
411
|
-
this.transcript.note(`queued: ${text.slice(0, 80)}${text.length > 80 ? "…" : ""}`, C.dim);
|
|
498
|
+
this.enqueue(text);
|
|
412
499
|
return;
|
|
413
500
|
}
|
|
414
501
|
await this.send(this.active, text);
|
|
@@ -489,7 +576,7 @@ export class ChatApp {
|
|
|
489
576
|
this.transcript.divider(`${why} · ${adapter.name}`);
|
|
490
577
|
this.transcript.user(text);
|
|
491
578
|
try {
|
|
492
|
-
await engine.start({ cwd: this.cwd });
|
|
579
|
+
await engine.start({ cwd: this.cwd, questions: this.config.questions !== "off" });
|
|
493
580
|
const brief = this.ledger.turns().length ? await buildBrief({ ledger: this.ledger, cwd: this.cwd, to: agent, from: this.active?.agent, config: this.config, purpose: "context" }) : "";
|
|
494
581
|
const body = this.withAttachments(text);
|
|
495
582
|
await this.checkpoint(side, text);
|
|
@@ -650,6 +737,64 @@ export class ChatApp {
|
|
|
650
737
|
return true;
|
|
651
738
|
}
|
|
652
739
|
|
|
740
|
+
/** Queue a message (or command) until the agent's turn ends; it shows above the prompt. */
|
|
741
|
+
private enqueue(text: string) {
|
|
742
|
+
// An edited queued message goes back to the place it came from.
|
|
743
|
+
if (this.queueEdit) {
|
|
744
|
+
this.queue.splice(Math.min(this.queueEdit.index, this.queue.length), 0, text);
|
|
745
|
+
this.queueEdit = null;
|
|
746
|
+
} else this.queue.push(text);
|
|
747
|
+
this.renderQueue();
|
|
748
|
+
this.renderFooter();
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
private renderQueue() {
|
|
752
|
+
this.safely("queue", () => {
|
|
753
|
+
for (const child of this.queueBox.getChildren()) child.destroyRecursively();
|
|
754
|
+
const show = this.queue.slice(0, 3);
|
|
755
|
+
for (const [i, q] of show.entries()) {
|
|
756
|
+
const line = q.startsWith("A side agent (") ? "side-agent note for the main agent" : q.replace(/\s+/g, " ");
|
|
757
|
+
this.queueBox.add(new TextRenderable(this.renderer, { id: `queue-${Date.now()}-${i}`, content: `${G.prompt} ${line}`, fg: C.dim, wrapMode: "none", truncate: true, width: "100%" } as any));
|
|
758
|
+
}
|
|
759
|
+
if (this.queue.length > show.length) this.queueBox.add(new TextRenderable(this.renderer, { id: `queue-more-${Date.now()}`, content: ` + ${this.queue.length - show.length} more queued`, fg: C.dimmer }));
|
|
760
|
+
const hint = this.queueEdit
|
|
761
|
+
? "editing a queued message · Enter puts it back · clear it and Enter to remove it · Esc cancels"
|
|
762
|
+
: this.queue.length ? "↑ to edit or remove a queued message" : "";
|
|
763
|
+
if (hint) this.queueBox.add(new TextRenderable(this.renderer, { id: `queue-hint-${Date.now()}`, content: ` ${hint}`, fg: this.queueEdit ? C.accent : C.dimmer, wrapMode: "word", width: "100%" } as any));
|
|
764
|
+
this.queueBox.visible = this.queue.length > 0 || !!this.queueEdit;
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* ↑ on an empty prompt pulls the last queued message back for editing; ↑ again (text
|
|
770
|
+
* unchanged or not) puts it back and moves to the one before it.
|
|
771
|
+
*/
|
|
772
|
+
private editQueued() {
|
|
773
|
+
let index = this.queue.length;
|
|
774
|
+
if (this.queueEdit) {
|
|
775
|
+
const cur = this.input.plainText;
|
|
776
|
+
index = this.queueEdit.index;
|
|
777
|
+
this.queue.splice(index, 0, cur.trim() ? cur : this.queueEdit.original);
|
|
778
|
+
this.queueEdit = null;
|
|
779
|
+
}
|
|
780
|
+
const at = index - 1;
|
|
781
|
+
if (at < 0) {
|
|
782
|
+
// Already at the oldest: keep editing it.
|
|
783
|
+
if (!this.queue.length) return;
|
|
784
|
+
return this.takeQueued(0);
|
|
785
|
+
}
|
|
786
|
+
this.takeQueued(at);
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
private takeQueued(at: number) {
|
|
790
|
+
const [text] = this.queue.splice(at, 1);
|
|
791
|
+
this.queueEdit = { index: at, original: text! };
|
|
792
|
+
this.input.setText(text!);
|
|
793
|
+
this.input.cursorOffset = text!.length;
|
|
794
|
+
this.renderQueue();
|
|
795
|
+
this.renderFooter();
|
|
796
|
+
}
|
|
797
|
+
|
|
653
798
|
/** Run one queued entry the same way as if it had just been typed. */
|
|
654
799
|
private async dispatch(active: Active, text: string) {
|
|
655
800
|
if (text.startsWith("!")) return this.shell(text.slice(1).trim());
|
|
@@ -658,12 +803,17 @@ export class ChatApp {
|
|
|
658
803
|
}
|
|
659
804
|
|
|
660
805
|
private async drainQueue(active: Active) {
|
|
661
|
-
while (this.queue.length && this.active === active && !active.turn)
|
|
806
|
+
while (this.queue.length && this.active === active && !active.turn) {
|
|
807
|
+
const next = this.queue.shift()!;
|
|
808
|
+
this.renderQueue();
|
|
809
|
+
await this.dispatch(active, next);
|
|
810
|
+
}
|
|
662
811
|
this.renderFooter();
|
|
663
812
|
}
|
|
664
813
|
|
|
665
814
|
private async send(active: Active, text: string) {
|
|
666
815
|
if (this.closed) return;
|
|
816
|
+
if (!this.side.has(active)) this.notePanel.setPaused(false);
|
|
667
817
|
this.transcript.user(text);
|
|
668
818
|
text = this.withAttachments(text);
|
|
669
819
|
const cp = await this.checkpoint(active, text);
|
|
@@ -703,10 +853,6 @@ export class ChatApp {
|
|
|
703
853
|
{ name: "/budget", description: "Today's spend against your daily budgets", run: () => this.showBudget() },
|
|
704
854
|
{ name: "/update", description: "Update codemax to the latest version", run: () => void this.runUpdate() },
|
|
705
855
|
{ name: "/config", description: "Settings: theme, keep awake, side agent and model, handoffs, checkpoints…", run: () => this.openConfig() },
|
|
706
|
-
{ name: "/side", description: "Side agent that reviews each turn: /side (pick agent and model) · /side codex <model> [auto] · /side off", run: (a) => void this.setSide(a) },
|
|
707
|
-
{ name: "/review", description: "Ask the side agent to review the latest work now", run: () => void this.reviewNow() },
|
|
708
|
-
{ name: "/notes", description: "Notes the side agent left", run: () => this.showNotes() },
|
|
709
|
-
{ name: "/forward", description: "Send the side agent's latest note (or /forward <n>) to the main agent", run: (a) => this.forwardNote(a.trim() ? Number(a) - 1 : undefined) },
|
|
710
856
|
{ name: "/awake", description: "Keep the machine awake while agents work: /awake on | lid | off", run: (a) => void this.setAwake(a) },
|
|
711
857
|
{ name: "/theme", description: "Colour theme: /theme dark | light | auto (saved; restart to apply everywhere)", run: (a) => this.setThemePref(a) },
|
|
712
858
|
{ name: "/quit", description: "Quit codemax", run: () => void this.quit() },
|
|
@@ -772,11 +918,17 @@ export class ChatApp {
|
|
|
772
918
|
const name = raw!.toLowerCase();
|
|
773
919
|
const b = this.builtins().find((x) => x.name === name || x.name === name.replace(/^\/(exit|q)$/, "/quit"));
|
|
774
920
|
if (b) return b.run(rest.join(" "));
|
|
921
|
+
// /<agent>:<cmd> runs the active agent's own command even when codemax has one of that name.
|
|
922
|
+
const ns = name.match(/^\/([a-z]+):(.+)$/);
|
|
923
|
+
if (ns && this.active && ns[1] === this.active.agent && this.active.commands.some((c) => c.name === ns[2])) {
|
|
924
|
+
const forward = `/${ns[2]}${rest.length ? " " + rest.join(" ") : ""}`;
|
|
925
|
+
if (this.active.turn) return this.enqueue(forward);
|
|
926
|
+
return this.send(this.active, forward);
|
|
927
|
+
}
|
|
775
928
|
if (name === "/exit" || name === "/q") return this.quit();
|
|
776
929
|
if (this.active?.commands.some((c) => `/${c.name}` === name)) {
|
|
777
930
|
if (this.active.turn) {
|
|
778
|
-
this.
|
|
779
|
-
this.transcript.note(`queued ${name} until the turn ends`, C.dim);
|
|
931
|
+
this.enqueue(line);
|
|
780
932
|
return;
|
|
781
933
|
}
|
|
782
934
|
return this.send(this.active, line);
|
|
@@ -786,7 +938,12 @@ export class ChatApp {
|
|
|
786
938
|
|
|
787
939
|
private runCommandValue(value: string, args: string) {
|
|
788
940
|
if (value.startsWith("builtin:")) return this.builtins().find((b) => b.name === value.slice(8))?.run(args);
|
|
789
|
-
if (value.startsWith("agentcmd:"))
|
|
941
|
+
if (value.startsWith("agentcmd:")) {
|
|
942
|
+
const name = value.slice(9);
|
|
943
|
+
// A name codemax also uses goes through /<agent>:<name> so the agent's own command runs.
|
|
944
|
+
const clash = this.builtins().some((b) => b.name === `/${name}`) && this.active;
|
|
945
|
+
return this.command(`/${clash ? `${this.active!.agent}:` : ""}${name}${args ? " " + args : ""}`);
|
|
946
|
+
}
|
|
790
947
|
}
|
|
791
948
|
|
|
792
949
|
private pendingShell: string[] = [];
|
|
@@ -822,7 +979,7 @@ export class ChatApp {
|
|
|
822
979
|
const apply = async (id: string) => {
|
|
823
980
|
try {
|
|
824
981
|
await a.engine.setModel!(id);
|
|
825
|
-
this.transcript.note(`model: ${id}`);
|
|
982
|
+
this.transcript.note(`model: ${displayModelName(id, a.engine.info?.models)}`);
|
|
826
983
|
if (a.agent === "cursor") this.transcript.note("Cursor saves this as its default model, so Cursor uses it outside codemax too", C.warn);
|
|
827
984
|
this.renderFooter();
|
|
828
985
|
} catch (e: any) {
|
|
@@ -834,7 +991,8 @@ export class ChatApp {
|
|
|
834
991
|
this.paletteKind = "modes";
|
|
835
992
|
this.palette = new Palette(this.renderer, {
|
|
836
993
|
title: `Model for ${a.name}`,
|
|
837
|
-
|
|
994
|
+
// The agent's own list, newest first, by its display name (e.g. "Opus 5.5" for the id "opus").
|
|
995
|
+
items: sortModels(models).map((m, i) => ({ label: `${m.name}${m.id === a.engine.info?.currentModel ? " (current)" : ""}`, value: m.id, weight: 100 - i })),
|
|
838
996
|
onPick: (it) => {
|
|
839
997
|
this.closePalette();
|
|
840
998
|
void apply(it.value);
|
|
@@ -899,17 +1057,26 @@ export class ChatApp {
|
|
|
899
1057
|
const block = this.transcript.agent();
|
|
900
1058
|
a.turn = { block, tools: new Set(), startedAt: Date.now(), tokens: 0, chars: 0, verb: "Summarizing" };
|
|
901
1059
|
this.startSpinner(a);
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
1060
|
+
let saved = false;
|
|
1061
|
+
try {
|
|
1062
|
+
const stop = await a.engine.prompt(prompt);
|
|
1063
|
+
const text = block.text().trim();
|
|
1064
|
+
if (stop === "end_turn" && text.length > 40) {
|
|
1065
|
+
this.ledger.writeSummary(`_${a.name}, ${new Date().toISOString().slice(0, 16).replace("T", " ")}_\n\n${text}`);
|
|
1066
|
+
block.footer("summary saved · used in every handoff");
|
|
1067
|
+
saved = true;
|
|
1068
|
+
} else block.footer("no summary saved");
|
|
1069
|
+
} catch (e: any) {
|
|
1070
|
+
block.footer(`no summary saved: ${e?.message ?? e}`);
|
|
1071
|
+
} finally {
|
|
1072
|
+
// Never leave the agent marked busy: everything typed afterwards would queue forever.
|
|
1073
|
+
a.turn = null;
|
|
1074
|
+
this.stopSpinner();
|
|
910
1075
|
}
|
|
911
|
-
|
|
912
|
-
|
|
1076
|
+
// Messages typed during the summary were queued: send them now (a quiet summary before a
|
|
1077
|
+
// switch leaves them for the next agent, whose start sends them).
|
|
1078
|
+
if (!o.quiet && this.active === a) await this.drainQueue(a);
|
|
1079
|
+
return saved;
|
|
913
1080
|
}
|
|
914
1081
|
|
|
915
1082
|
private async showTask() {
|
|
@@ -944,12 +1111,19 @@ export class ChatApp {
|
|
|
944
1111
|
const all = this.ledger.usage();
|
|
945
1112
|
for (const l of renderUsageTable(all, { title: "Usage for this project" })) this.transcript.note(l);
|
|
946
1113
|
this.transcript.note(`this codemax session: ${fmtUsd(this.sessionCost)} · all time here: ${fmtUsd(sumCost(all))} · prices in ~/.codemax/prices.json`);
|
|
1114
|
+
// Plan limits, as the agents report them (Claude Code's own /usage is terminal-only, so this is where it shows).
|
|
1115
|
+
const label: Record<string, string> = { five_hour: "5-hour", seven_day: "weekly", seven_day_opus: "weekly Opus", seven_day_sonnet: "weekly Sonnet", overage: "extra usage", seven_day_overage_included: "weekly incl. extra" };
|
|
1116
|
+
for (const [agent, m] of this.planLimits) {
|
|
1117
|
+
const parts = [...m].map(([w, v]) => `${label[w] ?? w} ${typeof v.usedPercent === "number" ? `${Math.round(v.usedPercent)}%` : v.status ?? "?"}${v.status === "rejected" ? " (used up)" : ""}${v.resetsAt ? ` · resets ${fmtDateTime12(v.resetsAt)}` : ""}`);
|
|
1118
|
+
this.transcript.note(`${getAdapter(agent).name} plan: ${parts.join("; ")}`, C.accent);
|
|
1119
|
+
}
|
|
1120
|
+
if (!this.planLimits.size) this.transcript.note("plan limits appear here once an agent reports them (Claude Code does after a turn)");
|
|
947
1121
|
}
|
|
948
1122
|
|
|
949
1123
|
private showStatus() {
|
|
950
1124
|
const a = this.active;
|
|
951
1125
|
const lines = [
|
|
952
|
-
`agent: ${a ? `${a.name}${a.engine.info?.currentModel ? ` · ${a.engine.info.currentModel}` : ""}` : "none"}`,
|
|
1126
|
+
`agent: ${a ? `${a.name}${a.engine.info?.currentModel ? ` · ${displayModelName(a.engine.info.currentModel, a.engine.info.models)}` : ""}` : "none"}`,
|
|
953
1127
|
`session: ${a?.engine.session?.id ?? "-"} mode: ${a?.mode ?? "-"}`,
|
|
954
1128
|
`ledger: ${this.ledger.dir} (${this.ledger.turns().length} turns)`,
|
|
955
1129
|
];
|
|
@@ -978,9 +1152,12 @@ export class ChatApp {
|
|
|
978
1152
|
const builtinNames = new Set(this.builtins().map((b) => b.name));
|
|
979
1153
|
const items: PaletteItem[] = [
|
|
980
1154
|
...this.builtins().map((b) => ({ label: b.name, description: b.description, value: `builtin:${b.name}`, weight: 1 })),
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
1155
|
+
// An agent command that shares a built-in's name stays reachable as /<agent>:<name>.
|
|
1156
|
+
...(this.active?.commands ?? []).map((c) => {
|
|
1157
|
+
const clash = builtinNames.has(`/${c.name}`);
|
|
1158
|
+
const label = clash ? `/${this.active!.agent}:${c.name}` : `/${c.name}`;
|
|
1159
|
+
return { label, description: `${c.description ?? `${this.active!.name} command`}${clash ? ` (${this.active!.name}'s own /${c.name})` : ""}`, value: `agentcmd:${c.name}` };
|
|
1160
|
+
}),
|
|
984
1161
|
];
|
|
985
1162
|
this.paletteKind = "commands";
|
|
986
1163
|
this.palette = new Palette(this.renderer, {
|
|
@@ -1004,7 +1181,9 @@ export class ChatApp {
|
|
|
1004
1181
|
const q = this.quota.get(a.id);
|
|
1005
1182
|
const usable = !!d?.installed && !!d?.engine;
|
|
1006
1183
|
const bits: string[] = [];
|
|
1007
|
-
|
|
1184
|
+
const installable = a.id === "antigravity" && !!d?.installed && !d.engine;
|
|
1185
|
+
if (!d?.installed) bits.push(d?.problem ? "found but does not run" : "not installed");
|
|
1186
|
+
else if (installable) bits.push(`${d.version ?? ""} · Enter to install Google's ACP server for it (about 110 MB, once)`.replace(/^ · /, ""));
|
|
1008
1187
|
else if (!d.engine) bits.push("no protocol · use codemax --tui");
|
|
1009
1188
|
else {
|
|
1010
1189
|
if (d.version) bits.push(d.version);
|
|
@@ -1012,7 +1191,7 @@ export class ChatApp {
|
|
|
1012
1191
|
if (q && quotaLabel(q)) bits.push(quotaLabel(q));
|
|
1013
1192
|
if (NO_PERMISSION_PROMPTS.has(a.id)) bits.push("acts without asking");
|
|
1014
1193
|
}
|
|
1015
|
-
return { label: a.name + (a.id === this.active?.agent ? " (current)" : ""), description: bits.join(" · "), value: a.id, disabled: !usable };
|
|
1194
|
+
return { label: a.name + (a.id === this.active?.agent ? " (current)" : ""), description: bits.join(" · "), value: a.id, disabled: !usable && !installable };
|
|
1016
1195
|
});
|
|
1017
1196
|
}
|
|
1018
1197
|
|
|
@@ -1027,6 +1206,7 @@ export class ChatApp {
|
|
|
1027
1206
|
this.closePalette();
|
|
1028
1207
|
const id = it.value as AgentId;
|
|
1029
1208
|
if (id === this.active?.agent) return;
|
|
1209
|
+
if (id === "antigravity" && !this.detect.get(id)?.engine) return void this.offerAntigravityInstall();
|
|
1030
1210
|
void this.switchTo(id, false);
|
|
1031
1211
|
},
|
|
1032
1212
|
onClose: () => this.closePalette(),
|
|
@@ -1036,6 +1216,50 @@ export class ChatApp {
|
|
|
1036
1216
|
void this.refreshDetect().then(() => this.palette === p && p.setItems(this.switchItems()));
|
|
1037
1217
|
}
|
|
1038
1218
|
|
|
1219
|
+
/**
|
|
1220
|
+
* Antigravity CLI has no ACP mode; Google's ACP server for it is a separate download
|
|
1221
|
+
* (from the ACP registry, dl.google.com only). Ask first, with the size, then install and switch.
|
|
1222
|
+
*/
|
|
1223
|
+
private async offerAntigravityInstall() {
|
|
1224
|
+
let plan: InstallPlan;
|
|
1225
|
+
try {
|
|
1226
|
+
plan = await antigravityInstallPlan();
|
|
1227
|
+
} catch (e: any) {
|
|
1228
|
+
return this.transcript.error(`Antigravity: ${e?.message ?? e}`);
|
|
1229
|
+
}
|
|
1230
|
+
if (this.closed) return;
|
|
1231
|
+
const size = plan.bytes ? `${Math.round(plan.bytes / 1e6)} MB` : "about 110 MB";
|
|
1232
|
+
this.closePalette();
|
|
1233
|
+
this.paletteKind = "modes";
|
|
1234
|
+
this.palette = new Palette(this.renderer, {
|
|
1235
|
+
title: `Antigravity needs Google's ACP server (${plan.version}, ${size} from dl.google.com) · it signs in with your Google account`,
|
|
1236
|
+
items: [
|
|
1237
|
+
{ label: "Download and switch", description: `into ~/.codemax/agents/antigravity-acp/${plan.version}`, value: "yes" },
|
|
1238
|
+
{ label: "Not now", description: "stay on the current agent", value: "no" },
|
|
1239
|
+
],
|
|
1240
|
+
onPick: (it) => {
|
|
1241
|
+
this.closePalette();
|
|
1242
|
+
if (it.value === "yes") void this.installAntigravity(plan);
|
|
1243
|
+
},
|
|
1244
|
+
onClose: () => this.closePalette(),
|
|
1245
|
+
}, this.paletteAnchor);
|
|
1246
|
+
this.renderer.root.add(this.palette.box);
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
private async installAntigravity(plan: InstallPlan) {
|
|
1250
|
+
this.setWorking(`${pick(G.spinner, 0)} Installing Antigravity's ACP server…`);
|
|
1251
|
+
try {
|
|
1252
|
+
await installAntigravityServer(plan, (m) => this.transcript.note(m, C.dim));
|
|
1253
|
+
this.transcript.note(`Antigravity ACP server ${plan.version} installed`, C.ok);
|
|
1254
|
+
await this.refreshDetect();
|
|
1255
|
+
this.setWorking(null);
|
|
1256
|
+
if (!this.closed) await this.switchTo("antigravity", false);
|
|
1257
|
+
} catch (e: any) {
|
|
1258
|
+
this.setWorking(null);
|
|
1259
|
+
this.transcript.error(`could not install the Antigravity ACP server: ${e?.message ?? e}`);
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1039
1263
|
private openModes(arg: string) {
|
|
1040
1264
|
const a = this.active;
|
|
1041
1265
|
if (!a) return;
|
|
@@ -1101,19 +1325,20 @@ export class ChatApp {
|
|
|
1101
1325
|
this.active = active;
|
|
1102
1326
|
this.setWorking(`${pick(G.spinner, 0)} Starting ${adapter.name}…`);
|
|
1103
1327
|
try {
|
|
1104
|
-
const info = await engine.start({ cwd: this.cwd, resume });
|
|
1328
|
+
const info = await engine.start({ cwd: this.cwd, resume, questions: this.config.questions !== "off" });
|
|
1105
1329
|
active.mode = info.currentMode;
|
|
1106
1330
|
if (engine.session) this.ledger.saveSession({ agent, id: engine.session.id, cwd: this.cwd, startedAt: engine.session.startedAt, lastUsedAt: Date.now() } as SessionRef);
|
|
1107
1331
|
this.ledger.addEvent({ kind: o.from ? "switch" : "start", agent: o.from, to: agent, detail: o.reason });
|
|
1108
1332
|
const resumed = !!resume && engine.session?.id === resume.id;
|
|
1109
1333
|
active.replay = null;
|
|
1110
1334
|
const version = this.detect.get(agent)?.version ?? info.version;
|
|
1111
|
-
const model = info.currentModel ? ` · ${info.currentModel}` : "";
|
|
1335
|
+
const model = info.currentModel ? ` · ${displayModelName(info.currentModel, info.models)}` : "";
|
|
1112
1336
|
this.transcript.divider(`${adapter.name}${version ? ` ${version}` : ""}${model} · ${resumed ? "resumed" : "new session"}`);
|
|
1113
1337
|
if (NO_PERMISSION_PROMPTS.has(agent)) this.transcript.note(grokPermissionNote() ?? `${adapter.name} acts without asking permission`, C.warn);
|
|
1114
1338
|
this.setWorking(null);
|
|
1115
1339
|
this.renderFooter();
|
|
1116
1340
|
this.setTitle();
|
|
1341
|
+
this.term.started(agent, adapter.name, engine.session?.id);
|
|
1117
1342
|
// Only brief the agent when there is something to hand over: a brief with
|
|
1118
1343
|
// nothing in it costs a turn (and quota) for no context.
|
|
1119
1344
|
const hasHistory = this.ledger.turns().length > 0 || this.ledger.journal.steps().some((s) => s.kind === "user");
|
|
@@ -1156,6 +1381,8 @@ export class ChatApp {
|
|
|
1156
1381
|
active.turn = { block, tools: new Set(), startedAt: Date.now(), tokens: 0, chars: 0, verb: pick(VERBS), rec };
|
|
1157
1382
|
this.startSpinner(active);
|
|
1158
1383
|
this.setTitle(`✳ ${active.name} working`);
|
|
1384
|
+
const main = !this.side.has(active);
|
|
1385
|
+
if (main) this.term.prompt(o.record === false ? "(handoff to " + active.name + ")" : text);
|
|
1159
1386
|
const stop = await active.engine.prompt(text);
|
|
1160
1387
|
if (this.closed) {
|
|
1161
1388
|
active.turn?.rec?.end(stop);
|
|
@@ -1167,9 +1394,13 @@ export class ChatApp {
|
|
|
1167
1394
|
this.stopSpinner();
|
|
1168
1395
|
this.setTitle();
|
|
1169
1396
|
if (turn && this.config.bellAfterSeconds > 0 && Date.now() - turn.startedAt > this.config.bellAfterSeconds * 1000) process.stdout.write("\x07");
|
|
1397
|
+
if (main) this.subagents.endAll();
|
|
1398
|
+
if (main && turn) this.notePanel.setPaused(asksUser(turn.block.text()));
|
|
1399
|
+
if (turn && main) this.term.turnEnded({ reply: turn.block.text(), ms: Date.now() - turn.startedAt, error: stop === "error", longMs: this.config.bellAfterSeconds > 0 ? this.config.bellAfterSeconds * 1000 : Infinity });
|
|
1170
1400
|
if (turn) {
|
|
1171
1401
|
const dur = fmtDuration(Date.now() - turn.startedAt);
|
|
1172
|
-
|
|
1402
|
+
const who = `${active.name} · ${this.modelName(active, turn.model)}`;
|
|
1403
|
+
block.footer(stop === "cancelled" ? `Interrupted after ${dur} · ${who}` : `${pick(DONE_VERBS, turn.startedAt)} for ${dur} · done ${fmtTime(Date.now())} · ${who}`);
|
|
1173
1404
|
const reply = block.text().trim();
|
|
1174
1405
|
if (isLimitNotice(reply)) turn.rec?.markLimit(reply);
|
|
1175
1406
|
turn.rec?.end(stop);
|
|
@@ -1204,7 +1435,7 @@ export class ChatApp {
|
|
|
1204
1435
|
}
|
|
1205
1436
|
|
|
1206
1437
|
private handleEngineEvent(active: Active, e: EngineEvent) {
|
|
1207
|
-
if (this.active !== active && !this.side.has(active) && e.type !== "permission_request") return;
|
|
1438
|
+
if (this.active !== active && !this.side.has(active) && e.type !== "permission_request" && e.type !== "question_request") return;
|
|
1208
1439
|
const target = () => active.turn?.block ?? (active.replay ??= this.transcript.agent());
|
|
1209
1440
|
switch (e.type) {
|
|
1210
1441
|
case "text_delta":
|
|
@@ -1228,15 +1459,27 @@ export class ChatApp {
|
|
|
1228
1459
|
active.turn?.tools.add(e.title.split(/[\s(]/)[0] ?? e.title);
|
|
1229
1460
|
active.turn?.rec?.tool(e.id, { title: e.title, kind: e.kind, status: e.status, input: e.input, output: e.output, files: e.files });
|
|
1230
1461
|
target().tool(e.id, e.title, e.status, e.detail);
|
|
1462
|
+
if (e.status === "completed" && this.active === active) this.term.toolDone(e.title);
|
|
1231
1463
|
break;
|
|
1232
1464
|
case "tool_update":
|
|
1233
1465
|
active.turn?.rec?.tool(e.id, { title: e.title, status: e.status, input: e.input, output: e.output, files: e.files });
|
|
1466
|
+
if (e.status === "completed" && this.active === active) this.term.toolDone(e.title ?? "tool");
|
|
1234
1467
|
target().toolUpdate(e.id, e.status, e.title, e.detail);
|
|
1235
1468
|
break;
|
|
1236
1469
|
case "permission_request":
|
|
1237
1470
|
this.permissionQueue.push({ request: e.request, engine: active.engine });
|
|
1238
1471
|
this.showNextPermission();
|
|
1239
1472
|
break;
|
|
1473
|
+
case "subagent":
|
|
1474
|
+
if (this.active === active) {
|
|
1475
|
+
this.subagents.update(e);
|
|
1476
|
+
this.renderFooter();
|
|
1477
|
+
}
|
|
1478
|
+
break;
|
|
1479
|
+
case "question_request":
|
|
1480
|
+
this.permissionQueue.push({ question: e.request, engine: active.engine });
|
|
1481
|
+
this.showNextPermission();
|
|
1482
|
+
break;
|
|
1240
1483
|
case "mode": {
|
|
1241
1484
|
active.mode = e.modeId;
|
|
1242
1485
|
this.renderFooter();
|
|
@@ -1247,10 +1490,18 @@ export class ChatApp {
|
|
|
1247
1490
|
break;
|
|
1248
1491
|
case "usage": {
|
|
1249
1492
|
if (active.turn && typeof e.outputTokens === "number") active.turn.tokens += e.outputTokens;
|
|
1493
|
+
if (active.turn && e.model && !/default model$/.test(e.model)) active.turn.model = e.model;
|
|
1250
1494
|
if (typeof e.contextUsed === "number" && typeof e.contextSize === "number" && e.contextSize > 0) {
|
|
1251
1495
|
active.context = { used: e.contextUsed, size: e.contextSize };
|
|
1252
1496
|
this.renderFooter();
|
|
1253
1497
|
}
|
|
1498
|
+
for (const pl of e.planLimits ?? []) {
|
|
1499
|
+
const m = this.planLimits.get(active.agent) ?? new Map();
|
|
1500
|
+
m.set(pl.window, { usedPercent: pl.usedPercent, resetsAt: pl.resetsAt, status: pl.status, at: Date.now() });
|
|
1501
|
+
this.planLimits.set(active.agent, m);
|
|
1502
|
+
// A refused window is the plan running out: the handoff offer follows from onQuota.
|
|
1503
|
+
if (pl.status === "rejected" || pl.window === "five_hour") this.onQuota({ agent: active.agent, usedPercent: pl.usedPercent, exhausted: pl.status === "rejected", resetsAt: pl.resetsAt, source: "probe", at: Date.now() });
|
|
1504
|
+
}
|
|
1254
1505
|
if (typeof e.usedPercent === "number") this.onQuota({ agent: active.agent, usedPercent: e.usedPercent, exhausted: e.usedPercent >= 100, source: "probe", at: Date.now() });
|
|
1255
1506
|
this.recordUsage(active.agent, active.engine, e);
|
|
1256
1507
|
break;
|
|
@@ -1278,10 +1529,13 @@ export class ChatApp {
|
|
|
1278
1529
|
}
|
|
1279
1530
|
}
|
|
1280
1531
|
|
|
1281
|
-
private showNextPermission() {
|
|
1532
|
+
private showNextPermission(): void {
|
|
1282
1533
|
if (this.permission || !this.permissionQueue.length) return;
|
|
1283
|
-
const
|
|
1534
|
+
const next = this.permissionQueue.shift()!;
|
|
1535
|
+
if ("question" in next) return this.showQuestion(next.question, next.engine);
|
|
1536
|
+
const { request, engine } = next;
|
|
1284
1537
|
const name = this.active?.engine === engine ? this.active.name : "the agent";
|
|
1538
|
+
this.term.waiting("permission", request.title, request.kind, request.detail ? { detail: request.detail } : undefined);
|
|
1285
1539
|
this.permission = new PermissionDialog(this.renderer, request, name, (choice) => {
|
|
1286
1540
|
this.permission?.destroy();
|
|
1287
1541
|
this.permission = null;
|
|
@@ -1289,6 +1543,7 @@ export class ChatApp {
|
|
|
1289
1543
|
const fallbackReject = request.options.find((o) => o.kind.startsWith("reject"))?.id ?? request.options[0]!.id;
|
|
1290
1544
|
engine.answerPermission(request.id, choice.optionId ?? fallbackReject);
|
|
1291
1545
|
const allowed = !!opt && opt.kind.startsWith("allow");
|
|
1546
|
+
this.term.answered(allowed);
|
|
1292
1547
|
if (!allowed) this.transcript.note(`declined: ${request.title.length > 70 ? request.title.slice(0, 67) + "…" : request.title}`, C.warn);
|
|
1293
1548
|
if (choice.tellAgent) {
|
|
1294
1549
|
this.input.placeholder = `Tell ${name} what to do differently…`;
|
|
@@ -1300,6 +1555,40 @@ export class ChatApp {
|
|
|
1300
1555
|
this.dialogHost.add(this.permission.box);
|
|
1301
1556
|
}
|
|
1302
1557
|
|
|
1558
|
+
private showQuestion(q: QuestionRequest, engine: Engine): void {
|
|
1559
|
+
const name = this.active?.engine === engine ? this.active.name : "the agent";
|
|
1560
|
+
if (!engine.answerQuestion) return this.showNextPermission();
|
|
1561
|
+
this.term.waiting("question", q.fields[0]?.question || q.message, "question");
|
|
1562
|
+
// Unattended runs: a question nobody answers is skipped so the agent carries on.
|
|
1563
|
+
const timer = this.config.questions === "timeout"
|
|
1564
|
+
? setTimeout(() => this.safely("question timeout", () => {
|
|
1565
|
+
if (this.permission !== dialog) return;
|
|
1566
|
+
this.transcript.note(`no answer for ${QUESTION_TIMEOUT_MS / 60000} minutes: skipped, ${name} carries on`, C.warn);
|
|
1567
|
+
dialog.handleKey({ name: "escape", sequence: "\x1b", ctrl: false, meta: false, shift: false } as any);
|
|
1568
|
+
}), QUESTION_TIMEOUT_MS)
|
|
1569
|
+
: null;
|
|
1570
|
+
const dialog = new QuestionDialog(this.renderer, q, name, (answer) => {
|
|
1571
|
+
if (timer) clearTimeout(timer);
|
|
1572
|
+
this.permission?.destroy();
|
|
1573
|
+
this.permission = null;
|
|
1574
|
+
engine.answerQuestion!(q.id, answer);
|
|
1575
|
+
this.term.answered(answer.action === "accept");
|
|
1576
|
+
if (answer.action === "accept") {
|
|
1577
|
+
for (const f of q.fields) {
|
|
1578
|
+
const v = answer.content[f.key];
|
|
1579
|
+
const other = f.otherKey ? answer.content[f.otherKey] : undefined;
|
|
1580
|
+
const label = (x: unknown) => f.options.find((o) => o.value === String(x))?.label ?? String(x);
|
|
1581
|
+
const shown = [...(Array.isArray(v) ? v.map(label) : v !== undefined ? [f.kind === "boolean" ? (v ? "Yes" : "No") : label(v)] : []), ...(other ? [`"${other}"`] : [])].join(", ");
|
|
1582
|
+
this.transcript.note(`${f.title ?? f.question}: ${shown || "(skipped)"}`, C.dim);
|
|
1583
|
+
}
|
|
1584
|
+
} else this.transcript.note(`skipped ${name}'s question`, C.warn);
|
|
1585
|
+
this.input.focus();
|
|
1586
|
+
this.showNextPermission();
|
|
1587
|
+
});
|
|
1588
|
+
this.permission = dialog;
|
|
1589
|
+
this.dialogHost.add(dialog.box);
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1303
1592
|
private onQuota(q: QuotaStatus) {
|
|
1304
1593
|
const prev = this.quota.get(q.agent);
|
|
1305
1594
|
this.quota.set(q.agent, q);
|
|
@@ -1339,27 +1628,11 @@ export class ChatApp {
|
|
|
1339
1628
|
|
|
1340
1629
|
// ---------- side agent ----------
|
|
1341
1630
|
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
private async setSide(arg: string) {
|
|
1348
|
-
const words = arg.trim().split(/\s+/).filter(Boolean);
|
|
1349
|
-
const cfg = this.config.sideAgent ?? { agent: null, forward: "ask" as const };
|
|
1350
|
-
if (!words.length) return this.openSidePicker();
|
|
1351
|
-
const who = words[0]!.toLowerCase();
|
|
1352
|
-
if (who === "off") {
|
|
1353
|
-
await this.closeReviewer();
|
|
1354
|
-
this.saveSide({ ...cfg, agent: null, model: undefined });
|
|
1355
|
-
this.transcript.note("side agent off", C.dim);
|
|
1356
|
-
return this.renderFooter();
|
|
1357
|
-
}
|
|
1358
|
-
if (!this.detect.get(who as AgentId)?.engine) return this.transcript.note(`no protocol engine for "${who}" · /side to pick one`, C.warn);
|
|
1359
|
-
const rest = words.slice(1);
|
|
1360
|
-
const forward = rest.includes("auto") ? "auto" : rest.includes("ask") ? "ask" : cfg.agent === who ? cfg.forward : "ask";
|
|
1361
|
-
const model = rest.find((w) => w !== "auto" && w !== "ask");
|
|
1362
|
-
await this.enableSide(who as AgentId, { forward, model, pickModel: false });
|
|
1631
|
+
private async disableSide() {
|
|
1632
|
+
await this.closeReviewer();
|
|
1633
|
+
this.saveSide({ ...(this.config.sideAgent ?? { forward: "ask" as const }), agent: null, model: undefined });
|
|
1634
|
+
this.transcript.note("side agent off", C.dim);
|
|
1635
|
+
this.renderFooter();
|
|
1363
1636
|
}
|
|
1364
1637
|
|
|
1365
1638
|
private saveSide(v: Config["sideAgent"]) {
|
|
@@ -1371,14 +1644,14 @@ export class ChatApp {
|
|
|
1371
1644
|
}
|
|
1372
1645
|
}
|
|
1373
1646
|
|
|
1374
|
-
private async enableSide(agent: AgentId, o: { forward: "ask" | "auto"; model?: string; pickModel: boolean }) {
|
|
1647
|
+
private async enableSide(agent: AgentId, o: { forward: "ask" | "auto"; model?: string; pickModel: boolean; samePlan?: boolean }) {
|
|
1375
1648
|
const prev = this.config.sideAgent;
|
|
1376
1649
|
if (prev?.agent !== agent || (o.model && o.model !== prev.model)) await this.closeReviewer();
|
|
1377
1650
|
// Keep the user's other side-agent settings (review cadence) when switching agent.
|
|
1378
|
-
this.saveSide({ ...prev, agent, forward: o.forward, model: o.model ?? (prev?.agent === agent ? prev.model : undefined) });
|
|
1651
|
+
this.saveSide({ ...prev, agent, forward: o.forward, model: o.model ?? (prev?.agent === agent ? prev.model : undefined), samePlan: o.samePlan ?? (prev?.agent === agent ? prev.samePlan : false) });
|
|
1379
1652
|
const name = getAdapter(agent).name;
|
|
1380
|
-
if (agent === this.active?.agent) this.transcript.note(`${name} is also the main agent: both use one ${name} plan, so each review brings the main agent's limit closer. A different subscription (or a cheaper
|
|
1381
|
-
this.transcript.note(`side agent: ${name}${this.config.sideAgent.model ? ` (${this.config.sideAgent.model})` : ""} reviews ${reviewCadence(this.config.sideAgent.every)} and speaks up only when something needs attention${o.forward === "auto" ? "; its notes go to the main agent automatically" : "
|
|
1653
|
+
if (agent === this.active?.agent) this.transcript.note(`${name} is also the main agent: both use one ${name} plan, so each review brings the main agent's limit closer. A different subscription (or a cheaper side-agent model, or fewer reviews, in /config) keeps it from running out sooner.`, C.warn);
|
|
1654
|
+
this.transcript.note(`side agent: ${name}${this.config.sideAgent.model ? ` (${displayModelName(this.config.sideAgent.model, this.reviewer?.engine.info?.models)})` : ""} reviews ${reviewCadence(this.config.sideAgent.every)} and speaks up only when something needs attention${o.forward === "auto" ? "; its notes go to the main agent automatically" : "; its notes appear above the prompt"}`, C.accent);
|
|
1382
1655
|
this.renderFooter();
|
|
1383
1656
|
if (!o.pickModel) return;
|
|
1384
1657
|
// Start the reviewer now so its real model list can be offered.
|
|
@@ -1391,8 +1664,8 @@ export class ChatApp {
|
|
|
1391
1664
|
this.closePalette();
|
|
1392
1665
|
this.paletteKind = "modes";
|
|
1393
1666
|
this.palette = new Palette(this.renderer, {
|
|
1394
|
-
title: `Model for the side agent (${name}) · Esc keeps ${
|
|
1395
|
-
items: models.map((m) => ({ label:
|
|
1667
|
+
title: `Model for the side agent (${name}) · Esc keeps ${displayModelName(r.engine.info?.currentModel, r.engine.info?.models)}`,
|
|
1668
|
+
items: sortModels(models).map((m, i) => ({ label: `${m.name}${m.id === r.engine.info?.currentModel ? " (current)" : ""}`, value: m.id, weight: 100 - i })),
|
|
1396
1669
|
onPick: (it) => {
|
|
1397
1670
|
this.closePalette();
|
|
1398
1671
|
void this.setSideModel(it.value);
|
|
@@ -1404,12 +1677,12 @@ export class ChatApp {
|
|
|
1404
1677
|
|
|
1405
1678
|
private async setSideModel(model: string) {
|
|
1406
1679
|
const cfg = this.config.sideAgent;
|
|
1407
|
-
if (!cfg?.agent) return this.transcript.note("no side agent ·
|
|
1680
|
+
if (!cfg?.agent) return this.transcript.note("no side agent · turn one on in /config", C.warn);
|
|
1408
1681
|
this.saveSide({ ...cfg, model });
|
|
1409
1682
|
const r = this.reviewer;
|
|
1410
1683
|
try {
|
|
1411
1684
|
if (r?.engine.setModel) await r.engine.setModel(model);
|
|
1412
|
-
this.transcript.note(`side agent model: ${model}`);
|
|
1685
|
+
this.transcript.note(`side agent model: ${displayModelName(model, this.reviewer?.engine.info?.models)}`);
|
|
1413
1686
|
if (cfg.agent === "cursor") this.transcript.note("Cursor saves this as its default model, so Cursor uses it outside codemax too", C.warn);
|
|
1414
1687
|
} catch (e: any) {
|
|
1415
1688
|
this.transcript.error(`could not set the side agent's model: ${e?.message ?? e}`);
|
|
@@ -1431,16 +1704,17 @@ export class ChatApp {
|
|
|
1431
1704
|
return [
|
|
1432
1705
|
{ key: "theme", label: "Theme", value: c.theme ?? "auto", choices: [{ value: "auto", description: "follow the terminal's background" }, { value: "dark" }, { value: "light" }], apply: (v) => this.setThemePref(v) },
|
|
1433
1706
|
{ key: "awake", label: "Keep awake", value: c.awake ?? "off", choices: [{ value: "off", description: "sleep normally" }, { value: "on", description: "no sleep while codemax runs" }, { value: "lid", description: "also keep working with the lid closed" }], apply: (v) => this.setAwake(v) },
|
|
1434
|
-
{ key: "side", label: "Side agent", value: side.agent ?? "off", choices: [{ value: "off" }, ...agents.map((a) => ({ value: a.id, label: a.name, description: "pick its model next" }))], apply: (v) => (v === "off" ? this.
|
|
1707
|
+
{ key: "side", label: "Side agent", value: side.agent ?? "off", choices: [{ value: "off" }, ...agents.map((a) => ({ value: a.id, label: a.name, description: a.id === this.active?.agent ? "same plan as the main agent: reviews count against its limit · pick its model next" : "its own subscription · pick its model next" }))], apply: (v) => (v === "off" ? this.disableSide() : this.enableSide(v as AgentId, { forward: side.forward, pickModel: true, samePlan: v === this.active?.agent })) },
|
|
1708
|
+
{ key: "sideSamePlan", label: "Side agent on the main agent's plan", value: side.samePlan ? "allowed" : "never", choices: [{ value: "never", description: "reviews pause while the side agent's tool is also the main agent" }, { value: "allowed", description: "reviews count against the main agent's usage limit" }], apply: (v) => this.saveSide({ ...side, samePlan: v === "allowed" }) },
|
|
1435
1709
|
{ key: "sideModel", label: "Side agent model", value: side.agent ? (side.model ?? "default") : "(no side agent)", choices: side.agent ? [{ value: "pick", label: "Choose from its models", description: "starts the side agent to list them" }] : [], apply: async () => { if (side.agent) await this.enableSide(side.agent, { forward: side.forward, pickModel: true }); } },
|
|
1436
1710
|
{
|
|
1437
1711
|
key: "sideEvery",
|
|
1438
1712
|
label: "Side agent reviews",
|
|
1439
1713
|
value: reviewCadence(side.every),
|
|
1440
|
-
choices: [{ value: "1", label: "every turn" }, { value: "2", label: "every 2nd turn" }, { value: "3", label: "every 3rd turn" }, { value: "
|
|
1714
|
+
choices: [{ value: "1", label: "every turn" }, { value: "2", label: "every 2nd turn" }, { value: "3", label: "every 3rd turn" }, { value: "5", label: "every 5th turn" }],
|
|
1441
1715
|
apply: (v) => this.saveSide({ ...side, every: Number(v) }),
|
|
1442
1716
|
},
|
|
1443
|
-
{ key: "sideForward", label: "Side agent notes", value: side.forward, choices: [{ value: "ask", description: "
|
|
1717
|
+
{ key: "sideForward", label: "Side agent notes", value: side.forward, choices: [{ value: "ask", description: "show them above the prompt: 1 expand · 2 discuss · 0 dismiss" }, { value: "auto", description: "send each note to the main agent" }], apply: (v) => this.saveSide({ ...side, forward: v === "auto" ? "auto" : "ask" }) },
|
|
1444
1718
|
{ key: "autoSwitch", label: "Offer a handoff at limits", value: onOff(c.autoSwitch), choices: [{ value: "on" }, { value: "off" }], apply: bool("autoSwitch") },
|
|
1445
1719
|
{ key: "summarizeOnSwitch", label: "Summary before switching", value: onOff(c.summarizeOnSwitch), choices: [{ value: "on", description: "only for history without a step journal" }, { value: "off" }], apply: bool("summarizeOnSwitch") },
|
|
1446
1720
|
{ key: "checkpoints", label: "Snapshots for /undo", value: onOff(c.checkpoints), choices: [{ value: "on" }, { value: "off", description: "also turns off code diffs in handoffs" }], apply: bool("checkpoints") },
|
|
@@ -1454,6 +1728,9 @@ export class ChatApp {
|
|
|
1454
1728
|
saveConfigKey("bellAfterSeconds", c.bellAfterSeconds);
|
|
1455
1729
|
},
|
|
1456
1730
|
},
|
|
1731
|
+
{ key: "questions", label: "Agent questions", value: c.questions ?? "ask", choices: [{ value: "ask", description: "show the agent's question and wait for your answer" }, { value: "timeout", label: "ask, skip after 5 min", description: "for unattended runs (keep awake): an unanswered question is skipped and the agent carries on" }, { value: "off", description: "agents never ask; they decide themselves (new sessions)" }], apply: (v) => { c.questions = v as Config["questions"]; saveConfigKey("questions", c.questions); } },
|
|
1732
|
+
{ key: "terminalStatus", label: "Terminal tab status", value: c.terminalStatus === false ? "off" : "on", choices: [{ value: "on", description: "Warp shows the agent's badge and running / waiting / done; other terminals get OSC 7501 status" }, { value: "off" }], apply: (v) => { c.terminalStatus = v === "on"; saveConfigKey("terminalStatus", c.terminalStatus); } },
|
|
1733
|
+
{ key: "notifications", label: "Desktop notifications", value: c.notifications === false ? "off" : "on", choices: [{ value: "on", description: "when an agent waits for you or a long turn ends, while you are in another window" }, { value: "off" }], apply: (v) => { c.notifications = v === "on"; saveConfigKey("notifications", c.notifications); } },
|
|
1457
1734
|
{ key: "updateCheck", label: "Check for codemax updates", value: c.updateCheck === false ? "off" : "on", choices: [{ value: "on", description: "asks npm at most every 12 hours" }, { value: "off" }], apply: (v) => { c.updateCheck = v === "on"; saveConfigKey("updateCheck", c.updateCheck); } },
|
|
1458
1735
|
{ key: "priceUpdates", label: "Weekly price list refresh", value: onOff(c.priceUpdates), choices: [{ value: "on" }, { value: "off", description: "stay fully offline" }], apply: bool("priceUpdates") },
|
|
1459
1736
|
];
|
|
@@ -1503,28 +1780,6 @@ export class ChatApp {
|
|
|
1503
1780
|
this.renderer.root.add(this.palette.box);
|
|
1504
1781
|
}
|
|
1505
1782
|
|
|
1506
|
-
/** /side with no arguments: pick the agent (then its model). */
|
|
1507
|
-
private openSidePicker() {
|
|
1508
|
-
const cfg = this.config.sideAgent;
|
|
1509
|
-
const items: PaletteItem[] = listAdapters(this.config.order)
|
|
1510
|
-
.filter((a) => this.detect.get(a.id)?.engine)
|
|
1511
|
-
// A different subscription first: the same tool would share (and drain) the main agent's plan.
|
|
1512
|
-
.map((a) => ({ label: a.name + (a.id === cfg?.agent ? " (current)" : ""), description: a.id === this.active?.agent ? "same plan as the main agent: reviews count against its limit" : "its own subscription: reviews do not touch the main agent's limit", value: a.id, weight: a.id === this.active?.agent ? 0 : 10 }));
|
|
1513
|
-
items.push({ label: "Off", description: "no side agent", value: "off" });
|
|
1514
|
-
this.closePalette();
|
|
1515
|
-
this.paletteKind = "modes";
|
|
1516
|
-
this.palette = new Palette(this.renderer, {
|
|
1517
|
-
title: "Side agent · reviews each turn and leaves a note when something needs attention",
|
|
1518
|
-
items,
|
|
1519
|
-
onPick: (it) => {
|
|
1520
|
-
this.closePalette();
|
|
1521
|
-
if (it.value === "off") return void this.setSide("off");
|
|
1522
|
-
void this.enableSide(it.value as AgentId, { forward: cfg?.forward ?? "ask", pickModel: true });
|
|
1523
|
-
},
|
|
1524
|
-
onClose: () => this.closePalette(),
|
|
1525
|
-
}, this.paletteAnchor);
|
|
1526
|
-
this.renderer.root.add(this.palette.box);
|
|
1527
|
-
}
|
|
1528
1783
|
|
|
1529
1784
|
/** The side agent's session, started on first use with the chosen model. */
|
|
1530
1785
|
private async ensureReviewer() {
|
|
@@ -1537,7 +1792,8 @@ export class ChatApp {
|
|
|
1537
1792
|
const r = { agent: cfg.agent, name: getAdapter(cfg.agent).name, engine, busy: false, pending: false, since: this.lastReviewBaseline(), primed: false };
|
|
1538
1793
|
this.reviewer = r;
|
|
1539
1794
|
try {
|
|
1540
|
-
|
|
1795
|
+
// The side agent never interrupts you with questions.
|
|
1796
|
+
await engine.start({ cwd: this.cwd, questions: false });
|
|
1541
1797
|
if (cfg.model && engine.setModel && engine.info?.currentModel !== cfg.model) await engine.setModel(cfg.model).catch((e: any) => this.transcript.note(`side agent: could not use model ${cfg.model} (${e?.message ?? e}); using ${engine.info?.currentModel ?? "its default"}`, C.warn));
|
|
1542
1798
|
return r;
|
|
1543
1799
|
} catch (e: any) {
|
|
@@ -1557,6 +1813,15 @@ export class ChatApp {
|
|
|
1557
1813
|
private async review() {
|
|
1558
1814
|
const cfg = this.config.sideAgent;
|
|
1559
1815
|
if (!cfg?.agent || this.closed) return;
|
|
1816
|
+
// Reviews never spend the main agent's plan unless you chose that (/config). After a handoff
|
|
1817
|
+
// onto the side agent's own tool, they pause instead.
|
|
1818
|
+
if (cfg.agent === this.active?.agent && !cfg.samePlan) {
|
|
1819
|
+
if (!this.samePlanNoted) this.transcript.note(`side agent paused: ${getAdapter(cfg.agent).name} is now the main agent, so its reviews would use the same plan · pick another side agent, or allow sharing, in /config`, C.dim);
|
|
1820
|
+
this.samePlanNoted = true;
|
|
1821
|
+
this.turnsSinceReview = 0;
|
|
1822
|
+
return;
|
|
1823
|
+
}
|
|
1824
|
+
this.samePlanNoted = false;
|
|
1560
1825
|
if (this.reviewer?.busy) {
|
|
1561
1826
|
this.reviewer.pending = true;
|
|
1562
1827
|
return;
|
|
@@ -1584,6 +1849,9 @@ export class ChatApp {
|
|
|
1584
1849
|
// A reviewer never acts: refuse anything it asks to do.
|
|
1585
1850
|
const no = e.request.options.find((o) => /reject|deny|decline/i.test(`${o.kind} ${o.id}`)) ?? e.request.options.at(-1);
|
|
1586
1851
|
if (no) engine.answerPermission(e.request.id, no.id);
|
|
1852
|
+
} else if (e.type === "question_request") {
|
|
1853
|
+
// Nor does it interrupt you: its questions are skipped.
|
|
1854
|
+
engine.answerQuestion?.(e.request.id, { action: "decline" });
|
|
1587
1855
|
} else if (e.type === "usage") this.recordUsage(r.agent, engine, e);
|
|
1588
1856
|
else if (e.type === "error" && e.kind === "quota") limited = true;
|
|
1589
1857
|
} catch {
|
|
@@ -1625,15 +1893,44 @@ export class ChatApp {
|
|
|
1625
1893
|
const task = this.ledger.journal.task().request?.id ?? "none";
|
|
1626
1894
|
const sent = this.autoForwards.get(task) ?? 0;
|
|
1627
1895
|
const capped = auto && sent >= AUTO_FORWARD_CAP;
|
|
1628
|
-
this.transcript.noteCard(
|
|
1629
|
-
`⚑ Note from ${name} (side agent)`,
|
|
1630
|
-
text,
|
|
1631
|
-
auto && !capped ? `sent to ${main} automatically` : capped ? `auto-forward paused: ${AUTO_FORWARD_CAP} notes already sent for this task · /forward to send this one` : `/forward sends this to ${main} · /notes lists all notes`,
|
|
1632
|
-
);
|
|
1633
1896
|
if (auto && !capped) {
|
|
1634
1897
|
this.autoForwards.set(task, sent + 1);
|
|
1898
|
+
this.transcript.noteCard(`⚑ Heads up from ${name} (side agent)`, text, `sent to ${main} automatically`);
|
|
1635
1899
|
this.forwardNote(this.notes.length - 1);
|
|
1900
|
+
return;
|
|
1636
1901
|
}
|
|
1902
|
+
if (capped) this.transcript.note(`auto-forward paused: ${AUTO_FORWARD_CAP} notes already sent for this task`, C.dim);
|
|
1903
|
+
this.notePanel.push({ name, text });
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
/**
|
|
1907
|
+
* The prompt holds only 0, 1 or 2 while a note shows: after a short pause that
|
|
1908
|
+
* digit acts on the note (and leaves the prompt). Typing on, before the pause
|
|
1909
|
+
* ends, keeps it a normal message, so "2 more tests please" never touches it.
|
|
1910
|
+
*/
|
|
1911
|
+
private armNote(text: string) {
|
|
1912
|
+
if (this.noteArmTimer) clearTimeout(this.noteArmTimer);
|
|
1913
|
+
this.noteArmTimer = null;
|
|
1914
|
+
const k = text;
|
|
1915
|
+
if (!this.notePanel.count || this.notePanel.isPaused || !/^[012]$/.test(k)) return;
|
|
1916
|
+
this.noteArmTimer = setTimeout(() => this.safely("note", () => {
|
|
1917
|
+
this.noteArmTimer = null;
|
|
1918
|
+
if (this.closed || this.input.plainText !== k || !this.notePanel.count) return;
|
|
1919
|
+
this.input.clear();
|
|
1920
|
+
this.noteKey(k as "0" | "1" | "2");
|
|
1921
|
+
}), NOTE_ARM_MS);
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
/** Act on the note shown above the prompt: 1 expand / collapse, 2 discuss, 0 dismiss. */
|
|
1925
|
+
private noteKey(k: "0" | "1" | "2") {
|
|
1926
|
+
const n = this.notePanel.current;
|
|
1927
|
+
if (!n) return;
|
|
1928
|
+
if (k === "1") return this.notePanel.toggle();
|
|
1929
|
+
this.notePanel.take();
|
|
1930
|
+
const { headline } = splitNote(n.text);
|
|
1931
|
+
if (k === "0") return this.transcript.note(`dismissed ${n.name}'s note: ${headline}`, C.dim);
|
|
1932
|
+
const i = this.notes.findIndex((x) => x.text === n.text);
|
|
1933
|
+
this.forwardNote(i >= 0 ? i : this.notes.length - 1);
|
|
1637
1934
|
}
|
|
1638
1935
|
|
|
1639
1936
|
private forwardNote(index = this.notes.length - 1) {
|
|
@@ -1644,14 +1941,18 @@ export class ChatApp {
|
|
|
1644
1941
|
if (!a) return this.transcript.note("no main agent to forward to", C.warn);
|
|
1645
1942
|
const msg = forwardMessage(n.text, n.name);
|
|
1646
1943
|
if (a.turn || this.switching) {
|
|
1647
|
-
this.
|
|
1648
|
-
this.transcript.note(`note queued for ${a.name} until its turn ends`);
|
|
1649
|
-
this.renderFooter();
|
|
1944
|
+
this.enqueue(msg);
|
|
1650
1945
|
return;
|
|
1651
1946
|
}
|
|
1652
1947
|
void this.send(a, msg);
|
|
1653
1948
|
}
|
|
1654
1949
|
|
|
1950
|
+
/** The model's display name as the agent lists it ("Opus 5.5"), else a short id. */
|
|
1951
|
+
private modelName(a: Active, id?: string): string {
|
|
1952
|
+
const mid = id ?? a.engine.info?.currentModel;
|
|
1953
|
+
return mid ? displayModelName(mid, a.engine.info?.models) : "default model";
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1655
1956
|
private announceUpdate(version: string) {
|
|
1656
1957
|
if (this.closed) return;
|
|
1657
1958
|
this.updateAvailable = version;
|
|
@@ -1686,20 +1987,6 @@ export class ChatApp {
|
|
|
1686
1987
|
this.renderFooter();
|
|
1687
1988
|
}
|
|
1688
1989
|
|
|
1689
|
-
private async reviewNow() {
|
|
1690
|
-
if (!this.config.sideAgent?.agent) return this.transcript.note("no side agent · /side to pick one", C.warn);
|
|
1691
|
-
this.transcript.note(`asking ${getAdapter(this.config.sideAgent.agent).name} to review…`);
|
|
1692
|
-
const before = this.notes.length;
|
|
1693
|
-
await this.review();
|
|
1694
|
-
if (this.notes.length === before && !this.closed) this.transcript.note("the side agent found nothing worth a note");
|
|
1695
|
-
}
|
|
1696
|
-
|
|
1697
|
-
private showNotes() {
|
|
1698
|
-
if (!this.notes.length) return this.transcript.note(this.config.sideAgent?.agent ? "no notes yet: the side agent only speaks up when something needs attention" : "no side agent · /side codex adds one");
|
|
1699
|
-
this.notes.forEach((n, i) => this.transcript.note(`${i + 1}. ${fmtTime(n.ts)} ${n.name}: ${n.text.split("\n")[0]!.slice(0, 140)}`));
|
|
1700
|
-
this.transcript.note("/forward <n> sends a note to the main agent");
|
|
1701
|
-
}
|
|
1702
|
-
|
|
1703
1990
|
private startSpinner(active: Active) {
|
|
1704
1991
|
this.stopSpinner();
|
|
1705
1992
|
this.spinnerTimer = setInterval(() => {
|
|
@@ -1710,11 +1997,11 @@ export class ChatApp {
|
|
|
1710
1997
|
const secs = fmtDuration(Date.now() - t.startedAt);
|
|
1711
1998
|
const approx = t.tokens || Math.round(t.chars / 4);
|
|
1712
1999
|
const tok = approx ? ` · ↓ ${t.tokens ? "" : "~"}${approx} tokens` : "";
|
|
1713
|
-
if (this.permission) this.setWorking(`${glyph} Waiting for your approval (${secs}) · esc to say no`);
|
|
2000
|
+
if (this.permission) this.setWorking(this.permission instanceof QuestionDialog ? `${glyph} Waiting for your answer (${secs}) · esc to skip` : `${glyph} Waiting for your approval (${secs}) · esc to say no`);
|
|
1714
2001
|
else {
|
|
1715
2002
|
const elapsed = Date.now() - t.startedAt;
|
|
1716
2003
|
const tip = elapsed > 8000 ? ` ⎿ tip: ${pick(TIPS, Math.floor(t.startedAt / 1000))}` : "";
|
|
1717
|
-
this.setWorking(`${glyph} ${t.verb}… (${secs}${tok}) · esc to interrupt${tip}`);
|
|
2004
|
+
this.setWorking(`${glyph} ${t.verb}… (${secs}${tok}) · ${active.name} · ${this.modelName(active, t.model)} · esc to interrupt${tip}`);
|
|
1718
2005
|
}
|
|
1719
2006
|
}, 120);
|
|
1720
2007
|
}
|
|
@@ -1771,11 +2058,11 @@ export class ChatApp {
|
|
|
1771
2058
|
else if (!this.shortcuts.visible) left.push({ text: "? for shortcuts", prio: 2 });
|
|
1772
2059
|
const right: { text: string; prio: number }[] = [];
|
|
1773
2060
|
if (this.updateAvailable) left.push({ text: `↑ ${this.updateAvailable} available · /update`, prio: 1 });
|
|
1774
|
-
if (this.config.sideAgent?.agent) left.push({ text: `◆ side ${this.config.sideAgent.agent}${this.config.sideAgent.model ? ` ${
|
|
2061
|
+
if (this.config.sideAgent?.agent) left.push({ text: `◆ side ${this.config.sideAgent.agent}${this.config.sideAgent.model ? ` ${displayModelName(this.config.sideAgent.model, this.reviewer?.engine.info?.models)}` : ""}${this.reviewer?.busy ? " reviewing…" : ""}`, prio: 2 });
|
|
1775
2062
|
// Narrow glyph: the footer is measured in characters, and an emoji takes two columns.
|
|
1776
2063
|
if (this.awake.mode !== "off") left.push({ text: `● awake${this.awake.status.lid ? " · lid" : ""}`, prio: 1 });
|
|
1777
2064
|
const effort = a ? this.effortOption(a)?.current : undefined;
|
|
1778
|
-
if (a) right.push({ text: `${a.name}${a.engine.info?.currentModel ? ` · ${
|
|
2065
|
+
if (a) right.push({ text: `${a.name}${a.engine.info?.currentModel ? ` · ${displayModelName(a.engine.info.currentModel, a.engine.info.models)}` : ""}${effort ? ` ${effort}` : ""}`, prio: 2 });
|
|
1779
2066
|
if (a?.context) right.push({ text: `ctx ${Math.min(100, Math.round((a.context.used / a.context.size) * 100))}%`, prio: 1 });
|
|
1780
2067
|
const bk: BudgetKey | null = a && this.config.budgets?.[a.agent] ? a.agent : this.config.budgets?.all ? "all" : null;
|
|
1781
2068
|
if (bk) right.push({ text: `${fmtUsd(this.today.get(bk) ?? 0)}/${fmtUsd(this.config.budgets[bk])} today`, prio: 1 });
|
|
@@ -1807,14 +2094,17 @@ export class ChatApp {
|
|
|
1807
2094
|
listAdapters(this.config.order).map(async (a) => {
|
|
1808
2095
|
const d = await a.detect();
|
|
1809
2096
|
const f = engineFactories[a.id];
|
|
1810
|
-
const engine = !!f && (await f.available().catch(() => false));
|
|
1811
|
-
this.detect.set(a.id, { installed: d.installed, version: d.version, engine });
|
|
2097
|
+
const engine = d.installed && !!f && (await f.available().catch(() => false));
|
|
2098
|
+
this.detect.set(a.id, { installed: d.installed, version: d.version, engine, problem: d.problem });
|
|
1812
2099
|
}),
|
|
1813
2100
|
);
|
|
1814
2101
|
}
|
|
1815
2102
|
|
|
1816
2103
|
private async switchTo(agent: AgentId, fresh: boolean, o: { resumeTask?: boolean } = {}) {
|
|
1817
2104
|
if (!hasEngine(agent)) return this.transcript.note(`${agent} has no protocol engine; run codemax --tui for it`, C.warn);
|
|
2105
|
+
// Every switch path (menu, /switch, routing, handoff offers) ends here, so an agent that is not really installed is refused here.
|
|
2106
|
+
const d = this.detect.get(agent);
|
|
2107
|
+
if (d && !d.installed) return this.transcript.note(d.problem ?? `${getAdapter(agent).name} is not installed`, C.warn);
|
|
1818
2108
|
this.switching = true;
|
|
1819
2109
|
const prev = this.active;
|
|
1820
2110
|
let from: AgentId | undefined;
|
|
@@ -1849,6 +2139,9 @@ export class ChatApp {
|
|
|
1849
2139
|
|
|
1850
2140
|
async quit() {
|
|
1851
2141
|
this.closed = true;
|
|
2142
|
+
if (this.noteArmTimer) clearTimeout(this.noteArmTimer);
|
|
2143
|
+
this.term.close();
|
|
2144
|
+
this.subagents.destroy();
|
|
1852
2145
|
await this.awake.release().catch(() => []);
|
|
1853
2146
|
await this.closeReviewer();
|
|
1854
2147
|
this.switching = true;
|
|
@@ -1869,10 +2162,38 @@ export class ChatApp {
|
|
|
1869
2162
|
}
|
|
1870
2163
|
}
|
|
1871
2164
|
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
2165
|
+
/**
|
|
2166
|
+
* Does the agent's reply end by asking you something ("Option 1 or 2?")? Then a lone
|
|
2167
|
+
* digit you type is an answer, not a note key.
|
|
2168
|
+
*/
|
|
2169
|
+
export function asksUser(reply: string): boolean {
|
|
2170
|
+
const tail = reply.trim().slice(-400);
|
|
2171
|
+
if (!tail) return false;
|
|
2172
|
+
const lastLine = tail.split("\n").filter((l) => l.trim()).pop() ?? "";
|
|
2173
|
+
return /\?\s*[)*_`"']*\s*$/.test(lastLine) || (/\?/.test(tail) && /^\s*(?:[-*]\s*)?\(?[0-9][.)]/m.test(tail));
|
|
2174
|
+
}
|
|
2175
|
+
|
|
2176
|
+
/** Rows `text` takes when word-wrapped at `width` columns (as the prompt wraps it). */
|
|
2177
|
+
export function wrappedLines(text: string, width: number): number {
|
|
2178
|
+
let rows = 0;
|
|
2179
|
+
for (const line of text.split("\n")) {
|
|
2180
|
+
let col = 0;
|
|
2181
|
+
rows++;
|
|
2182
|
+
for (const word of line.split(/(?<= )/)) {
|
|
2183
|
+
const w = Bun.stringWidth(word);
|
|
2184
|
+
if (col + Bun.stringWidth(word.trimEnd()) <= width) col += w;
|
|
2185
|
+
else if (w <= width) {
|
|
2186
|
+
rows++;
|
|
2187
|
+
col = w;
|
|
2188
|
+
} else {
|
|
2189
|
+
// A word longer than the line breaks by characters.
|
|
2190
|
+
const extra = col ? 1 : 0;
|
|
2191
|
+
rows += extra + Math.ceil(w / width) - 1;
|
|
2192
|
+
col = w % width || width;
|
|
2193
|
+
}
|
|
2194
|
+
}
|
|
2195
|
+
}
|
|
2196
|
+
return rows;
|
|
1876
2197
|
}
|
|
1877
2198
|
|
|
1878
2199
|
function ago(ts: number): string {
|
|
@@ -1883,14 +2204,14 @@ function ago(ts: number): string {
|
|
|
1883
2204
|
return h < 48 ? `${h}h ago` : `${Math.round(h / 24)}d ago`;
|
|
1884
2205
|
}
|
|
1885
2206
|
|
|
1886
|
-
export async function runChat(config: Config, ledger: Ledger, cwd: string, agent: AgentId, fresh: boolean, pick = false) {
|
|
2207
|
+
export async function runChat(config: Config, ledger: Ledger, cwd: string, agent: AgentId, fresh: boolean, pick = false, explicit = false) {
|
|
1887
2208
|
const renderer = await createCliRenderer({ exitOnCtrlC: false, exitSignals: ["SIGTERM", "SIGHUP"], useMouse: true, targetFps: 60 });
|
|
1888
2209
|
const pref = config.theme ?? "auto";
|
|
1889
2210
|
applyTheme(pref === "auto" ? ((await renderer.waitForThemeMode(250).catch(() => null)) ?? "dark") : pref);
|
|
1890
2211
|
const app = new ChatApp(renderer, config, ledger, cwd);
|
|
1891
2212
|
process.once("exit", () => app.awake.releaseSync());
|
|
1892
2213
|
try {
|
|
1893
|
-
await app.start(agent, fresh, { pick });
|
|
2214
|
+
await app.start(agent, fresh, { pick, explicit });
|
|
1894
2215
|
} finally {
|
|
1895
2216
|
renderer.destroy();
|
|
1896
2217
|
try {
|
|
@@ -1930,5 +2251,5 @@ async function copyToClipboard(renderer: CliRenderer, text: string): Promise<boo
|
|
|
1930
2251
|
|
|
1931
2252
|
function reviewCadence(every: number | undefined): string {
|
|
1932
2253
|
const n = every ?? 1;
|
|
1933
|
-
return n === 0 ? "
|
|
2254
|
+
return n === 0 ? "never (reviews paused)" : n === 1 ? "every turn" : n === 2 ? "every 2nd turn" : n === 3 ? "every 3rd turn" : `every ${n}th turn`;
|
|
1934
2255
|
}
|