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.
@@ -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
+ }
@@ -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
- const visible = this.matches.slice(0, this.maxRows);
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 titleLines = this.opts.title ? 1 : 0;
96
- this.titleRow.visible = !!this.opts.title;
97
- this.titleRow.content = this.opts.title ?? "";
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
- const n = Math.min(this.matches.length, this.maxRows);
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
- 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;
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 new Date(ts).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
159
+ return fmtTime12(ts);
158
160
  }
@@ -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
- user(text: string) {
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(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)} ` +
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(` ${new Date(e.ts).toLocaleString()} ${e.kind} ${e.agent ?? ""}${e.to ? ` → ${e.to}` : ""} ${e.detail ?? ""}`);
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[${new Date(t.ts).toLocaleTimeString()}] ${who}${t.tools?.length ? ` (tools: ${t.tools.join(", ")})` : ""}\n${t.text.slice(0, 600)}`);
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}, ${new Date(q.at).toLocaleString()}${q.detail ? `, ${q.detail}` : ""})`);
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": {