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.
@@ -31,3 +31,28 @@ export function sortModels<T extends ModelEntry>(models: T[]): T[] {
31
31
  })
32
32
  .map((x) => x.m);
33
33
  }
34
+
35
+ /**
36
+ * The one way a model is shown anywhere in codemax: the agent's own display name
37
+ * ("Opus 5.5"), never its id ("opus", "claude-opus-5-5"). When the agent's list
38
+ * does not have the id (a model reported in usage, a saved side-agent model), a
39
+ * Claude id is spelled out the same way ("claude-opus-5-5" → "Opus 5.5").
40
+ */
41
+ export function displayModelName(id: string | undefined, models: ModelEntry[] = []): string {
42
+ if (!id) return "Default";
43
+ // Cursor appends settings in brackets: "claude-opus-5-5[context=300k,effort=medium]".
44
+ const bare = id.replace(/\[.*\]$/, "").replace(/^(anthropic|openai|xai|google|opencode)\//, "");
45
+ const listed = models.find((m) => m.id === id) ?? models.find((m) => m.id === bare);
46
+ const tidy = (n: string) => n.replace(/\s*\((recommended|default)\)\s*$/i, "");
47
+ if (listed && listed.name && listed.name !== listed.id) return tidy(listed.name);
48
+ if (!bare || /^(default|auto)$/i.test(bare)) return bare.toLowerCase() === "auto" ? "Auto" : "Default";
49
+ const claude = bare.match(/^(?:claude-)?(opus|sonnet|haiku|fable)(?:-(\d{1,2})(?:[-.](\d{1,2}))?)?(?:-\d{8})?$/i);
50
+ if (claude) {
51
+ const family = claude[1]![0]!.toUpperCase() + claude[1]!.slice(1).toLowerCase();
52
+ if (claude[2]) return `${family} ${claude[2]}${claude[3] ? `.${claude[3]}` : ""}`;
53
+ // A bare alias ("opus"): the listed model of that family names its version.
54
+ const sameFamily = sortModels(models).find((m) => new RegExp(`^${family}\\b`, "i").test(m.name));
55
+ return sameFamily ? tidy(sameFamily.name) : family;
56
+ }
57
+ return listed?.name ?? bare;
58
+ }
@@ -0,0 +1,101 @@
1
+ import { BoxRenderable, TextRenderable, type CliRenderer } from "@opentui/core";
2
+ import { styleMarkdown } from "./markdown.ts";
3
+ import { C } from "./theme.ts";
4
+
5
+ export interface PendingNote {
6
+ name: string;
7
+ text: string;
8
+ }
9
+
10
+ /**
11
+ * Side-agent notes wait here, pinned above the prompt (with the queue), until
12
+ * you act on one:
13
+ *
14
+ * ╭──────────────────────────────────────────────────────────╮
15
+ * │ ⚑ Heads up from Codex · 1 of 2 │
16
+ * │ The tests only ran once, so a flaky pass would ship. │
17
+ * │ 1 expand · 2 discuss with Claude Code · 0 dismiss (type the number and pause) │
18
+ * ╰──────────────────────────────────────────────────────────╯
19
+ *
20
+ * A digit is always typed into the prompt first. Only when the prompt holds just
21
+ * that digit and you pause does it act on the note, so "2 more tests please"
22
+ * is a message, never a forwarded note.
23
+ */
24
+ export class NotePanel {
25
+ readonly box: BoxRenderable;
26
+ private notes: PendingNote[] = [];
27
+ private expanded = false;
28
+ /** The main agent just asked you something: a lone digit is your answer, so the keys pause. */
29
+ private paused = false;
30
+
31
+ constructor(
32
+ private renderer: CliRenderer,
33
+ private mainName: () => string,
34
+ ) {
35
+ this.box = new BoxRenderable(renderer, { id: "notes", width: "100%", flexShrink: 0, flexDirection: "column", border: true, borderStyle: "rounded", borderColor: C.warn, paddingX: 1 });
36
+ this.box.visible = false;
37
+ }
38
+
39
+ get count() {
40
+ return this.notes.length;
41
+ }
42
+
43
+ get current(): PendingNote | undefined {
44
+ return this.notes[0];
45
+ }
46
+
47
+ push(n: PendingNote) {
48
+ this.notes.push(n);
49
+ this.render();
50
+ }
51
+
52
+ /** Remove the note on show (after it was sent or dismissed); the next one shows. */
53
+ take(): PendingNote | undefined {
54
+ const n = this.notes.shift();
55
+ this.expanded = false;
56
+ this.render();
57
+ return n;
58
+ }
59
+
60
+ setPaused(p: boolean) {
61
+ if (this.paused === p) return;
62
+ this.paused = p;
63
+ this.render();
64
+ }
65
+
66
+ get isPaused() {
67
+ return this.paused;
68
+ }
69
+
70
+ toggle() {
71
+ this.expanded = !this.expanded;
72
+ this.render();
73
+ }
74
+
75
+ render() {
76
+ for (const c of [...this.box.getChildren()]) c.destroyRecursively();
77
+ const n = this.notes[0];
78
+ this.box.visible = !!n;
79
+ if (!n) return;
80
+ const { headline, body } = splitNote(n.text);
81
+ const more = this.notes.length > 1 ? ` · 1 of ${this.notes.length}` : "";
82
+ this.box.borderColor = C.warn;
83
+ this.box.add(new TextRenderable(this.renderer, { content: `⚑ Heads up from ${n.name}${more}`, fg: C.warn, attributes: 1, wrapMode: "none", truncate: true, width: "100%" } as any));
84
+ this.box.add(new TextRenderable(this.renderer, { content: headline, wrapMode: "word", width: "100%" }));
85
+ if (this.expanded && body) {
86
+ // At most half the screen; the rest of a very long note is cut (it is in the ledger).
87
+ const room = Math.max(3, Math.floor(this.renderer.height / 2) - 4);
88
+ this.box.add(new TextRenderable(this.renderer, { content: styleMarkdown(body), wrapMode: "word", width: "100%", height: Math.min(room, body.split("\n").length + Math.ceil(body.length / Math.max(20, this.renderer.width - 6))), truncate: true } as any));
89
+ }
90
+ const expand = body ? (this.expanded ? "1 collapse" : "1 expand") : null;
91
+ if (this.paused) this.box.add(new TextRenderable(this.renderer, { content: `${this.mainName()} asked you something, so 1 · 2 · 0 are your answer for now; the note keys come back after you reply`, fg: C.dim, wrapMode: "word", width: "100%" }));
92
+ else this.box.add(new TextRenderable(this.renderer, { content: [expand, `2 discuss with ${this.mainName()}`, "0 dismiss"].filter(Boolean).join(" · ") + " (type the number and pause)", fg: C.dim, wrapMode: "word", width: "100%" }));
93
+ }
94
+ }
95
+
96
+ /** "Heads up · <one line>" then a body: the line for the collapsed panel, the rest for expand. */
97
+ export function splitNote(text: string): { headline: string; body: string } {
98
+ const lines = text.trim().split("\n");
99
+ const first = (lines.shift() ?? "").replace(/^\s*(⚑\s*)?heads up\s*[·:-]\s*/i, "").trim();
100
+ return { headline: first || "The side agent left a note", body: lines.join("\n").trim() };
101
+ }
@@ -62,6 +62,11 @@ export class Palette {
62
62
  }
63
63
 
64
64
  setFilter(text: string) {
65
+ // New text: the best match is highlighted again, so Enter picks it without arrow keys.
66
+ if (text !== this.filter) {
67
+ this.cursor = 0;
68
+ this.offset = 0;
69
+ }
65
70
  this.filter = text;
66
71
  const q = text.trim().toLowerCase();
67
72
  const scored = this.opts.items
@@ -179,7 +184,15 @@ function score(it: PaletteItem, q: string): number {
179
184
  if (label.split(/[:_\-/. ]/).some((w) => w.startsWith(needle))) return 2;
180
185
  if (label.includes(needle)) return 1.5;
181
186
  if ((it.description ?? "").toLowerCase().includes(needle)) return 1;
182
- return 0;
187
+ // Letters in order ("mdl" → model) still find it, ranked last.
188
+ return isSubsequence(needle, label) ? 0.5 : 0;
189
+ }
190
+
191
+ function isSubsequence(needle: string, hay: string): boolean {
192
+ let i = 0;
193
+ for (const ch of hay) if (ch === needle[i]) i++;
194
+ // At least 3 letters, starting with the first: short queries stay exact ("st" is not "switch").
195
+ return needle.length >= 3 && hay[0] === needle[0] && i === needle.length;
183
196
  }
184
197
 
185
198
  function pad(s: string, n: number): string {
@@ -0,0 +1,192 @@
1
+ import { BoxRenderable, TextRenderable, type CliRenderer, type KeyEvent } from "@opentui/core";
2
+ import type { QuestionAnswer, QuestionField, QuestionRequest } from "../engines/types.ts";
3
+ import { C, G } from "./theme.ts";
4
+
5
+ /**
6
+ * An agent's question (Claude's AskUserQuestion, an MCP form), one field at a time:
7
+ *
8
+ * Claude Code has a question · 1 of 2
9
+ * [Database] Which database should we use?
10
+ * ❯ 1. Postgres relational, the default
11
+ * 2. SQLite
12
+ * 3. Type something else…
13
+ *
14
+ * Digits pick (or toggle, in a multi-select); typing goes into the
15
+ * "something else" box, so a stray letter never picks an option. Esc skips
16
+ * the question (the agent is told you skipped and carries on).
17
+ */
18
+ export class QuestionDialog {
19
+ readonly box: BoxRenderable;
20
+ private header: TextRenderable;
21
+ private prompt: TextRenderable;
22
+ private list: BoxRenderable;
23
+ private hint: TextRenderable;
24
+ private index = 0;
25
+ private cursor = 0;
26
+ private picked = new Set<string>();
27
+ private typed = "";
28
+ private error = "";
29
+ private content: Record<string, string | number | boolean | string[]> = {};
30
+
31
+ constructor(
32
+ private renderer: CliRenderer,
33
+ readonly request: QuestionRequest,
34
+ private agentName: string,
35
+ private onDone: (a: QuestionAnswer) => void,
36
+ ) {
37
+ this.box = new BoxRenderable(renderer, { id: `ask-${request.id}`, width: "100%", flexShrink: 0, border: true, borderStyle: "rounded", borderColor: C.accent, paddingX: 1, flexDirection: "column" });
38
+ this.header = new TextRenderable(renderer, { content: "", fg: C.accent });
39
+ this.prompt = new TextRenderable(renderer, { content: "", wrapMode: "word", width: "100%" });
40
+ this.list = new BoxRenderable(renderer, { width: "100%", flexDirection: "column", flexShrink: 0 });
41
+ this.hint = new TextRenderable(renderer, { content: "", fg: C.dim, wrapMode: "word", width: "100%" });
42
+ this.box.add(this.header);
43
+ this.box.add(this.prompt);
44
+ this.box.add(new TextRenderable(renderer, { content: "" }));
45
+ this.box.add(this.list);
46
+ this.box.add(this.hint);
47
+ this.render();
48
+ }
49
+
50
+ private get field(): QuestionField {
51
+ return this.request.fields[this.index]!;
52
+ }
53
+
54
+ /** Rows: the options, then a free-text row when the field takes text. */
55
+ private get rows(): ({ kind: "option"; value: string; label: string; description?: string } | { kind: "text" })[] {
56
+ const f = this.field;
57
+ const text = f.kind === "text" || f.kind === "number" || !!f.otherKey;
58
+ return [...f.options.map((o) => ({ kind: "option" as const, ...o })), ...(text ? [{ kind: "text" as const }] : [])];
59
+ }
60
+
61
+ private render() {
62
+ const f = this.field;
63
+ const n = this.request.fields.length;
64
+ this.header.content = `${this.agentName} has a question${n > 1 ? ` · ${this.index + 1} of ${n}` : ""}`;
65
+ this.prompt.content = `${f.title && f.title !== f.question ? `[${f.title}] ` : ""}${f.question || this.request.message}`;
66
+ for (const c of [...this.list.getChildren()]) c.destroyRecursively();
67
+ this.rows.forEach((r, i) => {
68
+ const sel = i === this.cursor;
69
+ const mark = sel ? G.prompt : " ";
70
+ if (r.kind === "option") {
71
+ const box = f.kind === "multi" ? (this.picked.has(r.value) ? "[x] " : "[ ] ") : "";
72
+ this.list.add(new TextRenderable(this.renderer, { content: ` ${mark} ${i + 1}. ${box}${r.label}`, fg: sel ? C.accent : undefined, wrapMode: "word", width: "100%" }));
73
+ if (r.description) this.list.add(new TextRenderable(this.renderer, { content: ` ${r.description}`, fg: C.dim, wrapMode: "word", width: "100%" }));
74
+ } else {
75
+ const label = f.kind === "text" || f.kind === "number" ? "" : f.kind === "multi" ? "Add your own: " : "Type something else: ";
76
+ const caret = sel ? "▏" : "";
77
+ const shown = this.typed ? this.typed + caret : caret || (f.kind === "text" || f.kind === "number" ? "(type your answer)" : "…");
78
+ this.list.add(new TextRenderable(this.renderer, { content: ` ${mark} ${f.options.length ? `${i + 1}. ` : ""}${label}${shown}`, fg: sel ? C.accent : undefined, wrapMode: "word", width: "100%" }));
79
+ }
80
+ });
81
+ const keys = f.kind === "multi" ? "1-9 or space to tick · Enter when done" : this.rows.length > 1 ? "1-9 or ↑/↓ then Enter" : "Enter to send";
82
+ this.hint.content = `${this.error ? this.error + " · " : ""}${keys} · Esc to skip${n > 1 && this.index > 0 ? " · shift+tab back" : ""}`;
83
+ this.hint.fg = this.error ? C.warn : C.dim;
84
+ }
85
+
86
+ /** Always consumes the key (the dialog owns the keyboard while open). */
87
+ handleKey(key: KeyEvent): boolean {
88
+ const rows = this.rows;
89
+ const onText = rows[this.cursor]?.kind === "text";
90
+ const n = key.name;
91
+ this.error = "";
92
+ if (n === "escape") return this.finish({ action: "decline" });
93
+ if (n === "up") this.cursor = (this.cursor - 1 + rows.length) % rows.length;
94
+ else if (n === "down" || (n === "tab" && !key.shift)) this.cursor = (this.cursor + 1) % rows.length;
95
+ else if (n === "tab" && key.shift) this.back();
96
+ else if (n === "return") return this.enter();
97
+ else if (n === "backspace" && onText) this.typed = this.typed.slice(0, -1);
98
+ else if (n === "space" && !onText && this.field.kind === "multi") this.toggle(this.cursor);
99
+ // Digits pick unless you are typing an answer (an empty answer box still takes digits as picks).
100
+ else if ((!onText || !this.typed) && /^[1-9]$/.test(n) && rows[Number(n) - 1]?.kind === "option") {
101
+ const i = Number(n) - 1;
102
+ this.cursor = i;
103
+ if (rows[i]!.kind === "option") {
104
+ if (this.field.kind === "multi") this.toggle(i);
105
+ else return this.enter();
106
+ }
107
+ } else {
108
+ const ch = key.sequence ?? "";
109
+ // Typing never picks an option: it goes into the free-text row.
110
+ if (ch.length >= 1 && !key.ctrl && !key.meta && !/[\x00-\x1f\x7f]/.test(ch)) {
111
+ const t = rows.findIndex((r) => r.kind === "text");
112
+ if (t >= 0) {
113
+ this.cursor = t;
114
+ this.typed += ch;
115
+ }
116
+ }
117
+ }
118
+ this.render();
119
+ return false;
120
+ }
121
+
122
+ private toggle(i: number) {
123
+ const r = this.rows[i];
124
+ if (r?.kind !== "option") return;
125
+ if (this.picked.has(r.value)) this.picked.delete(r.value);
126
+ else this.picked.add(r.value);
127
+ }
128
+
129
+ private back() {
130
+ if (this.index === 0) return;
131
+ this.index--;
132
+ // Going back re-asks the question, so its earlier answer is dropped.
133
+ delete this.content[this.field.key];
134
+ if (this.field.otherKey) delete this.content[this.field.otherKey];
135
+ this.resetField();
136
+ }
137
+
138
+ private enter(): boolean {
139
+ const f = this.field;
140
+ const row = this.rows[this.cursor];
141
+ const text = this.typed.trim();
142
+ if (f.kind === "multi") {
143
+ const picks = [...this.picked];
144
+ if (picks.length) this.content[f.key] = picks;
145
+ if (text && f.otherKey) this.content[f.otherKey] = text;
146
+ if (!picks.length && !text && f.required) return this.complain("pick at least one");
147
+ } else if (f.kind === "text" || f.kind === "number") {
148
+ if (!text && f.required) return this.complain("type an answer");
149
+ if (text && f.kind === "number") {
150
+ const v = Number(text);
151
+ if (!Number.isFinite(v) || (f.integer && !Number.isInteger(v))) return this.complain(f.integer ? "type a whole number" : "type a number");
152
+ this.content[f.key] = v;
153
+ } else if (text) this.content[f.key] = text;
154
+ } else if (row?.kind === "option") {
155
+ this.content[f.key] = f.kind === "boolean" ? row.value === "true" : row.value;
156
+ // A note typed in "something else" travels with the pick (the agent reads it as a note).
157
+ if (text && f.otherKey) this.content[f.otherKey] = text;
158
+ } else {
159
+ if (!text) return this.complain("type something, or pick an option");
160
+ if (f.otherKey) this.content[f.otherKey] = text;
161
+ else this.content[f.key] = text;
162
+ }
163
+ if (this.index + 1 < this.request.fields.length) {
164
+ this.index++;
165
+ this.resetField();
166
+ return false;
167
+ }
168
+ return this.finish({ action: "accept", content: this.content });
169
+ }
170
+
171
+ private resetField() {
172
+ this.cursor = 0;
173
+ this.picked.clear();
174
+ this.typed = "";
175
+ this.render();
176
+ }
177
+
178
+ private complain(msg: string): boolean {
179
+ this.error = msg;
180
+ this.render();
181
+ return false;
182
+ }
183
+
184
+ private finish(a: QuestionAnswer): boolean {
185
+ this.onDone(a);
186
+ return true;
187
+ }
188
+
189
+ destroy() {
190
+ this.box.destroyRecursively();
191
+ }
192
+ }
@@ -0,0 +1,107 @@
1
+ import { BoxRenderable, TextRenderable, type CliRenderer } from "@opentui/core";
2
+ import type { SubagentEvent } from "../engines/types.ts";
3
+ import { C, G } from "./theme.ts";
4
+
5
+ interface Row {
6
+ id: string;
7
+ name: string;
8
+ task?: string;
9
+ activity?: string;
10
+ status: "running" | "done" | "failed";
11
+ startedAt: number;
12
+ endedAt?: number;
13
+ }
14
+
15
+ /** Finished agents stay visible this long, then leave the list. */
16
+ const LINGER_MS = 4000;
17
+ const MAX_ROWS = 4;
18
+
19
+ /**
20
+ * The agents the main agent spawned (Claude's Task/Agent tool, OpenCode's
21
+ * task tool…), listed under the prompt the way Claude Code lists them:
22
+ *
23
+ * ⏺ 2 agents running
24
+ * ⎿ Explore · find where auth is checked · Grep(requireAuth) · 12s
25
+ * ⎿ general-purpose · update the tests · Edit(test/auth.test.ts) · 4s
26
+ *
27
+ * Each row shows the agent's type, its task and its latest step. A finished
28
+ * agent shows ✓ (or ✗) for a few seconds and then leaves the list.
29
+ */
30
+ export class SubagentPanel {
31
+ readonly box: BoxRenderable;
32
+ private rows = new Map<string, Row>();
33
+ private timer: ReturnType<typeof setInterval> | null = null;
34
+
35
+ constructor(private renderer: CliRenderer) {
36
+ this.box = new BoxRenderable(renderer, { id: "subagents", width: "100%", flexShrink: 0, flexDirection: "column", paddingX: 1 });
37
+ this.box.visible = false;
38
+ }
39
+
40
+ get running(): number {
41
+ return [...this.rows.values()].filter((r) => r.status === "running").length;
42
+ }
43
+
44
+ update(e: SubagentEvent) {
45
+ const prev = this.rows.get(e.id);
46
+ const row: Row = prev ?? { id: e.id, name: e.name || "agent", status: "running", startedAt: Date.now() };
47
+ if (e.name) row.name = e.name;
48
+ if (e.task) row.task = e.task;
49
+ if (e.activity) row.activity = e.activity;
50
+ if (e.status) {
51
+ if (e.status !== "running" && row.status === "running") row.endedAt = Date.now();
52
+ row.status = e.status;
53
+ }
54
+ this.rows.set(e.id, row);
55
+ this.render();
56
+ }
57
+
58
+ /** The turn ended: anything still marked running is over too. */
59
+ endAll() {
60
+ for (const r of this.rows.values()) if (r.status === "running") {
61
+ r.status = "done";
62
+ r.endedAt = Date.now();
63
+ }
64
+ this.render();
65
+ }
66
+
67
+ clear() {
68
+ this.rows.clear();
69
+ this.render();
70
+ }
71
+
72
+ render() {
73
+ const now = Date.now();
74
+ for (const [id, r] of this.rows) if (r.endedAt && now - r.endedAt > LINGER_MS) this.rows.delete(id);
75
+ for (const c of [...this.box.getChildren()]) c.destroyRecursively();
76
+ const list = [...this.rows.values()];
77
+ this.box.visible = list.length > 0;
78
+ if (!list.length) {
79
+ if (this.timer) clearInterval(this.timer);
80
+ this.timer = null;
81
+ return;
82
+ }
83
+ // Tick once a second for the elapsed times and to let finished rows leave.
84
+ this.timer ??= setInterval(() => {
85
+ try {
86
+ this.render();
87
+ } catch {
88
+ /* never break the screen */
89
+ }
90
+ }, 1000);
91
+ const running = this.running;
92
+ this.box.add(new TextRenderable(this.renderer, { content: `${G.bullet} ${running ? `${running} agent${running === 1 ? "" : "s"} running` : `${list.length} agent${list.length === 1 ? "" : "s"} finished`}`, fg: running ? C.accent : C.dim, wrapMode: "none", truncate: true, width: "100%" } as any));
93
+ const shown = [...list.filter((r) => r.status === "running"), ...list.filter((r) => r.status !== "running")].slice(0, MAX_ROWS);
94
+ for (const r of shown) {
95
+ const mark = r.status === "done" ? "✓ " : r.status === "failed" ? "✗ " : "";
96
+ const secs = Math.round(((r.endedAt ?? now) - r.startedAt) / 1000);
97
+ const parts = [r.name, r.task, r.status === "running" ? r.activity : undefined, `${secs}s`].filter(Boolean).map((p) => String(p).replace(/\s+/g, " ").trim());
98
+ this.box.add(new TextRenderable(this.renderer, { content: ` ${G.nest} ${mark}${parts.join(" · ")}`, fg: r.status === "failed" ? C.error : r.status === "done" ? C.dim : undefined, wrapMode: "none", truncate: true, width: "100%" } as any));
99
+ }
100
+ if (list.length > shown.length) this.box.add(new TextRenderable(this.renderer, { content: ` + ${list.length - shown.length} more`, fg: C.dimmer, wrapMode: "none" } as any));
101
+ }
102
+
103
+ destroy() {
104
+ if (this.timer) clearInterval(this.timer);
105
+ this.timer = null;
106
+ }
107
+ }
package/src/cli.ts CHANGED
@@ -89,9 +89,10 @@ async function main(argv: string[]) {
89
89
  const s = sessions[a.id];
90
90
  const q = quota[a.id];
91
91
  console.log(
92
- `${a.id.padEnd(9)} ${a.name.padEnd(13)} ${d.installed ? `✓ ${d.version ?? ""}`.padEnd(14) : "✗ not found".padEnd(14)} ` +
92
+ `${a.id.padEnd(12)} ${a.name.padEnd(16)} ${d.installed ? `✓ ${d.version ?? ""}`.padEnd(14) : (d.problem ? "✗ broken" : "✗ not found").padEnd(14)} ` +
93
93
  `${s ? `session ${s.id.slice(0, 8)}…` : "no session".padEnd(16)} ${q ? quotaLabel(q) : ""}`,
94
94
  );
95
+ if (d.problem) console.log(` ${d.problem}`);
95
96
  }
96
97
  return;
97
98
  }
package/src/config.ts CHANGED
@@ -36,12 +36,22 @@ export interface Config {
36
36
  /**
37
37
  * Side agent: another agent that reviews the main agent's work after each turn and
38
38
  * leaves a note when something needs attention. `forward: "auto"` sends notes to the
39
- * main agent by itself; "ask" waits for /forward.
39
+ * main agent by itself; "ask" shows them above the prompt (1 expand · 2 discuss · 0 dismiss).
40
40
  */
41
- /** `every`: review every Nth working turn (1 = each turn, 0 = only on /review). Each review is a request on the side agent's plan. */
42
- sideAgent: { agent: AgentId | null; forward: "ask" | "auto"; model?: string; every?: number };
41
+ /** `every`: review every Nth working turn (1 = each turn, 0 = paused). Each review is a request on the side agent's plan. */
42
+ /** `samePlan`: the user chose to let the side agent review on the main agent's own plan (off unless picked explicitly). */
43
+ sideAgent: { agent: AgentId | null; forward: "ask" | "auto"; model?: string; every?: number; samePlan?: boolean };
43
44
  /** Check npm for a newer codemax at most every 12 hours and offer /update. */
44
45
  updateCheck: boolean;
46
+ /**
47
+ * Agent questions (Claude's AskUserQuestion, MCP forms): "ask" shows them and waits,
48
+ * "timeout" skips one left unanswered for 5 minutes (unattended runs), "off" never lets agents ask.
49
+ */
50
+ questions?: "ask" | "timeout" | "off";
51
+ /** Tell the terminal what codemax is doing: Warp's agent badge and status, OSC 7501 elsewhere. */
52
+ terminalStatus?: boolean;
53
+ /** Desktop notification when an agent waits for you, or a turn longer than bellAfterSeconds ends, while the terminal is not focused. */
54
+ notifications?: boolean;
45
55
  /** Colour theme: "auto" follows the terminal's background, or force "dark" / "light". */
46
56
  theme: "auto" | "dark" | "light";
47
57
  /**
@@ -52,7 +62,7 @@ export interface Config {
52
62
  }
53
63
 
54
64
  export const DEFAULT_CONFIG: Config = {
55
- order: ["claude", "codex", "opencode", "cursor", "grok", "gemini", "copilot"],
65
+ order: ["claude", "codex", "opencode", "cursor", "grok", "antigravity", "copilot"],
56
66
  hotkeys: ["ctrl+/", "ctrl+^"],
57
67
  autoSwitch: true,
58
68
  agentArgs: {},
@@ -65,6 +75,9 @@ export const DEFAULT_CONFIG: Config = {
65
75
  theme: "auto",
66
76
  awake: "off",
67
77
  updateCheck: true,
78
+ questions: "ask",
79
+ terminalStatus: true,
80
+ notifications: true,
68
81
  sideAgent: { agent: null, forward: "ask" },
69
82
  brief: { turns: 10, maxChars: 8000, turnChars: 1200, resumeChars: 24000 },
70
83
  };
@@ -0,0 +1,124 @@
1
+ import { basename } from "node:path";
2
+ import type { AgentId } from "../adapters/types.ts";
3
+
4
+ /**
5
+ * What the terminal around codemax shows about it: the tab's agent badge and
6
+ * running / waiting / done status, and a notification when you are needed.
7
+ *
8
+ * - Warp: its "cli-agent" channel (OSC 777 with the title `warp://cli-agent`
9
+ * and a JSON body). Warp only has built-in logos, so the tab shows the agent
10
+ * codemax is driving (Claude Code, Codex, OpenCode, Cursor, Copilot, and
11
+ * Antigravity under Warp's Gemini badge, Google's closest logo there);
12
+ * other agents get status without a badge. Warp raises its own notifications
13
+ * from these events, so codemax sends none of its own there.
14
+ * - Other terminals: OSC 7501 program status (working / blocked / done), which
15
+ * terminals that lack it ignore, and a desktop notification when an agent
16
+ * waits for you or a long turn ends while the terminal is not focused.
17
+ *
18
+ * Every write goes through the renderer (`TerminalSink`), never straight to
19
+ * stdout, so it can never land inside a frame.
20
+ */
21
+ export interface TerminalSink {
22
+ status(s: { state: "idle" | "working" | "blocked" | "done" | "error" | "clear" }): void;
23
+ notify(message: string, title?: string): void;
24
+ }
25
+
26
+ /** Agent names Warp has a logo for (checked against Warp 2026.09). */
27
+ export const WARP_AGENTS: Partial<Record<AgentId, string>> = { claude: "claude", codex: "codex", antigravity: "gemini", opencode: "opencode", cursor: "cursor", copilot: "copilot" };
28
+
29
+ // Warp builds that advertise the protocol but drop it (from Warp's own adapters).
30
+ const WARP_BROKEN: Record<string, string> = { stable: "v0.2026.03.25.08.24.stable_05", preview: "v0.2026.03.25.08.24.preview_05" };
31
+
32
+ export function warpSupported(env: Record<string, string | undefined> = process.env): boolean {
33
+ if (!env.WARP_CLI_AGENT_PROTOCOL_VERSION || !env.WARP_CLIENT_VERSION) return false;
34
+ const channel = Object.keys(WARP_BROKEN).find((c) => env.WARP_CLIENT_VERSION!.includes(c));
35
+ return !channel || env.WARP_CLIENT_VERSION > WARP_BROKEN[channel]!;
36
+ }
37
+
38
+ const cut = (s: string, n: number) => (s.length > n ? s.slice(0, n - 3) + "..." : s);
39
+
40
+ export class TerminalStatus {
41
+ private agent: { id: AgentId; name: string; session: string } | null = null;
42
+ private lastQuery = "";
43
+ private readonly warp: boolean;
44
+ /** True/false once the terminal reports focus; null when it never has. */
45
+ focused: boolean | null = null;
46
+
47
+ constructor(
48
+ private sink: TerminalSink,
49
+ private o: { cwd: string; env?: Record<string, string | undefined>; enabled: () => boolean; notify: () => boolean },
50
+ ) {
51
+ this.warp = warpSupported(o.env ?? process.env);
52
+ }
53
+
54
+ /** An agent's session is ready (start, switch, handoff). */
55
+ started(id: AgentId, name: string, session = "") {
56
+ this.agent = { id, name, session };
57
+ this.warpEvent("session_start");
58
+ this.status("idle");
59
+ }
60
+
61
+ prompt(text: string) {
62
+ this.lastQuery = text;
63
+ this.warpEvent("prompt_submit", { query: cut(text, 200) });
64
+ this.status("working");
65
+ }
66
+
67
+ toolDone(tool: string) {
68
+ this.warpEvent("tool_complete", { tool_name: tool });
69
+ }
70
+
71
+ /** The agent waits for you: a permission prompt or a question. */
72
+ waiting(kind: "permission" | "question", summary: string, tool?: string, input?: unknown) {
73
+ if (kind === "question") this.warpEvent("question_asked", { tool_name: tool ?? "question", summary: cut(summary, 200) });
74
+ else this.warpEvent("permission_request", { summary: cut(summary, 200), tool_name: tool ?? "tool", tool_input: input ?? {} });
75
+ this.status("blocked");
76
+ this.desktop(`${this.agent?.name ?? "The agent"} ${kind === "question" ? "has a question" : "needs your approval"}: ${cut(summary, 120)}`);
77
+ }
78
+
79
+ answered(allowed: boolean) {
80
+ if (allowed) this.warpEvent("permission_replied");
81
+ this.status("working");
82
+ }
83
+
84
+ turnEnded(o: { reply: string; ms: number; error?: boolean; longMs: number }) {
85
+ this.warpEvent("stop", { query: cut(this.lastQuery, 200), response: cut(o.reply, 200), transcript_path: "" });
86
+ this.status(o.error ? "error" : "done");
87
+ if (o.ms >= o.longMs) this.desktop(`${this.agent?.name ?? "The agent"} finished: ${cut(o.reply.replace(/\s+/g, " ").trim() || "done", 120)}`);
88
+ }
89
+
90
+ close() {
91
+ this.status("clear");
92
+ }
93
+
94
+ private status(state: Parameters<TerminalSink["status"]>[0]["state"]) {
95
+ if (!this.o.enabled()) return;
96
+ try {
97
+ this.sink.status({ state });
98
+ } catch {
99
+ /* a terminal that refuses it must never break codemax */
100
+ }
101
+ }
102
+
103
+ private warpEvent(event: string, extra: Record<string, unknown> = {}) {
104
+ const slug = this.agent && WARP_AGENTS[this.agent.id];
105
+ if (!this.warp || !slug || !this.o.enabled()) return;
106
+ const body = { v: 1, agent: slug, event, session_id: this.agent!.session, cwd: this.o.cwd, project: basename(this.o.cwd), ...extra };
107
+ try {
108
+ this.sink.notify(JSON.stringify(body), "warp://cli-agent");
109
+ } catch {
110
+ /* ignore */
111
+ }
112
+ }
113
+
114
+ /** A plain desktop notification, only when you are not looking (Warp sends its own). */
115
+ private desktop(message: string) {
116
+ if (this.focused === true || !this.o.notify()) return;
117
+ if (this.warp && this.agent && WARP_AGENTS[this.agent.id] && this.o.enabled()) return;
118
+ try {
119
+ this.sink.notify(message, "codemax");
120
+ } catch {
121
+ /* ignore */
122
+ }
123
+ }
124
+ }
package/src/core/usage.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { AgentId } from "../adapters/types.ts";
2
2
  import { estimateCost, fmtTokens, fmtUsd, priceFor, priceProvenance } from "./pricing.ts";
3
+ import { displayModelName } from "../chat/models.ts";
3
4
 
4
5
  /** One usage sample: a model call, a turn, or a delta reported by the agent. */
5
6
  export interface UsageRecord {
@@ -89,7 +90,7 @@ export function renderUsageTable(records: UsageRecord[], opts: { title: string;
89
90
  }
90
91
  const head = ["agent", "calls", "input", "output", "cache read", "cache write", "est. cost"];
91
92
  const data = rows.map((t) => [
92
- t.agent + (t.models.size ? ` (${[...t.models].map(shortModel).join(", ")})` : ""),
93
+ t.agent + (t.models.size ? ` (${[...new Set([...t.models].map(shortModel))].join(", ")})` : ""),
93
94
  String(t.calls),
94
95
  fmtTokens(t.input),
95
96
  fmtTokens(t.output),
@@ -111,6 +112,7 @@ export function renderUsageTable(records: UsageRecord[], opts: { title: string;
111
112
  return lines;
112
113
  }
113
114
 
115
+ /** Display name for a recorded model id ("claude-opus-5-5" → "Opus 5.5"), as everywhere else. */
114
116
  export function shortModel(m: string): string {
115
- return m.replace(/^claude-/, "").replace(/^(anthropic|openai|xai|google|opencode)\//, "");
117
+ return displayModelName(m);
116
118
  }