codemax-cli 0.1.4 → 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 +2 -2
- package/src/adapters/simple.ts +7 -6
- package/src/adapters/types.ts +3 -1
- package/src/chat/app.ts +374 -123
- package/src/chat/models.ts +25 -0
- package/src/chat/notes.ts +101 -0
- package/src/chat/palette.ts +14 -1
- package/src/chat/question.ts +192 -0
- package/src/chat/subagents.ts +107 -0
- package/src/cli.ts +2 -1
- package/src/config.ts +17 -4
- package/src/core/termstatus.ts +124 -0
- package/src/core/usage.ts +4 -2
- package/src/engines/acp.ts +123 -10
- package/src/engines/antigravity.ts +95 -0
- package/src/engines/codex.ts +69 -28
- package/src/engines/elicitation.ts +60 -0
- package/src/engines/index.ts +6 -3
- package/src/engines/types.ts +45 -0
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,13 +32,21 @@ 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";
|
|
34
|
-
import { sortModels } from "./models.ts";
|
|
35
|
+
import { displayModelName, sortModels } from "./models.ts";
|
|
35
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";
|
|
36
41
|
|
|
37
42
|
const HOTKEY_NAMES: Record<string, string[]> = { "/": ["/", "_", "?"], "^": ["^", "6"] };
|
|
38
43
|
const VERSION = pkg.version;
|
|
39
44
|
const MODE_CYCLE = ["default", "acceptEdits", "plan"];
|
|
40
|
-
/**
|
|
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. */
|
|
41
50
|
const AUTO_FORWARD_CAP = 2;
|
|
42
51
|
|
|
43
52
|
interface Active {
|
|
@@ -67,9 +76,11 @@ interface Builtin {
|
|
|
67
76
|
export class ChatApp {
|
|
68
77
|
private active: Active | null = null;
|
|
69
78
|
private palette: Palette | null = null;
|
|
70
|
-
|
|
71
|
-
private
|
|
72
|
-
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 }>();
|
|
73
84
|
private quota = new Map<AgentId, QuotaStatus>();
|
|
74
85
|
private hotkeys: HotkeySpec[];
|
|
75
86
|
private transcript: Transcript;
|
|
@@ -94,15 +105,21 @@ export class ChatApp {
|
|
|
94
105
|
private side = new Set<Active>();
|
|
95
106
|
private sessionTokens = 0;
|
|
96
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;
|
|
97
110
|
private draft = "";
|
|
98
111
|
private queue: string[] = [];
|
|
99
112
|
private lastCtrlC = 0;
|
|
100
113
|
private switching = false;
|
|
101
114
|
/** Set by quit(); async work that resumes afterwards (a handoff being built) stops. */
|
|
102
115
|
private closed = false;
|
|
103
|
-
/** Side agent (/
|
|
116
|
+
/** Side agent (set up in /config): reviews the main agent's work after each turn. */
|
|
104
117
|
private reviewer: { agent: AgentId; name: string; engine: Engine; busy: boolean; pending: boolean; since: number; primed: boolean } | null = null;
|
|
105
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;
|
|
106
123
|
/** Plan limits each agent reported, by window ("five_hour", "seven_day"…). */
|
|
107
124
|
private planLimits = new Map<AgentId, Map<string, { usedPercent?: number; resetsAt?: number; status?: string; at: number }>>();
|
|
108
125
|
/** Working turns since the side agent last reviewed (for `sideAgent.every`). */
|
|
@@ -115,6 +132,8 @@ export class ChatApp {
|
|
|
115
132
|
updateChecker: (current: string) => Promise<string | null> = (current) => (process.env.NODE_ENV === "test" ? Promise.resolve(null) : checkForUpdate(current));
|
|
116
133
|
/** Keep-awake (/awake). Tests swap its system runner. */
|
|
117
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;
|
|
118
137
|
private resolveDone!: () => void;
|
|
119
138
|
readonly done: Promise<void>;
|
|
120
139
|
/** Test hooks. */
|
|
@@ -141,6 +160,9 @@ export class ChatApp {
|
|
|
141
160
|
root.add(this.working);
|
|
142
161
|
this.dialogHost = new BoxRenderable(renderer, { id: "dialog-host", width: "100%", flexShrink: 0, flexDirection: "column" });
|
|
143
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);
|
|
144
166
|
// Queued messages wait here, pinned just above the prompt, until they are sent.
|
|
145
167
|
this.queueBox = new BoxRenderable(renderer, { id: "queue", width: "100%", flexShrink: 0, flexDirection: "column", paddingX: 1 });
|
|
146
168
|
this.queueBox.visible = false;
|
|
@@ -165,11 +187,14 @@ export class ChatApp {
|
|
|
165
187
|
inputRow.add(this.input);
|
|
166
188
|
this.inputWrap.add(inputRow);
|
|
167
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);
|
|
168
193
|
this.shortcuts = new BoxRenderable(renderer, { id: "shortcuts", width: "100%", flexShrink: 0, flexDirection: "column", paddingX: 2 });
|
|
169
194
|
this.shortcuts.visible = false;
|
|
170
195
|
for (const l of [
|
|
171
196
|
"/ for commands shift + tab to cycle permission mode esc to interrupt a turn",
|
|
172
|
-
"/switch to change agent ↑ for history
|
|
197
|
+
"/switch to change agent ↑ for history (or to edit the queue) ctrl + c twice to quit",
|
|
173
198
|
"/mode to set permissions shift + ⏎ for newline ? to toggle this help",
|
|
174
199
|
"! to run a shell command /model to pick a model /compact for a handoff summary",
|
|
175
200
|
"@ to attach a file /undo reverts the last turn's edits /ask <agent> <msg> for a one-off",
|
|
@@ -189,8 +214,15 @@ export class ChatApp {
|
|
|
189
214
|
void this.copyText(text).then((ok) => this.setFlash(ok ? `copied ${text.length} chars` : "could not copy: no clipboard available"));
|
|
190
215
|
});
|
|
191
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));
|
|
192
223
|
renderer.keyInput.on("keypress", this.onKey);
|
|
193
224
|
renderer.on("resize", () => this.safely("resize", () => {
|
|
225
|
+
this.fitInput();
|
|
194
226
|
this.palette?.render();
|
|
195
227
|
this.renderFooter();
|
|
196
228
|
}));
|
|
@@ -209,7 +241,7 @@ export class ChatApp {
|
|
|
209
241
|
const ok = (a: AgentId) => this.detect.get(a)?.installed && this.detect.get(a)?.engine;
|
|
210
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"}`);
|
|
211
243
|
const first = ok(agent) ? agent : listAdapters(this.config.order).find((a) => ok(a.id))?.id;
|
|
212
|
-
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)");
|
|
213
245
|
const notes: string[] = [];
|
|
214
246
|
if (first !== agent) notes.push(`${agent} has no protocol engine here; starting ${first}`);
|
|
215
247
|
for (const n of await recoverAwake({ ...defaultAwakeDeps(), interactive: (fn) => this.withTerminal(fn) }).catch(() => [])) notes.push(n);
|
|
@@ -279,6 +311,17 @@ export class ChatApp {
|
|
|
279
311
|
void this.cycleMode();
|
|
280
312
|
return;
|
|
281
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
|
+
}
|
|
282
325
|
if (key.name === "escape") {
|
|
283
326
|
if (this.active?.turn) {
|
|
284
327
|
key.stopPropagation();
|
|
@@ -312,6 +355,11 @@ export class ChatApp {
|
|
|
312
355
|
this.lastCtrlC = now;
|
|
313
356
|
return;
|
|
314
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
|
+
}
|
|
315
363
|
if (key.name === "up" && !this.input.plainText.includes("\n") && (this.history.length || this.draft)) {
|
|
316
364
|
if (this.input.plainText === "" || this.historyIdx >= 0) {
|
|
317
365
|
key.stopPropagation();
|
|
@@ -363,8 +411,26 @@ export class ChatApp {
|
|
|
363
411
|
}
|
|
364
412
|
private lastDraft = "";
|
|
365
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
|
+
|
|
366
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);
|
|
367
432
|
const text = this.input.plainText;
|
|
433
|
+
this.armNote(text);
|
|
368
434
|
if (this.historyIdx >= 0 && text !== this.history[this.historyIdx]) this.historyIdx = -1;
|
|
369
435
|
const mention = text.match(MENTION_RE);
|
|
370
436
|
if (mention && !text.startsWith("/")) {
|
|
@@ -388,7 +454,19 @@ export class ChatApp {
|
|
|
388
454
|
|
|
389
455
|
private async submit() {
|
|
390
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
|
+
}
|
|
391
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
|
+
}
|
|
392
470
|
if (this.palette && this.paletteKind === "commands") {
|
|
393
471
|
// Enter on an exact or highlighted command runs it; Enter on a typo does nothing dangerous.
|
|
394
472
|
const cur = this.palette.current;
|
|
@@ -498,7 +576,7 @@ export class ChatApp {
|
|
|
498
576
|
this.transcript.divider(`${why} · ${adapter.name}`);
|
|
499
577
|
this.transcript.user(text);
|
|
500
578
|
try {
|
|
501
|
-
await engine.start({ cwd: this.cwd });
|
|
579
|
+
await engine.start({ cwd: this.cwd, questions: this.config.questions !== "off" });
|
|
502
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" }) : "";
|
|
503
581
|
const body = this.withAttachments(text);
|
|
504
582
|
await this.checkpoint(side, text);
|
|
@@ -661,7 +739,11 @@ export class ChatApp {
|
|
|
661
739
|
|
|
662
740
|
/** Queue a message (or command) until the agent's turn ends; it shows above the prompt. */
|
|
663
741
|
private enqueue(text: string) {
|
|
664
|
-
|
|
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);
|
|
665
747
|
this.renderQueue();
|
|
666
748
|
this.renderFooter();
|
|
667
749
|
}
|
|
@@ -675,10 +757,44 @@ export class ChatApp {
|
|
|
675
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));
|
|
676
758
|
}
|
|
677
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 }));
|
|
678
|
-
|
|
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;
|
|
679
765
|
});
|
|
680
766
|
}
|
|
681
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
|
+
|
|
682
798
|
/** Run one queued entry the same way as if it had just been typed. */
|
|
683
799
|
private async dispatch(active: Active, text: string) {
|
|
684
800
|
if (text.startsWith("!")) return this.shell(text.slice(1).trim());
|
|
@@ -697,6 +813,7 @@ export class ChatApp {
|
|
|
697
813
|
|
|
698
814
|
private async send(active: Active, text: string) {
|
|
699
815
|
if (this.closed) return;
|
|
816
|
+
if (!this.side.has(active)) this.notePanel.setPaused(false);
|
|
700
817
|
this.transcript.user(text);
|
|
701
818
|
text = this.withAttachments(text);
|
|
702
819
|
const cp = await this.checkpoint(active, text);
|
|
@@ -736,10 +853,6 @@ export class ChatApp {
|
|
|
736
853
|
{ name: "/budget", description: "Today's spend against your daily budgets", run: () => this.showBudget() },
|
|
737
854
|
{ name: "/update", description: "Update codemax to the latest version", run: () => void this.runUpdate() },
|
|
738
855
|
{ name: "/config", description: "Settings: theme, keep awake, side agent and model, handoffs, checkpoints…", run: () => this.openConfig() },
|
|
739
|
-
{ 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) },
|
|
740
|
-
{ name: "/review", description: "Ask the side agent to review the latest work now", run: () => void this.reviewNow() },
|
|
741
|
-
{ name: "/notes", description: "Notes the side agent left", run: () => this.showNotes() },
|
|
742
|
-
{ 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) },
|
|
743
856
|
{ name: "/awake", description: "Keep the machine awake while agents work: /awake on | lid | off", run: (a) => void this.setAwake(a) },
|
|
744
857
|
{ name: "/theme", description: "Colour theme: /theme dark | light | auto (saved; restart to apply everywhere)", run: (a) => this.setThemePref(a) },
|
|
745
858
|
{ name: "/quit", description: "Quit codemax", run: () => void this.quit() },
|
|
@@ -866,7 +979,7 @@ export class ChatApp {
|
|
|
866
979
|
const apply = async (id: string) => {
|
|
867
980
|
try {
|
|
868
981
|
await a.engine.setModel!(id);
|
|
869
|
-
this.transcript.note(`model: ${id}`);
|
|
982
|
+
this.transcript.note(`model: ${displayModelName(id, a.engine.info?.models)}`);
|
|
870
983
|
if (a.agent === "cursor") this.transcript.note("Cursor saves this as its default model, so Cursor uses it outside codemax too", C.warn);
|
|
871
984
|
this.renderFooter();
|
|
872
985
|
} catch (e: any) {
|
|
@@ -879,7 +992,7 @@ export class ChatApp {
|
|
|
879
992
|
this.palette = new Palette(this.renderer, {
|
|
880
993
|
title: `Model for ${a.name}`,
|
|
881
994
|
// The agent's own list, newest first, by its display name (e.g. "Opus 5.5" for the id "opus").
|
|
882
|
-
items: sortModels(models).map((m, i) => ({ label:
|
|
995
|
+
items: sortModels(models).map((m, i) => ({ label: `${m.name}${m.id === a.engine.info?.currentModel ? " (current)" : ""}`, value: m.id, weight: 100 - i })),
|
|
883
996
|
onPick: (it) => {
|
|
884
997
|
this.closePalette();
|
|
885
998
|
void apply(it.value);
|
|
@@ -944,17 +1057,26 @@ export class ChatApp {
|
|
|
944
1057
|
const block = this.transcript.agent();
|
|
945
1058
|
a.turn = { block, tools: new Set(), startedAt: Date.now(), tokens: 0, chars: 0, verb: "Summarizing" };
|
|
946
1059
|
this.startSpinner(a);
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
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();
|
|
955
1075
|
}
|
|
956
|
-
|
|
957
|
-
|
|
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;
|
|
958
1080
|
}
|
|
959
1081
|
|
|
960
1082
|
private async showTask() {
|
|
@@ -1001,7 +1123,7 @@ export class ChatApp {
|
|
|
1001
1123
|
private showStatus() {
|
|
1002
1124
|
const a = this.active;
|
|
1003
1125
|
const lines = [
|
|
1004
|
-
`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"}`,
|
|
1005
1127
|
`session: ${a?.engine.session?.id ?? "-"} mode: ${a?.mode ?? "-"}`,
|
|
1006
1128
|
`ledger: ${this.ledger.dir} (${this.ledger.turns().length} turns)`,
|
|
1007
1129
|
];
|
|
@@ -1059,7 +1181,9 @@ export class ChatApp {
|
|
|
1059
1181
|
const q = this.quota.get(a.id);
|
|
1060
1182
|
const usable = !!d?.installed && !!d?.engine;
|
|
1061
1183
|
const bits: string[] = [];
|
|
1062
|
-
|
|
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(/^ · /, ""));
|
|
1063
1187
|
else if (!d.engine) bits.push("no protocol · use codemax --tui");
|
|
1064
1188
|
else {
|
|
1065
1189
|
if (d.version) bits.push(d.version);
|
|
@@ -1067,7 +1191,7 @@ export class ChatApp {
|
|
|
1067
1191
|
if (q && quotaLabel(q)) bits.push(quotaLabel(q));
|
|
1068
1192
|
if (NO_PERMISSION_PROMPTS.has(a.id)) bits.push("acts without asking");
|
|
1069
1193
|
}
|
|
1070
|
-
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 };
|
|
1071
1195
|
});
|
|
1072
1196
|
}
|
|
1073
1197
|
|
|
@@ -1082,6 +1206,7 @@ export class ChatApp {
|
|
|
1082
1206
|
this.closePalette();
|
|
1083
1207
|
const id = it.value as AgentId;
|
|
1084
1208
|
if (id === this.active?.agent) return;
|
|
1209
|
+
if (id === "antigravity" && !this.detect.get(id)?.engine) return void this.offerAntigravityInstall();
|
|
1085
1210
|
void this.switchTo(id, false);
|
|
1086
1211
|
},
|
|
1087
1212
|
onClose: () => this.closePalette(),
|
|
@@ -1091,6 +1216,50 @@ export class ChatApp {
|
|
|
1091
1216
|
void this.refreshDetect().then(() => this.palette === p && p.setItems(this.switchItems()));
|
|
1092
1217
|
}
|
|
1093
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
|
+
|
|
1094
1263
|
private openModes(arg: string) {
|
|
1095
1264
|
const a = this.active;
|
|
1096
1265
|
if (!a) return;
|
|
@@ -1156,19 +1325,20 @@ export class ChatApp {
|
|
|
1156
1325
|
this.active = active;
|
|
1157
1326
|
this.setWorking(`${pick(G.spinner, 0)} Starting ${adapter.name}…`);
|
|
1158
1327
|
try {
|
|
1159
|
-
const info = await engine.start({ cwd: this.cwd, resume });
|
|
1328
|
+
const info = await engine.start({ cwd: this.cwd, resume, questions: this.config.questions !== "off" });
|
|
1160
1329
|
active.mode = info.currentMode;
|
|
1161
1330
|
if (engine.session) this.ledger.saveSession({ agent, id: engine.session.id, cwd: this.cwd, startedAt: engine.session.startedAt, lastUsedAt: Date.now() } as SessionRef);
|
|
1162
1331
|
this.ledger.addEvent({ kind: o.from ? "switch" : "start", agent: o.from, to: agent, detail: o.reason });
|
|
1163
1332
|
const resumed = !!resume && engine.session?.id === resume.id;
|
|
1164
1333
|
active.replay = null;
|
|
1165
1334
|
const version = this.detect.get(agent)?.version ?? info.version;
|
|
1166
|
-
const model = info.currentModel ? ` · ${info.currentModel}` : "";
|
|
1335
|
+
const model = info.currentModel ? ` · ${displayModelName(info.currentModel, info.models)}` : "";
|
|
1167
1336
|
this.transcript.divider(`${adapter.name}${version ? ` ${version}` : ""}${model} · ${resumed ? "resumed" : "new session"}`);
|
|
1168
1337
|
if (NO_PERMISSION_PROMPTS.has(agent)) this.transcript.note(grokPermissionNote() ?? `${adapter.name} acts without asking permission`, C.warn);
|
|
1169
1338
|
this.setWorking(null);
|
|
1170
1339
|
this.renderFooter();
|
|
1171
1340
|
this.setTitle();
|
|
1341
|
+
this.term.started(agent, adapter.name, engine.session?.id);
|
|
1172
1342
|
// Only brief the agent when there is something to hand over: a brief with
|
|
1173
1343
|
// nothing in it costs a turn (and quota) for no context.
|
|
1174
1344
|
const hasHistory = this.ledger.turns().length > 0 || this.ledger.journal.steps().some((s) => s.kind === "user");
|
|
@@ -1211,6 +1381,8 @@ export class ChatApp {
|
|
|
1211
1381
|
active.turn = { block, tools: new Set(), startedAt: Date.now(), tokens: 0, chars: 0, verb: pick(VERBS), rec };
|
|
1212
1382
|
this.startSpinner(active);
|
|
1213
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);
|
|
1214
1386
|
const stop = await active.engine.prompt(text);
|
|
1215
1387
|
if (this.closed) {
|
|
1216
1388
|
active.turn?.rec?.end(stop);
|
|
@@ -1222,6 +1394,9 @@ export class ChatApp {
|
|
|
1222
1394
|
this.stopSpinner();
|
|
1223
1395
|
this.setTitle();
|
|
1224
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 });
|
|
1225
1400
|
if (turn) {
|
|
1226
1401
|
const dur = fmtDuration(Date.now() - turn.startedAt);
|
|
1227
1402
|
const who = `${active.name} · ${this.modelName(active, turn.model)}`;
|
|
@@ -1260,7 +1435,7 @@ export class ChatApp {
|
|
|
1260
1435
|
}
|
|
1261
1436
|
|
|
1262
1437
|
private handleEngineEvent(active: Active, e: EngineEvent) {
|
|
1263
|
-
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;
|
|
1264
1439
|
const target = () => active.turn?.block ?? (active.replay ??= this.transcript.agent());
|
|
1265
1440
|
switch (e.type) {
|
|
1266
1441
|
case "text_delta":
|
|
@@ -1284,15 +1459,27 @@ export class ChatApp {
|
|
|
1284
1459
|
active.turn?.tools.add(e.title.split(/[\s(]/)[0] ?? e.title);
|
|
1285
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 });
|
|
1286
1461
|
target().tool(e.id, e.title, e.status, e.detail);
|
|
1462
|
+
if (e.status === "completed" && this.active === active) this.term.toolDone(e.title);
|
|
1287
1463
|
break;
|
|
1288
1464
|
case "tool_update":
|
|
1289
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");
|
|
1290
1467
|
target().toolUpdate(e.id, e.status, e.title, e.detail);
|
|
1291
1468
|
break;
|
|
1292
1469
|
case "permission_request":
|
|
1293
1470
|
this.permissionQueue.push({ request: e.request, engine: active.engine });
|
|
1294
1471
|
this.showNextPermission();
|
|
1295
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;
|
|
1296
1483
|
case "mode": {
|
|
1297
1484
|
active.mode = e.modeId;
|
|
1298
1485
|
this.renderFooter();
|
|
@@ -1342,10 +1529,13 @@ export class ChatApp {
|
|
|
1342
1529
|
}
|
|
1343
1530
|
}
|
|
1344
1531
|
|
|
1345
|
-
private showNextPermission() {
|
|
1532
|
+
private showNextPermission(): void {
|
|
1346
1533
|
if (this.permission || !this.permissionQueue.length) return;
|
|
1347
|
-
const
|
|
1534
|
+
const next = this.permissionQueue.shift()!;
|
|
1535
|
+
if ("question" in next) return this.showQuestion(next.question, next.engine);
|
|
1536
|
+
const { request, engine } = next;
|
|
1348
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);
|
|
1349
1539
|
this.permission = new PermissionDialog(this.renderer, request, name, (choice) => {
|
|
1350
1540
|
this.permission?.destroy();
|
|
1351
1541
|
this.permission = null;
|
|
@@ -1353,6 +1543,7 @@ export class ChatApp {
|
|
|
1353
1543
|
const fallbackReject = request.options.find((o) => o.kind.startsWith("reject"))?.id ?? request.options[0]!.id;
|
|
1354
1544
|
engine.answerPermission(request.id, choice.optionId ?? fallbackReject);
|
|
1355
1545
|
const allowed = !!opt && opt.kind.startsWith("allow");
|
|
1546
|
+
this.term.answered(allowed);
|
|
1356
1547
|
if (!allowed) this.transcript.note(`declined: ${request.title.length > 70 ? request.title.slice(0, 67) + "…" : request.title}`, C.warn);
|
|
1357
1548
|
if (choice.tellAgent) {
|
|
1358
1549
|
this.input.placeholder = `Tell ${name} what to do differently…`;
|
|
@@ -1364,6 +1555,40 @@ export class ChatApp {
|
|
|
1364
1555
|
this.dialogHost.add(this.permission.box);
|
|
1365
1556
|
}
|
|
1366
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
|
+
|
|
1367
1592
|
private onQuota(q: QuotaStatus) {
|
|
1368
1593
|
const prev = this.quota.get(q.agent);
|
|
1369
1594
|
this.quota.set(q.agent, q);
|
|
@@ -1403,27 +1628,11 @@ export class ChatApp {
|
|
|
1403
1628
|
|
|
1404
1629
|
// ---------- side agent ----------
|
|
1405
1630
|
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
private async setSide(arg: string) {
|
|
1412
|
-
const words = arg.trim().split(/\s+/).filter(Boolean);
|
|
1413
|
-
const cfg = this.config.sideAgent ?? { agent: null, forward: "ask" as const };
|
|
1414
|
-
if (!words.length) return this.openSidePicker();
|
|
1415
|
-
const who = words[0]!.toLowerCase();
|
|
1416
|
-
if (who === "off") {
|
|
1417
|
-
await this.closeReviewer();
|
|
1418
|
-
this.saveSide({ ...cfg, agent: null, model: undefined });
|
|
1419
|
-
this.transcript.note("side agent off", C.dim);
|
|
1420
|
-
return this.renderFooter();
|
|
1421
|
-
}
|
|
1422
|
-
if (!this.detect.get(who as AgentId)?.engine) return this.transcript.note(`no protocol engine for "${who}" · /side to pick one`, C.warn);
|
|
1423
|
-
const rest = words.slice(1);
|
|
1424
|
-
const forward = rest.includes("auto") ? "auto" : rest.includes("ask") ? "ask" : cfg.agent === who ? cfg.forward : "ask";
|
|
1425
|
-
const model = rest.find((w) => w !== "auto" && w !== "ask");
|
|
1426
|
-
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();
|
|
1427
1636
|
}
|
|
1428
1637
|
|
|
1429
1638
|
private saveSide(v: Config["sideAgent"]) {
|
|
@@ -1435,14 +1644,14 @@ export class ChatApp {
|
|
|
1435
1644
|
}
|
|
1436
1645
|
}
|
|
1437
1646
|
|
|
1438
|
-
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 }) {
|
|
1439
1648
|
const prev = this.config.sideAgent;
|
|
1440
1649
|
if (prev?.agent !== agent || (o.model && o.model !== prev.model)) await this.closeReviewer();
|
|
1441
1650
|
// Keep the user's other side-agent settings (review cadence) when switching agent.
|
|
1442
|
-
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) });
|
|
1443
1652
|
const name = getAdapter(agent).name;
|
|
1444
|
-
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
|
|
1445
|
-
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);
|
|
1446
1655
|
this.renderFooter();
|
|
1447
1656
|
if (!o.pickModel) return;
|
|
1448
1657
|
// Start the reviewer now so its real model list can be offered.
|
|
@@ -1455,8 +1664,8 @@ export class ChatApp {
|
|
|
1455
1664
|
this.closePalette();
|
|
1456
1665
|
this.paletteKind = "modes";
|
|
1457
1666
|
this.palette = new Palette(this.renderer, {
|
|
1458
|
-
title: `Model for the side agent (${name}) · Esc keeps ${
|
|
1459
|
-
items: sortModels(models).map((m, i) => ({ 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 })),
|
|
1460
1669
|
onPick: (it) => {
|
|
1461
1670
|
this.closePalette();
|
|
1462
1671
|
void this.setSideModel(it.value);
|
|
@@ -1468,12 +1677,12 @@ export class ChatApp {
|
|
|
1468
1677
|
|
|
1469
1678
|
private async setSideModel(model: string) {
|
|
1470
1679
|
const cfg = this.config.sideAgent;
|
|
1471
|
-
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);
|
|
1472
1681
|
this.saveSide({ ...cfg, model });
|
|
1473
1682
|
const r = this.reviewer;
|
|
1474
1683
|
try {
|
|
1475
1684
|
if (r?.engine.setModel) await r.engine.setModel(model);
|
|
1476
|
-
this.transcript.note(`side agent model: ${model}`);
|
|
1685
|
+
this.transcript.note(`side agent model: ${displayModelName(model, this.reviewer?.engine.info?.models)}`);
|
|
1477
1686
|
if (cfg.agent === "cursor") this.transcript.note("Cursor saves this as its default model, so Cursor uses it outside codemax too", C.warn);
|
|
1478
1687
|
} catch (e: any) {
|
|
1479
1688
|
this.transcript.error(`could not set the side agent's model: ${e?.message ?? e}`);
|
|
@@ -1495,16 +1704,17 @@ export class ChatApp {
|
|
|
1495
1704
|
return [
|
|
1496
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) },
|
|
1497
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) },
|
|
1498
|
-
{ 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" }) },
|
|
1499
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 }); } },
|
|
1500
1710
|
{
|
|
1501
1711
|
key: "sideEvery",
|
|
1502
1712
|
label: "Side agent reviews",
|
|
1503
1713
|
value: reviewCadence(side.every),
|
|
1504
|
-
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" }],
|
|
1505
1715
|
apply: (v) => this.saveSide({ ...side, every: Number(v) }),
|
|
1506
1716
|
},
|
|
1507
|
-
{ 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" }) },
|
|
1508
1718
|
{ key: "autoSwitch", label: "Offer a handoff at limits", value: onOff(c.autoSwitch), choices: [{ value: "on" }, { value: "off" }], apply: bool("autoSwitch") },
|
|
1509
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") },
|
|
1510
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") },
|
|
@@ -1518,6 +1728,9 @@ export class ChatApp {
|
|
|
1518
1728
|
saveConfigKey("bellAfterSeconds", c.bellAfterSeconds);
|
|
1519
1729
|
},
|
|
1520
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); } },
|
|
1521
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); } },
|
|
1522
1735
|
{ key: "priceUpdates", label: "Weekly price list refresh", value: onOff(c.priceUpdates), choices: [{ value: "on" }, { value: "off", description: "stay fully offline" }], apply: bool("priceUpdates") },
|
|
1523
1736
|
];
|
|
@@ -1567,28 +1780,6 @@ export class ChatApp {
|
|
|
1567
1780
|
this.renderer.root.add(this.palette.box);
|
|
1568
1781
|
}
|
|
1569
1782
|
|
|
1570
|
-
/** /side with no arguments: pick the agent (then its model). */
|
|
1571
|
-
private openSidePicker() {
|
|
1572
|
-
const cfg = this.config.sideAgent;
|
|
1573
|
-
const items: PaletteItem[] = listAdapters(this.config.order)
|
|
1574
|
-
.filter((a) => this.detect.get(a.id)?.engine)
|
|
1575
|
-
// A different subscription first: the same tool would share (and drain) the main agent's plan.
|
|
1576
|
-
.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 }));
|
|
1577
|
-
items.push({ label: "Off", description: "no side agent", value: "off" });
|
|
1578
|
-
this.closePalette();
|
|
1579
|
-
this.paletteKind = "modes";
|
|
1580
|
-
this.palette = new Palette(this.renderer, {
|
|
1581
|
-
title: "Side agent · reviews each turn and leaves a note when something needs attention",
|
|
1582
|
-
items,
|
|
1583
|
-
onPick: (it) => {
|
|
1584
|
-
this.closePalette();
|
|
1585
|
-
if (it.value === "off") return void this.setSide("off");
|
|
1586
|
-
void this.enableSide(it.value as AgentId, { forward: cfg?.forward ?? "ask", pickModel: true });
|
|
1587
|
-
},
|
|
1588
|
-
onClose: () => this.closePalette(),
|
|
1589
|
-
}, this.paletteAnchor);
|
|
1590
|
-
this.renderer.root.add(this.palette.box);
|
|
1591
|
-
}
|
|
1592
1783
|
|
|
1593
1784
|
/** The side agent's session, started on first use with the chosen model. */
|
|
1594
1785
|
private async ensureReviewer() {
|
|
@@ -1601,7 +1792,8 @@ export class ChatApp {
|
|
|
1601
1792
|
const r = { agent: cfg.agent, name: getAdapter(cfg.agent).name, engine, busy: false, pending: false, since: this.lastReviewBaseline(), primed: false };
|
|
1602
1793
|
this.reviewer = r;
|
|
1603
1794
|
try {
|
|
1604
|
-
|
|
1795
|
+
// The side agent never interrupts you with questions.
|
|
1796
|
+
await engine.start({ cwd: this.cwd, questions: false });
|
|
1605
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));
|
|
1606
1798
|
return r;
|
|
1607
1799
|
} catch (e: any) {
|
|
@@ -1621,6 +1813,15 @@ export class ChatApp {
|
|
|
1621
1813
|
private async review() {
|
|
1622
1814
|
const cfg = this.config.sideAgent;
|
|
1623
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;
|
|
1624
1825
|
if (this.reviewer?.busy) {
|
|
1625
1826
|
this.reviewer.pending = true;
|
|
1626
1827
|
return;
|
|
@@ -1648,6 +1849,9 @@ export class ChatApp {
|
|
|
1648
1849
|
// A reviewer never acts: refuse anything it asks to do.
|
|
1649
1850
|
const no = e.request.options.find((o) => /reject|deny|decline/i.test(`${o.kind} ${o.id}`)) ?? e.request.options.at(-1);
|
|
1650
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" });
|
|
1651
1855
|
} else if (e.type === "usage") this.recordUsage(r.agent, engine, e);
|
|
1652
1856
|
else if (e.type === "error" && e.kind === "quota") limited = true;
|
|
1653
1857
|
} catch {
|
|
@@ -1689,15 +1893,44 @@ export class ChatApp {
|
|
|
1689
1893
|
const task = this.ledger.journal.task().request?.id ?? "none";
|
|
1690
1894
|
const sent = this.autoForwards.get(task) ?? 0;
|
|
1691
1895
|
const capped = auto && sent >= AUTO_FORWARD_CAP;
|
|
1692
|
-
this.transcript.noteCard(
|
|
1693
|
-
`⚑ Note from ${name} (side agent)`,
|
|
1694
|
-
text,
|
|
1695
|
-
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`,
|
|
1696
|
-
);
|
|
1697
1896
|
if (auto && !capped) {
|
|
1698
1897
|
this.autoForwards.set(task, sent + 1);
|
|
1898
|
+
this.transcript.noteCard(`⚑ Heads up from ${name} (side agent)`, text, `sent to ${main} automatically`);
|
|
1699
1899
|
this.forwardNote(this.notes.length - 1);
|
|
1900
|
+
return;
|
|
1700
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);
|
|
1701
1934
|
}
|
|
1702
1935
|
|
|
1703
1936
|
private forwardNote(index = this.notes.length - 1) {
|
|
@@ -1717,9 +1950,7 @@ export class ChatApp {
|
|
|
1717
1950
|
/** The model's display name as the agent lists it ("Opus 5.5"), else a short id. */
|
|
1718
1951
|
private modelName(a: Active, id?: string): string {
|
|
1719
1952
|
const mid = id ?? a.engine.info?.currentModel;
|
|
1720
|
-
|
|
1721
|
-
const listed = a.engine.info?.models?.find((m) => m.id === mid);
|
|
1722
|
-
return listed && listed.name !== listed.id ? listed.name : shortModelName(mid);
|
|
1953
|
+
return mid ? displayModelName(mid, a.engine.info?.models) : "default model";
|
|
1723
1954
|
}
|
|
1724
1955
|
|
|
1725
1956
|
private announceUpdate(version: string) {
|
|
@@ -1756,20 +1987,6 @@ export class ChatApp {
|
|
|
1756
1987
|
this.renderFooter();
|
|
1757
1988
|
}
|
|
1758
1989
|
|
|
1759
|
-
private async reviewNow() {
|
|
1760
|
-
if (!this.config.sideAgent?.agent) return this.transcript.note("no side agent · /side to pick one", C.warn);
|
|
1761
|
-
this.transcript.note(`asking ${getAdapter(this.config.sideAgent.agent).name} to review…`);
|
|
1762
|
-
const before = this.notes.length;
|
|
1763
|
-
await this.review();
|
|
1764
|
-
if (this.notes.length === before && !this.closed) this.transcript.note("the side agent found nothing worth a note");
|
|
1765
|
-
}
|
|
1766
|
-
|
|
1767
|
-
private showNotes() {
|
|
1768
|
-
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");
|
|
1769
|
-
this.notes.forEach((n, i) => this.transcript.note(`${i + 1}. ${fmtTime(n.ts)} ${n.name}: ${n.text.split("\n")[0]!.slice(0, 140)}`));
|
|
1770
|
-
this.transcript.note("/forward <n> sends a note to the main agent");
|
|
1771
|
-
}
|
|
1772
|
-
|
|
1773
1990
|
private startSpinner(active: Active) {
|
|
1774
1991
|
this.stopSpinner();
|
|
1775
1992
|
this.spinnerTimer = setInterval(() => {
|
|
@@ -1780,7 +1997,7 @@ export class ChatApp {
|
|
|
1780
1997
|
const secs = fmtDuration(Date.now() - t.startedAt);
|
|
1781
1998
|
const approx = t.tokens || Math.round(t.chars / 4);
|
|
1782
1999
|
const tok = approx ? ` · ↓ ${t.tokens ? "" : "~"}${approx} tokens` : "";
|
|
1783
|
-
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`);
|
|
1784
2001
|
else {
|
|
1785
2002
|
const elapsed = Date.now() - t.startedAt;
|
|
1786
2003
|
const tip = elapsed > 8000 ? ` ⎿ tip: ${pick(TIPS, Math.floor(t.startedAt / 1000))}` : "";
|
|
@@ -1841,11 +2058,11 @@ export class ChatApp {
|
|
|
1841
2058
|
else if (!this.shortcuts.visible) left.push({ text: "? for shortcuts", prio: 2 });
|
|
1842
2059
|
const right: { text: string; prio: number }[] = [];
|
|
1843
2060
|
if (this.updateAvailable) left.push({ text: `↑ ${this.updateAvailable} available · /update`, prio: 1 });
|
|
1844
|
-
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 });
|
|
1845
2062
|
// Narrow glyph: the footer is measured in characters, and an emoji takes two columns.
|
|
1846
2063
|
if (this.awake.mode !== "off") left.push({ text: `● awake${this.awake.status.lid ? " · lid" : ""}`, prio: 1 });
|
|
1847
2064
|
const effort = a ? this.effortOption(a)?.current : undefined;
|
|
1848
|
-
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 });
|
|
1849
2066
|
if (a?.context) right.push({ text: `ctx ${Math.min(100, Math.round((a.context.used / a.context.size) * 100))}%`, prio: 1 });
|
|
1850
2067
|
const bk: BudgetKey | null = a && this.config.budgets?.[a.agent] ? a.agent : this.config.budgets?.all ? "all" : null;
|
|
1851
2068
|
if (bk) right.push({ text: `${fmtUsd(this.today.get(bk) ?? 0)}/${fmtUsd(this.config.budgets[bk])} today`, prio: 1 });
|
|
@@ -1877,14 +2094,17 @@ export class ChatApp {
|
|
|
1877
2094
|
listAdapters(this.config.order).map(async (a) => {
|
|
1878
2095
|
const d = await a.detect();
|
|
1879
2096
|
const f = engineFactories[a.id];
|
|
1880
|
-
const engine = !!f && (await f.available().catch(() => false));
|
|
1881
|
-
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 });
|
|
1882
2099
|
}),
|
|
1883
2100
|
);
|
|
1884
2101
|
}
|
|
1885
2102
|
|
|
1886
2103
|
private async switchTo(agent: AgentId, fresh: boolean, o: { resumeTask?: boolean } = {}) {
|
|
1887
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);
|
|
1888
2108
|
this.switching = true;
|
|
1889
2109
|
const prev = this.active;
|
|
1890
2110
|
let from: AgentId | undefined;
|
|
@@ -1919,6 +2139,9 @@ export class ChatApp {
|
|
|
1919
2139
|
|
|
1920
2140
|
async quit() {
|
|
1921
2141
|
this.closed = true;
|
|
2142
|
+
if (this.noteArmTimer) clearTimeout(this.noteArmTimer);
|
|
2143
|
+
this.term.close();
|
|
2144
|
+
this.subagents.destroy();
|
|
1922
2145
|
await this.awake.release().catch(() => []);
|
|
1923
2146
|
await this.closeReviewer();
|
|
1924
2147
|
this.switching = true;
|
|
@@ -1939,10 +2162,38 @@ export class ChatApp {
|
|
|
1939
2162
|
}
|
|
1940
2163
|
}
|
|
1941
2164
|
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
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;
|
|
1946
2197
|
}
|
|
1947
2198
|
|
|
1948
2199
|
function ago(ts: number): string {
|
|
@@ -2000,5 +2251,5 @@ async function copyToClipboard(renderer: CliRenderer, text: string): Promise<boo
|
|
|
2000
2251
|
|
|
2001
2252
|
function reviewCadence(every: number | undefined): string {
|
|
2002
2253
|
const n = every ?? 1;
|
|
2003
|
-
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`;
|
|
2004
2255
|
}
|