codemax-cli 0.1.3 → 0.1.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codemax-cli",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "One terminal for every coding agent you pay for: drive Claude Code, Codex, OpenCode, Grok and Gemini CLI from one chat, on your subscriptions, with context that survives switching agents.",
5
5
  "keywords": [
6
6
  "acp",
@@ -3,19 +3,25 @@ import { claude } from "./claude.ts";
3
3
  import { codex } from "./codex.ts";
4
4
  import { opencode, cursor, grok, gemini, copilot, fake } from "./simple.ts";
5
5
 
6
- const all: Adapter[] = [claude, codex, opencode, cursor, grok, gemini, copilot];
7
- if (process.env.CODEMAX_FAKE_AGENT) all.push(fake);
6
+ const real: Adapter[] = [claude, codex, opencode, cursor, grok, gemini, copilot];
8
7
 
9
- export const adapters: Record<string, Adapter> = Object.fromEntries(all.map((a) => [a.id, a]));
8
+ /**
9
+ * The adapters, read at call time: tests set CODEMAX_FAKE_AGENT after another
10
+ * test file may already have imported this module (bun runs all files in one
11
+ * process), so the fake agent must not be decided once at import.
12
+ */
13
+ function all(): Adapter[] {
14
+ return process.env.CODEMAX_FAKE_AGENT ? [...real, fake] : real;
15
+ }
10
16
 
11
17
  export function getAdapter(id: string): Adapter {
12
- const a = adapters[id];
13
- if (!a) throw new Error(`unknown agent "${id}". Known: ${Object.keys(adapters).join(", ")}`);
18
+ const a = all().find((x) => x.id === id);
19
+ if (!a) throw new Error(`unknown agent "${id}". Known: ${all().map((x) => x.id).join(", ")}`);
14
20
  return a;
15
21
  }
16
22
 
17
23
  export function listAdapters(order: AgentId[]): Adapter[] {
18
- const ranked = order.map((id) => adapters[id]).filter((a): a is Adapter => !!a);
19
- const rest = all.filter((a) => !ranked.includes(a));
20
- return [...ranked, ...rest];
24
+ const list = all();
25
+ const ranked = order.map((id) => list.find((a) => a.id === id)).filter((a): a is Adapter => !!a);
26
+ return [...ranked, ...list.filter((a) => !ranked.includes(a))];
21
27
  }
package/src/chat/app.ts CHANGED
@@ -31,6 +31,8 @@ import { Awake, defaultAwakeDeps, recoverAwake } from "../core/awake.ts";
31
31
  import { buildReviewPrompt, forwardMessage, isForwardedNote, parseNote } from "../core/review.ts";
32
32
  import { logoLines } from "./logo.ts";
33
33
  import { checkForUpdate, updatePlan } from "../core/update.ts";
34
+ import { sortModels } from "./models.ts";
35
+ import { fmtDateTime12 } from "../core/time.ts";
34
36
 
35
37
  const HOTKEY_NAMES: Record<string, string[]> = { "/": ["/", "_", "?"], "^": ["^", "6"] };
36
38
  const VERSION = pkg.version;
@@ -42,7 +44,8 @@ interface Active {
42
44
  agent: AgentId;
43
45
  name: string;
44
46
  engine: Engine;
45
- turn: { block: AgentBlock; tools: Set<string>; startedAt: number; tokens: number; chars: number; verb: string; rec?: TurnRecorder } | null;
47
+ /** `model`: the model the agent reported for this turn (usage), when it does. */
48
+ turn: { block: AgentBlock; tools: Set<string>; startedAt: number; tokens: number; chars: number; verb: string; rec?: TurnRecorder; model?: string } | null;
46
49
  replay: AgentBlock | null;
47
50
  unsubscribe: () => void;
48
51
  mode?: string;
@@ -74,6 +77,7 @@ export class ChatApp {
74
77
  private input: TextareaRenderable;
75
78
  private inputWrap: BoxRenderable;
76
79
  private dialogHost: BoxRenderable;
80
+ private queueBox!: BoxRenderable;
77
81
  private footerText: TextRenderable;
78
82
  private shortcuts: BoxRenderable;
79
83
  private flash = "";
@@ -99,6 +103,8 @@ export class ChatApp {
99
103
  /** Side agent (/side): reviews the main agent's work after each turn. */
100
104
  private reviewer: { agent: AgentId; name: string; engine: Engine; busy: boolean; pending: boolean; since: number; primed: boolean } | null = null;
101
105
  private notes: { ts: number; agent: AgentId; name: string; text: string }[] = [];
106
+ /** Plan limits each agent reported, by window ("five_hour", "seven_day"…). */
107
+ private planLimits = new Map<AgentId, Map<string, { usedPercent?: number; resetsAt?: number; status?: string; at: number }>>();
102
108
  /** Working turns since the side agent last reviewed (for `sideAgent.every`). */
103
109
  private turnsSinceReview = 0;
104
110
  /** Notes sent automatically per task (auto mode is capped so the two agents cannot loop). */
@@ -135,6 +141,10 @@ export class ChatApp {
135
141
  root.add(this.working);
136
142
  this.dialogHost = new BoxRenderable(renderer, { id: "dialog-host", width: "100%", flexShrink: 0, flexDirection: "column" });
137
143
  root.add(this.dialogHost);
144
+ // Queued messages wait here, pinned just above the prompt, until they are sent.
145
+ this.queueBox = new BoxRenderable(renderer, { id: "queue", width: "100%", flexShrink: 0, flexDirection: "column", paddingX: 1 });
146
+ this.queueBox.visible = false;
147
+ root.add(this.queueBox);
138
148
  this.inputWrap = new BoxRenderable(renderer, { id: "input-wrap", width: "100%", flexShrink: 0, flexDirection: "column", border: ["top", "bottom"], borderStyle: "single", borderColor: C.accent });
139
149
  const inputRow = new BoxRenderable(renderer, { id: "input-row", width: "100%", flexDirection: "row" });
140
150
  inputRow.add(new TextRenderable(renderer, { id: "input-prompt", content: `${G.prompt} `, width: 2, fg: C.accent }));
@@ -188,7 +198,8 @@ export class ChatApp {
188
198
  this.renderFooter();
189
199
  }
190
200
 
191
- async start(agent: AgentId, fresh: boolean, o: { pick?: boolean } = {}) {
201
+ /** `explicit`: the user named this agent (--agent), so never start a different one. */
202
+ async start(agent: AgentId, fresh: boolean, o: { pick?: boolean; explicit?: boolean } = {}) {
192
203
  try {
193
204
  this.today = spendToday();
194
205
  } catch {
@@ -196,6 +207,7 @@ export class ChatApp {
196
207
  }
197
208
  await this.refreshDetect();
198
209
  const ok = (a: AgentId) => this.detect.get(a)?.installed && this.detect.get(a)?.engine;
210
+ if (!ok(agent) && o.explicit) throw new Error(`${agent} is ${this.detect.get(agent)?.installed ? "installed but has no protocol engine (try codemax --tui --agent " + agent + ")" : "not installed"}. Available: ${listAdapters(this.config.order).filter((a) => ok(a.id)).map((a) => a.id).join(", ") || "none"}`);
199
211
  const first = ok(agent) ? agent : listAdapters(this.config.order).find((a) => ok(a.id))?.id;
200
212
  if (!first) throw new Error("no agent with a protocol engine found (need claude, codex, opencode, grok or gemini on PATH)");
201
213
  const notes: string[] = [];
@@ -393,9 +405,7 @@ export class ChatApp {
393
405
  const busy = !!this.active?.turn;
394
406
  const immediate = /^\/(quit|exit|q|help|status|usage|cost|diff|clear|switch|agents)\b/.test(text);
395
407
  if (busy && !immediate && (text.startsWith("/") || text.startsWith("!"))) {
396
- this.queue.push(text);
397
- this.transcript.note(`queued until the turn ends: ${text.slice(0, 60)}`, C.dim);
398
- this.renderFooter();
408
+ this.enqueue(text);
399
409
  return;
400
410
  }
401
411
  if (text.startsWith("/")) return this.command(text);
@@ -407,8 +417,7 @@ export class ChatApp {
407
417
  const route = this.routeFor(text);
408
418
  if (route && route !== this.active.agent && !this.active.turn) return this.askAgent(route, text, `routed to ${route} by rule`);
409
419
  if (this.active.turn) {
410
- this.queue.push(text);
411
- this.transcript.note(`queued: ${text.slice(0, 80)}${text.length > 80 ? "…" : ""}`, C.dim);
420
+ this.enqueue(text);
412
421
  return;
413
422
  }
414
423
  await this.send(this.active, text);
@@ -650,6 +659,26 @@ export class ChatApp {
650
659
  return true;
651
660
  }
652
661
 
662
+ /** Queue a message (or command) until the agent's turn ends; it shows above the prompt. */
663
+ private enqueue(text: string) {
664
+ this.queue.push(text);
665
+ this.renderQueue();
666
+ this.renderFooter();
667
+ }
668
+
669
+ private renderQueue() {
670
+ this.safely("queue", () => {
671
+ for (const child of this.queueBox.getChildren()) child.destroyRecursively();
672
+ const show = this.queue.slice(0, 3);
673
+ for (const [i, q] of show.entries()) {
674
+ const line = q.startsWith("A side agent (") ? "side-agent note for the main agent" : q.replace(/\s+/g, " ");
675
+ this.queueBox.add(new TextRenderable(this.renderer, { id: `queue-${Date.now()}-${i}`, content: `${G.prompt} ${line}`, fg: C.dim, wrapMode: "none", truncate: true, width: "100%" } as any));
676
+ }
677
+ if (this.queue.length > show.length) this.queueBox.add(new TextRenderable(this.renderer, { id: `queue-more-${Date.now()}`, content: ` + ${this.queue.length - show.length} more queued`, fg: C.dimmer }));
678
+ this.queueBox.visible = this.queue.length > 0;
679
+ });
680
+ }
681
+
653
682
  /** Run one queued entry the same way as if it had just been typed. */
654
683
  private async dispatch(active: Active, text: string) {
655
684
  if (text.startsWith("!")) return this.shell(text.slice(1).trim());
@@ -658,7 +687,11 @@ export class ChatApp {
658
687
  }
659
688
 
660
689
  private async drainQueue(active: Active) {
661
- while (this.queue.length && this.active === active && !active.turn) await this.dispatch(active, this.queue.shift()!);
690
+ while (this.queue.length && this.active === active && !active.turn) {
691
+ const next = this.queue.shift()!;
692
+ this.renderQueue();
693
+ await this.dispatch(active, next);
694
+ }
662
695
  this.renderFooter();
663
696
  }
664
697
 
@@ -772,11 +805,17 @@ export class ChatApp {
772
805
  const name = raw!.toLowerCase();
773
806
  const b = this.builtins().find((x) => x.name === name || x.name === name.replace(/^\/(exit|q)$/, "/quit"));
774
807
  if (b) return b.run(rest.join(" "));
808
+ // /<agent>:<cmd> runs the active agent's own command even when codemax has one of that name.
809
+ const ns = name.match(/^\/([a-z]+):(.+)$/);
810
+ if (ns && this.active && ns[1] === this.active.agent && this.active.commands.some((c) => c.name === ns[2])) {
811
+ const forward = `/${ns[2]}${rest.length ? " " + rest.join(" ") : ""}`;
812
+ if (this.active.turn) return this.enqueue(forward);
813
+ return this.send(this.active, forward);
814
+ }
775
815
  if (name === "/exit" || name === "/q") return this.quit();
776
816
  if (this.active?.commands.some((c) => `/${c.name}` === name)) {
777
817
  if (this.active.turn) {
778
- this.queue.push(line);
779
- this.transcript.note(`queued ${name} until the turn ends`, C.dim);
818
+ this.enqueue(line);
780
819
  return;
781
820
  }
782
821
  return this.send(this.active, line);
@@ -786,7 +825,12 @@ export class ChatApp {
786
825
 
787
826
  private runCommandValue(value: string, args: string) {
788
827
  if (value.startsWith("builtin:")) return this.builtins().find((b) => b.name === value.slice(8))?.run(args);
789
- if (value.startsWith("agentcmd:")) return this.command(`/${value.slice(9)}${args ? " " + args : ""}`);
828
+ if (value.startsWith("agentcmd:")) {
829
+ const name = value.slice(9);
830
+ // A name codemax also uses goes through /<agent>:<name> so the agent's own command runs.
831
+ const clash = this.builtins().some((b) => b.name === `/${name}`) && this.active;
832
+ return this.command(`/${clash ? `${this.active!.agent}:` : ""}${name}${args ? " " + args : ""}`);
833
+ }
790
834
  }
791
835
 
792
836
  private pendingShell: string[] = [];
@@ -834,7 +878,8 @@ export class ChatApp {
834
878
  this.paletteKind = "modes";
835
879
  this.palette = new Palette(this.renderer, {
836
880
  title: `Model for ${a.name}`,
837
- items: models.map((m) => ({ label: m.id, description: `${m.name !== m.id ? m.name : ""}${m.id === a.engine.info?.currentModel ? " (current)" : ""}`, value: m.id })),
881
+ // The agent's own list, newest first, by its display name (e.g. "Opus 5.5" for the id "opus").
882
+ items: sortModels(models).map((m, i) => ({ label: m.name, description: `${m.name !== m.id ? m.id : ""}${m.id === a.engine.info?.currentModel ? " (current)" : ""}`, value: m.id, weight: 100 - i })),
838
883
  onPick: (it) => {
839
884
  this.closePalette();
840
885
  void apply(it.value);
@@ -944,6 +989,13 @@ export class ChatApp {
944
989
  const all = this.ledger.usage();
945
990
  for (const l of renderUsageTable(all, { title: "Usage for this project" })) this.transcript.note(l);
946
991
  this.transcript.note(`this codemax session: ${fmtUsd(this.sessionCost)} · all time here: ${fmtUsd(sumCost(all))} · prices in ~/.codemax/prices.json`);
992
+ // Plan limits, as the agents report them (Claude Code's own /usage is terminal-only, so this is where it shows).
993
+ const label: Record<string, string> = { five_hour: "5-hour", seven_day: "weekly", seven_day_opus: "weekly Opus", seven_day_sonnet: "weekly Sonnet", overage: "extra usage", seven_day_overage_included: "weekly incl. extra" };
994
+ for (const [agent, m] of this.planLimits) {
995
+ const parts = [...m].map(([w, v]) => `${label[w] ?? w} ${typeof v.usedPercent === "number" ? `${Math.round(v.usedPercent)}%` : v.status ?? "?"}${v.status === "rejected" ? " (used up)" : ""}${v.resetsAt ? ` · resets ${fmtDateTime12(v.resetsAt)}` : ""}`);
996
+ this.transcript.note(`${getAdapter(agent).name} plan: ${parts.join("; ")}`, C.accent);
997
+ }
998
+ if (!this.planLimits.size) this.transcript.note("plan limits appear here once an agent reports them (Claude Code does after a turn)");
947
999
  }
948
1000
 
949
1001
  private showStatus() {
@@ -978,9 +1030,12 @@ export class ChatApp {
978
1030
  const builtinNames = new Set(this.builtins().map((b) => b.name));
979
1031
  const items: PaletteItem[] = [
980
1032
  ...this.builtins().map((b) => ({ label: b.name, description: b.description, value: `builtin:${b.name}`, weight: 1 })),
981
- ...(this.active?.commands ?? [])
982
- .filter((c) => !builtinNames.has(`/${c.name}`))
983
- .map((c) => ({ label: `/${c.name}`, description: c.description ?? `${this.active!.name} command`, value: `agentcmd:${c.name}` })),
1033
+ // An agent command that shares a built-in's name stays reachable as /<agent>:<name>.
1034
+ ...(this.active?.commands ?? []).map((c) => {
1035
+ const clash = builtinNames.has(`/${c.name}`);
1036
+ const label = clash ? `/${this.active!.agent}:${c.name}` : `/${c.name}`;
1037
+ return { label, description: `${c.description ?? `${this.active!.name} command`}${clash ? ` (${this.active!.name}'s own /${c.name})` : ""}`, value: `agentcmd:${c.name}` };
1038
+ }),
984
1039
  ];
985
1040
  this.paletteKind = "commands";
986
1041
  this.palette = new Palette(this.renderer, {
@@ -1169,7 +1224,8 @@ export class ChatApp {
1169
1224
  if (turn && this.config.bellAfterSeconds > 0 && Date.now() - turn.startedAt > this.config.bellAfterSeconds * 1000) process.stdout.write("\x07");
1170
1225
  if (turn) {
1171
1226
  const dur = fmtDuration(Date.now() - turn.startedAt);
1172
- block.footer(stop === "cancelled" ? `Interrupted after ${dur}` : `${pick(DONE_VERBS, turn.startedAt)} for ${dur} · done ${fmtTime(Date.now())}`);
1227
+ const who = `${active.name} · ${this.modelName(active, turn.model)}`;
1228
+ block.footer(stop === "cancelled" ? `Interrupted after ${dur} · ${who}` : `${pick(DONE_VERBS, turn.startedAt)} for ${dur} · done ${fmtTime(Date.now())} · ${who}`);
1173
1229
  const reply = block.text().trim();
1174
1230
  if (isLimitNotice(reply)) turn.rec?.markLimit(reply);
1175
1231
  turn.rec?.end(stop);
@@ -1247,10 +1303,18 @@ export class ChatApp {
1247
1303
  break;
1248
1304
  case "usage": {
1249
1305
  if (active.turn && typeof e.outputTokens === "number") active.turn.tokens += e.outputTokens;
1306
+ if (active.turn && e.model && !/default model$/.test(e.model)) active.turn.model = e.model;
1250
1307
  if (typeof e.contextUsed === "number" && typeof e.contextSize === "number" && e.contextSize > 0) {
1251
1308
  active.context = { used: e.contextUsed, size: e.contextSize };
1252
1309
  this.renderFooter();
1253
1310
  }
1311
+ for (const pl of e.planLimits ?? []) {
1312
+ const m = this.planLimits.get(active.agent) ?? new Map();
1313
+ m.set(pl.window, { usedPercent: pl.usedPercent, resetsAt: pl.resetsAt, status: pl.status, at: Date.now() });
1314
+ this.planLimits.set(active.agent, m);
1315
+ // A refused window is the plan running out: the handoff offer follows from onQuota.
1316
+ if (pl.status === "rejected" || pl.window === "five_hour") this.onQuota({ agent: active.agent, usedPercent: pl.usedPercent, exhausted: pl.status === "rejected", resetsAt: pl.resetsAt, source: "probe", at: Date.now() });
1317
+ }
1254
1318
  if (typeof e.usedPercent === "number") this.onQuota({ agent: active.agent, usedPercent: e.usedPercent, exhausted: e.usedPercent >= 100, source: "probe", at: Date.now() });
1255
1319
  this.recordUsage(active.agent, active.engine, e);
1256
1320
  break;
@@ -1392,7 +1456,7 @@ export class ChatApp {
1392
1456
  this.paletteKind = "modes";
1393
1457
  this.palette = new Palette(this.renderer, {
1394
1458
  title: `Model for the side agent (${name}) · Esc keeps ${shortModelName(r.engine.info?.currentModel ?? "default")}`,
1395
- items: models.map((m) => ({ label: m.id, description: `${m.name !== m.id ? m.name : ""}${m.id === r.engine.info?.currentModel ? " (current)" : ""}`, value: m.id })),
1459
+ items: sortModels(models).map((m, i) => ({ label: m.name, description: `${m.name !== m.id ? m.id : ""}${m.id === r.engine.info?.currentModel ? " (current)" : ""}`, value: m.id, weight: 100 - i })),
1396
1460
  onPick: (it) => {
1397
1461
  this.closePalette();
1398
1462
  void this.setSideModel(it.value);
@@ -1644,14 +1708,20 @@ export class ChatApp {
1644
1708
  if (!a) return this.transcript.note("no main agent to forward to", C.warn);
1645
1709
  const msg = forwardMessage(n.text, n.name);
1646
1710
  if (a.turn || this.switching) {
1647
- this.queue.push(msg);
1648
- this.transcript.note(`note queued for ${a.name} until its turn ends`);
1649
- this.renderFooter();
1711
+ this.enqueue(msg);
1650
1712
  return;
1651
1713
  }
1652
1714
  void this.send(a, msg);
1653
1715
  }
1654
1716
 
1717
+ /** The model's display name as the agent lists it ("Opus 5.5"), else a short id. */
1718
+ private modelName(a: Active, id?: string): string {
1719
+ const mid = id ?? a.engine.info?.currentModel;
1720
+ if (!mid) return "default model";
1721
+ const listed = a.engine.info?.models?.find((m) => m.id === mid);
1722
+ return listed && listed.name !== listed.id ? listed.name : shortModelName(mid);
1723
+ }
1724
+
1655
1725
  private announceUpdate(version: string) {
1656
1726
  if (this.closed) return;
1657
1727
  this.updateAvailable = version;
@@ -1714,7 +1784,7 @@ export class ChatApp {
1714
1784
  else {
1715
1785
  const elapsed = Date.now() - t.startedAt;
1716
1786
  const tip = elapsed > 8000 ? ` ⎿ tip: ${pick(TIPS, Math.floor(t.startedAt / 1000))}` : "";
1717
- this.setWorking(`${glyph} ${t.verb}… (${secs}${tok}) · esc to interrupt${tip}`);
1787
+ this.setWorking(`${glyph} ${t.verb}… (${secs}${tok}) · ${active.name} · ${this.modelName(active, t.model)} · esc to interrupt${tip}`);
1718
1788
  }
1719
1789
  }, 120);
1720
1790
  }
@@ -1883,14 +1953,14 @@ function ago(ts: number): string {
1883
1953
  return h < 48 ? `${h}h ago` : `${Math.round(h / 24)}d ago`;
1884
1954
  }
1885
1955
 
1886
- export async function runChat(config: Config, ledger: Ledger, cwd: string, agent: AgentId, fresh: boolean, pick = false) {
1956
+ export async function runChat(config: Config, ledger: Ledger, cwd: string, agent: AgentId, fresh: boolean, pick = false, explicit = false) {
1887
1957
  const renderer = await createCliRenderer({ exitOnCtrlC: false, exitSignals: ["SIGTERM", "SIGHUP"], useMouse: true, targetFps: 60 });
1888
1958
  const pref = config.theme ?? "auto";
1889
1959
  applyTheme(pref === "auto" ? ((await renderer.waitForThemeMode(250).catch(() => null)) ?? "dark") : pref);
1890
1960
  const app = new ChatApp(renderer, config, ledger, cwd);
1891
1961
  process.once("exit", () => app.awake.releaseSync());
1892
1962
  try {
1893
- await app.start(agent, fresh, { pick });
1963
+ await app.start(agent, fresh, { pick, explicit });
1894
1964
  } finally {
1895
1965
  renderer.destroy();
1896
1966
  try {
@@ -0,0 +1,33 @@
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
+ }
@@ -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;
@@ -90,11 +92,16 @@ export class Palette {
90
92
 
91
93
  render() {
92
94
  const { top, width } = this.anchor();
93
- const visible = this.matches.slice(0, this.maxRows);
95
+ // Keep the highlighted row inside the visible window.
96
+ if (this.cursor < this.offset) this.offset = this.cursor;
97
+ if (this.cursor >= this.offset + this.maxRows) this.offset = this.cursor - this.maxRows + 1;
98
+ this.offset = Math.max(0, Math.min(this.offset, this.matches.length - this.maxRows));
99
+ const visible = this.matches.slice(this.offset, this.offset + this.maxRows);
94
100
  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 ?? "";
101
+ const more = this.matches.length > this.maxRows;
102
+ const titleLines = this.opts.title || more ? 1 : 0;
103
+ this.titleRow.visible = titleLines > 0;
104
+ this.titleRow.content = `${this.opts.title ?? ""}${more ? `${this.opts.title ? " · " : ""}${this.cursor + 1}/${this.matches.length} · ↑↓ to scroll` : ""}`;
98
105
  this.box.height = shown + titleLines + (shown === 0 ? 1 : 0);
99
106
  this.box.top = Math.max(0, top - this.box.height);
100
107
  const labelW = Math.min(Math.max(28, Math.floor(width * 0.35)), Math.max(10, ...visible.map((v) => v.label.length + 2)));
@@ -112,7 +119,7 @@ export class Palette {
112
119
  return;
113
120
  }
114
121
  row.visible = true;
115
- const sel = i === this.cursor;
122
+ const sel = this.offset + i === this.cursor;
116
123
  const label = pad(it.label, labelW);
117
124
  const desc = it.description ?? "";
118
125
  const hint = it.hint ? ` ${it.hint}` : "";
@@ -150,7 +157,8 @@ export class Palette {
150
157
  }
151
158
 
152
159
  private move(d: number): boolean {
153
- const n = Math.min(this.matches.length, this.maxRows);
160
+ // Every match is reachable; the window in render() scrolls to follow.
161
+ const n = this.matches.length;
154
162
  if (!n) return true;
155
163
  this.cursor = (this.cursor + d + n) % n;
156
164
  this.render();
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
  }
@@ -105,7 +106,7 @@ async function main(argv: string[]) {
105
106
  const last = events.slice(-5);
106
107
  if (last.length) {
107
108
  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 ?? ""}`);
109
+ for (const e of last) console.log(` ${fmtDateTime12(e.ts)} ${e.kind} ${e.agent ?? ""}${e.to ? ` → ${e.to}` : ""} ${e.detail ?? ""}`);
109
110
  }
110
111
  return;
111
112
  }
@@ -114,7 +115,7 @@ async function main(argv: string[]) {
114
115
  const turns = new Ledger(cwd).turns().slice(-n);
115
116
  for (const t of turns) {
116
117
  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)}`);
118
+ console.log(`\n[${fmtDateTime12(t.ts)}] ${who}${t.tools?.length ? ` (tools: ${t.tools.join(", ")})` : ""}\n${t.text.slice(0, 600)}`);
118
119
  }
119
120
  return;
120
121
  }
@@ -218,7 +219,7 @@ async function main(argv: string[]) {
218
219
  }
219
220
  const all = ledger.quota();
220
221
  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}` : ""})`);
222
+ 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
223
  return;
223
224
  }
224
225
  case "keys": {
package/src/core/brief.ts CHANGED
@@ -4,6 +4,7 @@ import { diffStat, headLine, recentCommits } from "./git.ts";
4
4
  import { checkpointDiff } from "./checkpoint.ts";
5
5
  import { BRIEF_MARKER, type Ledger } from "./ledger.ts";
6
6
  import type { Step, StepOf } from "./journal.ts";
7
+ import { fmtTime12 } from "./time.ts";
7
8
 
8
9
  export interface BriefInput {
9
10
  ledger: Ledger;
@@ -161,7 +162,7 @@ export async function buildCatchup(input: BriefInput): Promise<string | null> {
161
162
  function whereItStopped(steps: Step[], end: StepOf<"end"> | undefined): string[] {
162
163
  const out: string[] = [];
163
164
  if (!end) return ["The last agent had not finished its first turn."];
164
- const when = new Date(end.ts).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
165
+ const when = fmtTime12(end.ts);
165
166
  const why =
166
167
  end.outcome === "limit" ? `ran out of its usage limit${end.detail ? ` ("${oneLine(end.detail, 140)}")` : ""}`
167
168
  : end.outcome === "cancelled" ? "was interrupted by the user"
package/src/core/quota.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { AgentId, QuotaStatus } from "../adapters/types.ts";
2
2
  import { stripAnsi } from "./terminal.ts";
3
+ import { fmtTime12 } from "./time.ts";
3
4
 
4
5
  /**
5
6
  * Vendor-neutral phrases. Deliberately narrow: "rate limit" or "429" alone
@@ -47,7 +48,7 @@ export class OutputScanner {
47
48
  export function quotaLabel(q?: QuotaStatus): string {
48
49
  if (!q) return "";
49
50
  if (isExhausted(q)) {
50
- const reset = q.resetsAt ? ` until ${new Date(q.resetsAt).toLocaleTimeString()}` : "";
51
+ const reset = q.resetsAt ? ` until ${fmtTime12(q.resetsAt)}` : "";
51
52
  return q.source === "output" ? `exhausted? (text match)${reset}` : `EXHAUSTED${reset}`;
52
53
  }
53
54
  if (typeof q.usedPercent === "number") return `${Math.round(q.usedPercent)}% used`;
@@ -0,0 +1,9 @@
1
+ /** Every time codemax shows is 12-hour, whatever the system locale: "7:25 PM", "Oct 18, 7:25 PM". */
2
+ export function fmtTime12(ts: number): string {
3
+ return new Date(ts).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true });
4
+ }
5
+
6
+ export function fmtDateTime12(ts: number): string {
7
+ const d = new Date(ts);
8
+ return `${d.toLocaleDateString("en-US", { month: "short", day: "numeric" })}, ${fmtTime12(ts)}`;
9
+ }
@@ -1,6 +1,6 @@
1
1
  import { ClientSideConnection, ndJsonStream, type Client, type Stream } from "@agentclientprotocol/sdk";
2
2
  import type { AgentId } from "../adapters/types.ts";
3
- import type { Engine, EngineEvent, EngineInfo, EngineOption, EngineSessionRef, EngineStartOptions, PermissionRequest, StopReason } from "./types.ts";
3
+ import type { Engine, EngineEvent, EngineInfo, EngineOption, EngineSessionRef, EngineStartOptions, PermissionRequest, PlanLimit, StopReason } from "./types.ts";
4
4
  import { agentEnv } from "./env.ts";
5
5
  import { platformArgv } from "../core/spawn.ts";
6
6
  import { classifyError } from "./errors.ts";
@@ -204,7 +204,7 @@ export class AcpEngine implements Engine {
204
204
  // `used` is how full the context window is, not tokens spent: it feeds the context meter.
205
205
  // Token counts come from the prompt response (or session files) at turn end.
206
206
  if (typeof u.used === "number") this.turnContext = u.used;
207
- this.emit({ type: "usage", contextUsed: u.used, contextSize: u.size, costUsd: delta, model: this.currentModelName() });
207
+ this.emit({ type: "usage", contextUsed: u.used, contextSize: u.size, costUsd: delta, model: this.currentModelName(), planLimits: planLimitsOf(u) });
208
208
  break;
209
209
  }
210
210
  default:
@@ -339,6 +339,15 @@ function extraOptions(config: unknown): EngineOption[] {
339
339
  }));
340
340
  }
341
341
 
342
+ /** Claude's plan limits ride on usage updates as `_meta["_claude/rateLimit"]` (SDKRateLimitInfo). */
343
+ function planLimitsOf(u: any): PlanLimit[] | undefined {
344
+ const rl = u?._meta?.["_claude/rateLimit"];
345
+ if (!rl || typeof rl !== "object") return undefined;
346
+ const util = typeof rl.utilization === "number" ? (rl.utilization <= 1 ? rl.utilization * 100 : rl.utilization) : undefined;
347
+ const reset = typeof rl.resetsAt === "number" ? (rl.resetsAt < 1e12 ? rl.resetsAt * 1000 : rl.resetsAt) : undefined;
348
+ return [{ window: String(rl.rateLimitType ?? "plan"), usedPercent: util, resetsAt: reset, status: rl.status }];
349
+ }
350
+
342
351
  function mapStatus(s: string | undefined): "pending" | "in_progress" | "completed" | "failed" {
343
352
  if (s === "in_progress" || s === "completed" || s === "failed") return s;
344
353
  return "pending";
@@ -1,8 +1,9 @@
1
1
  import type { AgentId } from "../adapters/types.ts";
2
- import type { Engine, EngineEvent, EngineInfo, EngineSessionRef, EngineStartOptions, PermissionRequest, StopReason, ToolStatus } from "./types.ts";
2
+ import type { Engine, EngineEvent, EngineInfo, EngineOption, EngineSessionRef, EngineStartOptions, PermissionRequest, StopReason, ToolStatus } from "./types.ts";
3
3
  import { agentEnv } from "./env.ts";
4
4
  import { platformArgv } from "../core/spawn.ts";
5
5
  import { classifyError } from "./errors.ts";
6
+ import pkg from "../../package.json" with { type: "json" };
6
7
 
7
8
  export interface CodexEngineOptions {
8
9
  /** Command for the app server. Default: codex app-server. */
@@ -18,6 +19,10 @@ export interface CodexEngineOptions {
18
19
  * `answerPermission`.
19
20
  */
20
21
  export class CodexEngine implements Engine {
22
+ /** Models this account can use, from `model/list`. */
23
+ private catalog: { id: string; name: string; efforts: string[]; defaultEffort?: string }[] = [];
24
+ /** Reasoning effort chosen with /effort (sent with each turn); undefined = the model's default. */
25
+ private effort: string | undefined;
21
26
  readonly agent: AgentId = "codex";
22
27
  info: EngineInfo | null = null;
23
28
  session: EngineSessionRef | null = null;
@@ -49,7 +54,7 @@ export class CodexEngine implements Engine {
49
54
  this.cwd = o.cwd;
50
55
  this.transport = this.opts.transport ?? this.spawn(o.cwd);
51
56
  this.transport.onLine((line) => this.onLine(line));
52
- const init = await this.request("initialize", { clientInfo: { name: "codemax", version: "0.3.0" }, capabilities: {} });
57
+ const init = await this.request("initialize", { clientInfo: { name: "codemax", version: pkg.version }, capabilities: {} });
53
58
  this.notify("initialized", {});
54
59
 
55
60
  let thread: any;
@@ -65,6 +70,9 @@ export class CodexEngine implements Engine {
65
70
  this.model = thread.model ?? undefined;
66
71
  this.session = { agent: "codex", id: thread.id, cwd: o.cwd, startedAt: Date.now(), lastUsedAt: Date.now() };
67
72
  const account: any = await this.request("account/read", {}).catch(() => null);
73
+ // The models this account can use, straight from Codex: never a list of our own.
74
+ // An older Codex without model/list must not hold up startup: give it a few seconds.
75
+ this.catalog = await Promise.race([this.listModels(), Bun.sleep(4000).then(() => [])]).catch(() => []);
68
76
  this.info = {
69
77
  agent: "codex",
70
78
  name: "Codex",
@@ -74,8 +82,9 @@ export class CodexEngine implements Engine {
74
82
  { id: "never", name: "full auto (no approvals)" },
75
83
  ],
76
84
  currentMode: "on-request",
77
- models: [...new Set([this.model, "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.5"].filter(Boolean))].map((id) => ({ id: id!, name: id! })),
85
+ models: this.catalog.length ? this.catalog.map((m) => ({ id: m.id, name: m.name })) : this.model ? [{ id: this.model, name: this.model }] : [],
78
86
  currentModel: this.model,
87
+ options: this.effortOptions(),
79
88
  plan: account?.account?.planType ? `chatgpt ${account.account.planType}` : undefined,
80
89
  };
81
90
  this.emit({ type: "session", ref: this.session });
@@ -281,7 +290,7 @@ export class CodexEngine implements Engine {
281
290
  const done = new Promise<StopReason>((resolve) => (this.turn = { resolve }));
282
291
  try {
283
292
  const approvalPolicy = this.info?.currentMode === "never" ? "never" : "on-request";
284
- const res = await this.request("turn/start", { threadId: this.threadId, cwd: this.cwd, input: [{ type: "text", text }], model: this.model, approvalPolicy });
293
+ const res = await this.request("turn/start", { threadId: this.threadId, cwd: this.cwd, input: [{ type: "text", text }], model: this.model, approvalPolicy, ...(this.effort ? { effort: this.effort } : {}) });
285
294
  if (this.turn) this.turn.id = res?.turn?.id;
286
295
  } catch (e: any) {
287
296
  const msg = `${e?.message ?? e} ${JSON.stringify(e?.data ?? "")}`.trim();
@@ -314,6 +323,46 @@ export class CodexEngine implements Engine {
314
323
  async setModel(modelId: string) {
315
324
  this.model = modelId;
316
325
  if (this.info) this.info.currentModel = modelId;
326
+ // Reasoning efforts differ per model: keep the chosen one only if the new model has it.
327
+ const efforts = this.catalog.find((m) => m.id === modelId)?.efforts ?? [];
328
+ if (this.effort && !efforts.includes(this.effort)) this.effort = undefined;
329
+ if (this.info) this.info.options = this.effortOptions();
330
+ }
331
+
332
+ async setOption(optionId: string, value: string) {
333
+ if (optionId !== "effort") return;
334
+ this.effort = value;
335
+ if (this.info) this.info.options = this.effortOptions();
336
+ }
337
+
338
+ /** `model/list`, all pages, hidden models left out. */
339
+ private async listModels(): Promise<{ id: string; name: string; efforts: string[]; defaultEffort?: string }[]> {
340
+ const out: { id: string; name: string; efforts: string[]; defaultEffort?: string }[] = [];
341
+ let cursor: string | undefined;
342
+ for (let page = 0; page < 10; page++) {
343
+ const r: any = await this.request("model/list", cursor ? { cursor } : {});
344
+ for (const m of r?.data ?? []) {
345
+ if (m?.hidden) continue;
346
+ const id = String(m.model ?? m.id ?? "");
347
+ if (!id) continue;
348
+ out.push({
349
+ id,
350
+ name: String(m.displayName ?? id),
351
+ efforts: (m.supportedReasoningEfforts ?? []).map((e: any) => String(e?.reasoningEffort ?? e)).filter(Boolean),
352
+ defaultEffort: m.defaultReasoningEffort ? String(m.defaultReasoningEffort) : undefined,
353
+ });
354
+ }
355
+ cursor = r?.nextCursor ?? undefined;
356
+ if (!cursor) break;
357
+ }
358
+ return out;
359
+ }
360
+
361
+ /** The current model's reasoning efforts, as Codex lists them. */
362
+ private effortOptions(): EngineOption[] {
363
+ const m = this.catalog.find((x) => x.id === this.model);
364
+ if (!m?.efforts.length) return [];
365
+ return [{ id: "effort", name: "Reasoning effort", category: "thought_level", current: this.effort ?? m.defaultEffort ?? "default", values: m.efforts.map((e) => ({ value: e, name: e })) }];
317
366
  }
318
367
 
319
368
  async close() {
@@ -27,6 +27,8 @@ export type EngineEvent =
27
27
  costUsd?: number;
28
28
  /** Rate-limit window consumption, when the agent exposes it. */
29
29
  usedPercent?: number;
30
+ /** Subscription plan limits the agent reported (Claude: 5-hour, weekly…), one entry per window. */
31
+ planLimits?: PlanLimit[];
30
32
  /** Context window fill, when reported (tokens used / size). */
31
33
  contextUsed?: number;
32
34
  contextSize?: number;
@@ -36,6 +38,15 @@ export type EngineEvent =
36
38
  | { type: "error"; kind: ErrorKind; message: string }
37
39
  | { type: "log"; level: "info" | "warn" | "error"; message: string };
38
40
 
41
+ export interface PlanLimit {
42
+ /** e.g. "five_hour", "seven_day", "seven_day_opus". */
43
+ window: string;
44
+ usedPercent?: number;
45
+ resetsAt?: number;
46
+ /** "allowed" | "allowed_warning" | "rejected" (out of this window's quota). */
47
+ status?: string;
48
+ }
49
+
39
50
  export type ToolStatus = "pending" | "in_progress" | "completed" | "failed";
40
51
  export type StopReason = "end_turn" | "max_tokens" | "cancelled" | "refusal" | "error" | "other";
41
52
  export type ErrorKind = "quota" | "auth" | "protocol" | "crashed" | "other";
package/src/tui/app.ts CHANGED
@@ -68,10 +68,15 @@ export class App {
68
68
  this.setStatus("");
69
69
  }
70
70
 
71
- async start(agent: AgentId, fresh: boolean) {
71
+ /** `explicit`: the user named this agent (--agent), so never start a different one. */
72
+ async start(agent: AgentId, fresh: boolean, o: { explicit?: boolean } = {}) {
72
73
  await this.refreshDetect();
73
- const first = this.detect.get(agent)?.installed ? agent : this.firstInstalled();
74
+ const installed = this.detect.get(agent)?.installed;
75
+ if (!installed && o.explicit) throw new Error(`${agent} is not installed. Installed: ${this.installedList() || "none"}`);
76
+ const first = installed ? agent : this.firstInstalled();
74
77
  if (!first) throw new Error("no supported agent CLI found on PATH");
78
+ // Under tests a fallback must never reach a real agent (no logins, no quota).
79
+ if (first !== agent && process.env.NODE_ENV === "test") throw new Error(`test asked for ${agent}, which is not available; refusing to fall back to ${first}`);
75
80
  if (first !== agent) this.setStatus(`${agent} not found, starting ${first}`);
76
81
  await this.launch(getAdapter(first), { fresh });
77
82
  return this.done;
@@ -282,6 +287,10 @@ export class App {
282
287
  );
283
288
  }
284
289
 
290
+ private installedList(): string {
291
+ return [...this.detect.entries()].filter(([, d]) => d.installed).map(([id]) => id).join(", ");
292
+ }
293
+
285
294
  private firstInstalled(): AgentId | undefined {
286
295
  return listAdapters(this.config.order).find((a) => this.detect.get(a.id)?.installed)?.id;
287
296
  }
@@ -360,11 +369,11 @@ export class App {
360
369
  }
361
370
 
362
371
  /** Run codemax on the real terminal until the user quits. */
363
- export async function runApp(config: Config, ledger: Ledger, cwd: string, agent: AgentId, fresh: boolean) {
372
+ export async function runApp(config: Config, ledger: Ledger, cwd: string, agent: AgentId, fresh: boolean, explicit = false) {
364
373
  const renderer = await createCliRenderer({ exitOnCtrlC: false, exitSignals: ["SIGTERM", "SIGHUP"], useMouse: true, targetFps: 60 });
365
374
  const app = new App(renderer, config, ledger, cwd);
366
375
  try {
367
- await app.start(agent, fresh);
376
+ await app.start(agent, fresh, { explicit });
368
377
  } finally {
369
378
  renderer.destroy();
370
379
  try {