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/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
- /** Most side-agent notes sent automatically per task; more wait for /forward. */
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
- turn: { block: AgentBlock; tools: Set<string>; startedAt: number; tokens: number; chars: number; verb: string; rec?: TurnRecorder } | null;
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
- private permission: PermissionDialog | null = null;
68
- private permissionQueue: { request: PermissionRequest; engine: Engine }[] = [];
69
- private detect = new Map<AgentId, { installed: boolean; version?: string; engine: boolean }>();
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 (/side): reviews the main agent's work after each turn. */
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 ctrl + c twice to quit",
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
- async start(agent: AgentId, fresh: boolean, o: { pick?: boolean } = {}) {
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 gemini on PATH)");
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.queue.push(text);
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.queue.push(text);
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) await this.dispatch(active, this.queue.shift()!);
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.queue.push(line);
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:")) return this.command(`/${value.slice(9)}${args ? " " + args : ""}`);
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
- items: models.map((m) => ({ label: m.id, description: `${m.name !== m.id ? m.name : ""}${m.id === a.engine.info?.currentModel ? " (current)" : ""}`, value: m.id })),
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
- const stop = await a.engine.prompt(prompt);
903
- a.turn = null;
904
- this.stopSpinner();
905
- const text = block.text().trim();
906
- if (stop === "end_turn" && text.length > 40) {
907
- this.ledger.writeSummary(`_${a.name}, ${new Date().toISOString().slice(0, 16).replace("T", " ")}_\n\n${text}`);
908
- block.footer("summary saved · used in every handoff");
909
- return true;
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
- block.footer("no summary saved");
912
- return false;
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
- ...(this.active?.commands ?? [])
982
- .filter((c) => !builtinNames.has(`/${c.name}`))
983
- .map((c) => ({ label: `/${c.name}`, description: c.description ?? `${this.active!.name} command`, value: `agentcmd:${c.name}` })),
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
- if (!d?.installed) bits.push("not installed");
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
- block.footer(stop === "cancelled" ? `Interrupted after ${dur}` : `${pick(DONE_VERBS, turn.startedAt)} for ${dur} · done ${fmtTime(Date.now())}`);
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 { request, engine } = this.permissionQueue.shift()!;
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
- * /side pick the side agent, then its model
1344
- * /side <agent> [model] [auto|ask]
1345
- * /side off
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 model: /side ${agent} <model>, or fewer reviews in /config) keeps it from running out sooner.`, C.warn);
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" : " · /forward sends a note to the main agent"}`, C.accent);
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 ${shortModelName(r.engine.info?.currentModel ?? "default")}`,
1395
- items: models.map((m) => ({ label: m.id, description: `${m.name !== m.id ? m.name : ""}${m.id === r.engine.info?.currentModel ? " (current)" : ""}`, value: m.id })),
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 · /side to pick one", C.warn);
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.setSide("off") : this.enableSide(v as AgentId, { forward: side.forward, pickModel: true })) },
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: "0", label: "only on /review", description: "no automatic reviews" }],
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: "wait for /forward" }, { value: "auto", description: "send each note to the main agent" }], apply: (v) => this.saveSide({ ...side, forward: v === "auto" ? "auto" : "ask" }) },
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
- await engine.start({ cwd: this.cwd });
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.queue.push(msg);
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 ? ` ${shortModelName(this.config.sideAgent.model)}` : ""}${this.reviewer?.busy ? " reviewing…" : ""}`, prio: 2 });
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 ? ` · ${shortModelName(a.engine.info.currentModel)}` : ""}${effort ? ` ${effort}` : ""}`, prio: 2 });
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
- function shortModelName(m: string): string {
1873
- // Cursor appends settings in brackets: "claude-opus-5-5[context=300k,effort=medium]".
1874
- const name = m.replace(/\[.*\]$/, "").replace(/^claude-/, "").replace(/^(anthropic|openai|xai|google|opencode)\//, "");
1875
- return !name || name === "default" ? "auto" : name;
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 ? "only on /review" : n === 1 ? "every turn" : n === 2 ? "every 2nd turn" : n === 3 ? "every 3rd turn" : `every ${n}th turn`;
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
  }