codemax-cli 0.1.3 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -5
- package/package.json +1 -1
- package/src/adapters/detect.ts +38 -9
- package/src/adapters/index.ts +15 -9
- package/src/adapters/simple.ts +7 -6
- package/src/adapters/types.ts +3 -1
- package/src/chat/app.ts +459 -138
- package/src/chat/models.ts +58 -0
- package/src/chat/notes.ts +101 -0
- package/src/chat/palette.ts +28 -7
- package/src/chat/question.ts +192 -0
- package/src/chat/subagents.ts +107 -0
- package/src/chat/theme.ts +3 -1
- package/src/chat/transcript.ts +5 -2
- package/src/cli.ts +8 -6
- package/src/config.ts +17 -4
- package/src/core/brief.ts +2 -1
- package/src/core/quota.ts +2 -1
- package/src/core/termstatus.ts +124 -0
- package/src/core/time.ts +9 -0
- package/src/core/usage.ts +4 -2
- package/src/engines/acp.ts +133 -11
- package/src/engines/antigravity.ts +95 -0
- package/src/engines/codex.ts +122 -32
- package/src/engines/elicitation.ts +60 -0
- package/src/engines/index.ts +6 -3
- package/src/engines/types.ts +56 -0
- package/src/tui/app.ts +13 -4
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model menus list exactly what the agent offers, newest first: "Default" /
|
|
3
|
+
* "Auto" stay on top, then models by the version in their name ("Opus 5.5"
|
|
4
|
+
* before "Opus 4.6", "GPT-6.1" before "GPT-5.6"); equal versions keep the
|
|
5
|
+
* agent's own order.
|
|
6
|
+
*/
|
|
7
|
+
export interface ModelEntry {
|
|
8
|
+
id: string;
|
|
9
|
+
name: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const PINNED = /^(default|auto)\b/i;
|
|
13
|
+
|
|
14
|
+
/** "Opus 5.5" → 5.5, "GPT-6.1-Sol" → 6.1, "claude-opus-4-7" → 4.7, "Sonnet 5" → 5; none → undefined. */
|
|
15
|
+
export function modelVersion(m: ModelEntry): number | undefined {
|
|
16
|
+
for (const s of [m.name, m.id]) {
|
|
17
|
+
// Skip date stamps like 20251001; a version is a small major with an optional minor.
|
|
18
|
+
const hit = s.match(/(?:^|[^\d])(\d{1,2})(?:[.-](\d{1,2}))?(?=$|[^\d])/);
|
|
19
|
+
if (hit) return Number(hit[1]) + (hit[2] ? Number(hit[2]) / 100 : 0);
|
|
20
|
+
}
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function sortModels<T extends ModelEntry>(models: T[]): T[] {
|
|
25
|
+
return models
|
|
26
|
+
.map((m, i) => ({ m, i, pinned: PINNED.test(m.name) || PINNED.test(m.id), v: modelVersion(m) }))
|
|
27
|
+
.sort((a, b) => {
|
|
28
|
+
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
|
|
29
|
+
if (a.v !== b.v) return (b.v ?? -1) - (a.v ?? -1);
|
|
30
|
+
return a.i - b.i;
|
|
31
|
+
})
|
|
32
|
+
.map((x) => x.m);
|
|
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
|
@@ -37,6 +37,8 @@ export class Palette {
|
|
|
37
37
|
private rows: TextRenderable[] = [];
|
|
38
38
|
private titleRow: TextRenderable;
|
|
39
39
|
private cursor = 0;
|
|
40
|
+
/** First visible row: the window scrolls so every item can be reached with the arrows. */
|
|
41
|
+
private offset = 0;
|
|
40
42
|
private filter: string;
|
|
41
43
|
private matches: PaletteItem[] = [];
|
|
42
44
|
private maxRows: number;
|
|
@@ -60,6 +62,11 @@ export class Palette {
|
|
|
60
62
|
}
|
|
61
63
|
|
|
62
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
|
+
}
|
|
63
70
|
this.filter = text;
|
|
64
71
|
const q = text.trim().toLowerCase();
|
|
65
72
|
const scored = this.opts.items
|
|
@@ -90,11 +97,16 @@ export class Palette {
|
|
|
90
97
|
|
|
91
98
|
render() {
|
|
92
99
|
const { top, width } = this.anchor();
|
|
93
|
-
|
|
100
|
+
// Keep the highlighted row inside the visible window.
|
|
101
|
+
if (this.cursor < this.offset) this.offset = this.cursor;
|
|
102
|
+
if (this.cursor >= this.offset + this.maxRows) this.offset = this.cursor - this.maxRows + 1;
|
|
103
|
+
this.offset = Math.max(0, Math.min(this.offset, this.matches.length - this.maxRows));
|
|
104
|
+
const visible = this.matches.slice(this.offset, this.offset + this.maxRows);
|
|
94
105
|
const shown = visible.length;
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
this.titleRow.
|
|
106
|
+
const more = this.matches.length > this.maxRows;
|
|
107
|
+
const titleLines = this.opts.title || more ? 1 : 0;
|
|
108
|
+
this.titleRow.visible = titleLines > 0;
|
|
109
|
+
this.titleRow.content = `${this.opts.title ?? ""}${more ? `${this.opts.title ? " · " : ""}${this.cursor + 1}/${this.matches.length} · ↑↓ to scroll` : ""}`;
|
|
98
110
|
this.box.height = shown + titleLines + (shown === 0 ? 1 : 0);
|
|
99
111
|
this.box.top = Math.max(0, top - this.box.height);
|
|
100
112
|
const labelW = Math.min(Math.max(28, Math.floor(width * 0.35)), Math.max(10, ...visible.map((v) => v.label.length + 2)));
|
|
@@ -112,7 +124,7 @@ export class Palette {
|
|
|
112
124
|
return;
|
|
113
125
|
}
|
|
114
126
|
row.visible = true;
|
|
115
|
-
const sel = i === this.cursor;
|
|
127
|
+
const sel = this.offset + i === this.cursor;
|
|
116
128
|
const label = pad(it.label, labelW);
|
|
117
129
|
const desc = it.description ?? "";
|
|
118
130
|
const hint = it.hint ? ` ${it.hint}` : "";
|
|
@@ -150,7 +162,8 @@ export class Palette {
|
|
|
150
162
|
}
|
|
151
163
|
|
|
152
164
|
private move(d: number): boolean {
|
|
153
|
-
|
|
165
|
+
// Every match is reachable; the window in render() scrolls to follow.
|
|
166
|
+
const n = this.matches.length;
|
|
154
167
|
if (!n) return true;
|
|
155
168
|
this.cursor = (this.cursor + d + n) % n;
|
|
156
169
|
this.render();
|
|
@@ -171,7 +184,15 @@ function score(it: PaletteItem, q: string): number {
|
|
|
171
184
|
if (label.split(/[:_\-/. ]/).some((w) => w.startsWith(needle))) return 2;
|
|
172
185
|
if (label.includes(needle)) return 1.5;
|
|
173
186
|
if ((it.description ?? "").toLowerCase().includes(needle)) return 1;
|
|
174
|
-
|
|
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;
|
|
175
196
|
}
|
|
176
197
|
|
|
177
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/chat/theme.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { fmtTime12 } from "../core/time.ts";
|
|
1
2
|
/**
|
|
2
3
|
* Glyphs and colours. The codemax brand theme: pink is the accent and nothing
|
|
3
4
|
* else, plum is the one dark ink, and warnings, errors and success keep their
|
|
@@ -153,6 +154,7 @@ export function fmtDuration(ms: number): string {
|
|
|
153
154
|
return `${m}m ${s - m * 60}s`;
|
|
154
155
|
}
|
|
155
156
|
|
|
157
|
+
/** 12-hour, always ("7:25 PM"). */
|
|
156
158
|
export function fmtTime(ts: number): string {
|
|
157
|
-
return
|
|
159
|
+
return fmtTime12(ts);
|
|
158
160
|
}
|
package/src/chat/transcript.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { BoxRenderable, ScrollBoxRenderable, StyledText, TextRenderable, fg, italic, strikethrough, stringToStyledText, type CliRenderer, type TextChunk } from "@opentui/core";
|
|
2
2
|
import { C, G } from "./theme.ts";
|
|
3
3
|
import { styleMarkdown, styleToolOutput } from "./markdown.ts";
|
|
4
|
+
import { fmtTime12 } from "../core/time.ts";
|
|
4
5
|
|
|
5
6
|
export interface PlanEntry {
|
|
6
7
|
content: string;
|
|
@@ -109,9 +110,11 @@ export class Transcript {
|
|
|
109
110
|
this.scrollToEnd();
|
|
110
111
|
}
|
|
111
112
|
|
|
112
|
-
|
|
113
|
+
/** Your message, with the time it was sent ("· 7:25 PM", 12-hour). */
|
|
114
|
+
user(text: string, ts = Date.now()) {
|
|
113
115
|
this.gap();
|
|
114
|
-
this.line(`${G.prompt} `, text, { fg: undefined, glyphFg: C.accent });
|
|
116
|
+
const node = this.line(`${G.prompt} `, text, { fg: undefined, glyphFg: C.accent });
|
|
117
|
+
node.content = new StyledText([...stringToStyledText(text).chunks, fg(C.dimmer)(` · ${fmtTime12(ts)}`)]);
|
|
115
118
|
}
|
|
116
119
|
|
|
117
120
|
note(text: string, fg_: string = C.dim) {
|
package/src/cli.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { diagnose } from "./core/doctor.ts";
|
|
|
18
18
|
import { renderUsageTable, sumCost } from "./core/usage.ts";
|
|
19
19
|
import { fmtUsd, priceFor, priceProvenance, resetPriceCache } from "./core/pricing.ts";
|
|
20
20
|
import { fetchLivePrices, loadLivePrices, LIVE_PRICES_PATH } from "./core/prices-live.ts";
|
|
21
|
+
import { fmtDateTime12 } from "./core/time.ts";
|
|
21
22
|
|
|
22
23
|
const HELP = `codemax — one terminal for all your coding agents
|
|
23
24
|
|
|
@@ -73,9 +74,9 @@ async function main(argv: string[]) {
|
|
|
73
74
|
const orch = new Orchestrator(config, ledger, cwd);
|
|
74
75
|
await orch.start(agent, args.includes("--fresh"));
|
|
75
76
|
} else if (args.includes("--tui") || !hasEngine(agent)) {
|
|
76
|
-
await runApp(config, ledger, cwd, agent, args.includes("--fresh"));
|
|
77
|
+
await runApp(config, ledger, cwd, agent, args.includes("--fresh"), !!agentFlag);
|
|
77
78
|
} else {
|
|
78
|
-
await runChat(config, ledger, cwd, agent, args.includes("--fresh"), !agentFlag);
|
|
79
|
+
await runChat(config, ledger, cwd, agent, args.includes("--fresh"), !agentFlag, !!agentFlag);
|
|
79
80
|
}
|
|
80
81
|
process.exit(0);
|
|
81
82
|
}
|
|
@@ -88,9 +89,10 @@ async function main(argv: string[]) {
|
|
|
88
89
|
const s = sessions[a.id];
|
|
89
90
|
const q = quota[a.id];
|
|
90
91
|
console.log(
|
|
91
|
-
`${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)} ` +
|
|
92
93
|
`${s ? `session ${s.id.slice(0, 8)}…` : "no session".padEnd(16)} ${q ? quotaLabel(q) : ""}`,
|
|
93
94
|
);
|
|
95
|
+
if (d.problem) console.log(` ${d.problem}`);
|
|
94
96
|
}
|
|
95
97
|
return;
|
|
96
98
|
}
|
|
@@ -105,7 +107,7 @@ async function main(argv: string[]) {
|
|
|
105
107
|
const last = events.slice(-5);
|
|
106
108
|
if (last.length) {
|
|
107
109
|
console.log("recent events:");
|
|
108
|
-
for (const e of last) console.log(` ${
|
|
110
|
+
for (const e of last) console.log(` ${fmtDateTime12(e.ts)} ${e.kind} ${e.agent ?? ""}${e.to ? ` → ${e.to}` : ""} ${e.detail ?? ""}`);
|
|
109
111
|
}
|
|
110
112
|
return;
|
|
111
113
|
}
|
|
@@ -114,7 +116,7 @@ async function main(argv: string[]) {
|
|
|
114
116
|
const turns = new Ledger(cwd).turns().slice(-n);
|
|
115
117
|
for (const t of turns) {
|
|
116
118
|
const who = t.role === "user" ? "USER" : `${t.agent.toUpperCase()}`;
|
|
117
|
-
console.log(`\n[${
|
|
119
|
+
console.log(`\n[${fmtDateTime12(t.ts)}] ${who}${t.tools?.length ? ` (tools: ${t.tools.join(", ")})` : ""}\n${t.text.slice(0, 600)}`);
|
|
118
120
|
}
|
|
119
121
|
return;
|
|
120
122
|
}
|
|
@@ -218,7 +220,7 @@ async function main(argv: string[]) {
|
|
|
218
220
|
}
|
|
219
221
|
const all = ledger.quota();
|
|
220
222
|
if (!Object.keys(all).length) return console.log("no quota state recorded");
|
|
221
|
-
for (const q of Object.values(all)) console.log(`${q.agent.padEnd(9)} ${quotaLabel(q) || "ok"} (${q.source}, ${
|
|
223
|
+
for (const q of Object.values(all)) console.log(`${q.agent.padEnd(9)} ${quotaLabel(q) || "ok"} (${q.source}, ${fmtDateTime12(q.at)}${q.detail ? `, ${q.detail}` : ""})`);
|
|
222
224
|
return;
|
|
223
225
|
}
|
|
224
226
|
case "keys": {
|