codemax-cli 0.1.4 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -5
- package/package.json +1 -1
- package/src/adapters/detect.ts +38 -9
- package/src/adapters/index.ts +2 -2
- package/src/adapters/simple.ts +7 -6
- package/src/adapters/types.ts +3 -1
- package/src/chat/app.ts +374 -123
- package/src/chat/models.ts +25 -0
- package/src/chat/notes.ts +101 -0
- package/src/chat/palette.ts +14 -1
- package/src/chat/question.ts +192 -0
- package/src/chat/subagents.ts +107 -0
- package/src/cli.ts +2 -1
- package/src/config.ts +17 -4
- package/src/core/termstatus.ts +124 -0
- package/src/core/usage.ts +4 -2
- package/src/engines/acp.ts +123 -10
- package/src/engines/antigravity.ts +95 -0
- package/src/engines/codex.ts +69 -28
- package/src/engines/elicitation.ts +60 -0
- package/src/engines/index.ts +6 -3
- package/src/engines/types.ts +45 -0
package/src/chat/models.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/chat/palette.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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"
|
|
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 =
|
|
42
|
-
|
|
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", "
|
|
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
|
|
117
|
+
return displayModelName(m);
|
|
116
118
|
}
|