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/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
- /** 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. */
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
- private permission: PermissionDialog | null = null;
71
- private permissionQueue: { request: PermissionRequest; engine: Engine }[] = [];
72
- 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 }>();
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 (/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. */
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 ctrl + c twice to quit",
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 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)");
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
- this.queue.push(text);
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
- this.queueBox.visible = this.queue.length > 0;
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: m.name, description: `${m.name !== m.id ? m.id : ""}${m.id === a.engine.info?.currentModel ? " (current)" : ""}`, value: m.id, weight: 100 - i })),
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
- const stop = await a.engine.prompt(prompt);
948
- a.turn = null;
949
- this.stopSpinner();
950
- const text = block.text().trim();
951
- if (stop === "end_turn" && text.length > 40) {
952
- this.ledger.writeSummary(`_${a.name}, ${new Date().toISOString().slice(0, 16).replace("T", " ")}_\n\n${text}`);
953
- block.footer("summary saved · used in every handoff");
954
- 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();
955
1075
  }
956
- block.footer("no summary saved");
957
- 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;
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
- 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(/^ · /, ""));
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 { 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;
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
- * /side pick the side agent, then its model
1408
- * /side <agent> [model] [auto|ask]
1409
- * /side off
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 model: /side ${agent} <model>, or fewer reviews in /config) keeps it from running out sooner.`, C.warn);
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" : " · /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);
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 ${shortModelName(r.engine.info?.currentModel ?? "default")}`,
1459
- items: sortModels(models).map((m, i) => ({ label: m.name, description: `${m.name !== m.id ? m.id : ""}${m.id === r.engine.info?.currentModel ? " (current)" : ""}`, value: m.id, weight: 100 - i })),
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 · /side to pick one", C.warn);
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.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" }) },
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: "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" }],
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: "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" }) },
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
- await engine.start({ cwd: this.cwd });
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
- if (!mid) return "default model";
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 ? ` ${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 });
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 ? ` · ${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 });
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
- function shortModelName(m: string): string {
1943
- // Cursor appends settings in brackets: "claude-opus-5-5[context=300k,effort=medium]".
1944
- const name = m.replace(/\[.*\]$/, "").replace(/^claude-/, "").replace(/^(anthropic|openai|xai|google|opencode)\//, "");
1945
- 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;
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 ? "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`;
2004
2255
  }