codemax-cli 0.1.1 → 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/README.md +14 -1
- package/bin/codemax.cjs +3 -2
- package/package.json +13 -4
- package/src/adapters/index.ts +14 -8
- package/src/chat/app.ts +641 -59
- package/src/chat/logo.ts +37 -0
- package/src/chat/models.ts +33 -0
- package/src/chat/palette.ts +14 -6
- package/src/chat/theme.ts +3 -1
- package/src/chat/transcript.ts +27 -6
- package/src/cli.ts +52 -6
- package/src/config.ts +18 -0
- package/src/core/awake.ts +345 -0
- package/src/core/brief.ts +3 -2
- package/src/core/journal.ts +4 -2
- package/src/core/quota.ts +2 -1
- package/src/core/review.ts +67 -0
- package/src/core/time.ts +9 -0
- package/src/core/update.ts +90 -0
- package/src/engines/acp.ts +11 -2
- package/src/engines/codex.ts +53 -4
- package/src/engines/index.ts +6 -6
- package/src/engines/types.ts +11 -0
- package/src/tui/app.ts +13 -4
package/src/chat/app.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { BoxRenderable, TextRenderable, TextareaRenderable, createCliRenderer, type CliRenderer, type KeyEvent } from "@opentui/core";
|
|
2
2
|
import type { Config } from "../config.ts";
|
|
3
|
-
import { hotkeyLabel, saveConfigKey } from "../config.ts";
|
|
3
|
+
import { CONFIG_PATH, hotkeyLabel, saveConfigKey } from "../config.ts";
|
|
4
|
+
import { homedir } from "node:os";
|
|
4
5
|
import type { AgentId, QuotaStatus, SessionRef } from "../adapters/types.ts";
|
|
5
6
|
import { listAdapters, getAdapter } from "../adapters/index.ts";
|
|
6
7
|
import { Ledger } from "../core/ledger.ts";
|
|
@@ -26,16 +27,25 @@ import pkg from "../../package.json" with { type: "json" };
|
|
|
26
27
|
import { platformArgv, shellArgv } from "../core/spawn.ts";
|
|
27
28
|
import { cleanError, isLimitNotice, parseResetTime } from "../engines/errors.ts";
|
|
28
29
|
import { TurnRecorder } from "../core/journal.ts";
|
|
30
|
+
import { Awake, defaultAwakeDeps, recoverAwake } from "../core/awake.ts";
|
|
31
|
+
import { buildReviewPrompt, forwardMessage, isForwardedNote, parseNote } from "../core/review.ts";
|
|
32
|
+
import { logoLines } from "./logo.ts";
|
|
33
|
+
import { checkForUpdate, updatePlan } from "../core/update.ts";
|
|
34
|
+
import { sortModels } from "./models.ts";
|
|
35
|
+
import { fmtDateTime12 } from "../core/time.ts";
|
|
29
36
|
|
|
30
37
|
const HOTKEY_NAMES: Record<string, string[]> = { "/": ["/", "_", "?"], "^": ["^", "6"] };
|
|
31
38
|
const VERSION = pkg.version;
|
|
32
39
|
const MODE_CYCLE = ["default", "acceptEdits", "plan"];
|
|
40
|
+
/** Most side-agent notes sent automatically per task; more wait for /forward. */
|
|
41
|
+
const AUTO_FORWARD_CAP = 2;
|
|
33
42
|
|
|
34
43
|
interface Active {
|
|
35
44
|
agent: AgentId;
|
|
36
45
|
name: string;
|
|
37
46
|
engine: Engine;
|
|
38
|
-
|
|
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;
|
|
39
49
|
replay: AgentBlock | null;
|
|
40
50
|
unsubscribe: () => void;
|
|
41
51
|
mode?: string;
|
|
@@ -67,6 +77,7 @@ export class ChatApp {
|
|
|
67
77
|
private input: TextareaRenderable;
|
|
68
78
|
private inputWrap: BoxRenderable;
|
|
69
79
|
private dialogHost: BoxRenderable;
|
|
80
|
+
private queueBox!: BoxRenderable;
|
|
70
81
|
private footerText: TextRenderable;
|
|
71
82
|
private shortcuts: BoxRenderable;
|
|
72
83
|
private flash = "";
|
|
@@ -89,6 +100,21 @@ export class ChatApp {
|
|
|
89
100
|
private switching = false;
|
|
90
101
|
/** Set by quit(); async work that resumes afterwards (a handoff being built) stops. */
|
|
91
102
|
private closed = false;
|
|
103
|
+
/** Side agent (/side): reviews the main agent's work after each turn. */
|
|
104
|
+
private reviewer: { agent: AgentId; name: string; engine: Engine; busy: boolean; pending: boolean; since: number; primed: boolean } | null = null;
|
|
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 }>>();
|
|
108
|
+
/** Working turns since the side agent last reviewed (for `sideAgent.every`). */
|
|
109
|
+
private turnsSinceReview = 0;
|
|
110
|
+
/** Notes sent automatically per task (auto mode is capped so the two agents cannot loop). */
|
|
111
|
+
private autoForwards = new Map<string, number>();
|
|
112
|
+
/** A newer codemax on npm, when the update check found one. */
|
|
113
|
+
private updateAvailable: string | null = null;
|
|
114
|
+
/** Update check (tests swap it; it never runs under `bun test`). */
|
|
115
|
+
updateChecker: (current: string) => Promise<string | null> = (current) => (process.env.NODE_ENV === "test" ? Promise.resolve(null) : checkForUpdate(current));
|
|
116
|
+
/** Keep-awake (/awake). Tests swap its system runner. */
|
|
117
|
+
awake = new Awake({ ...defaultAwakeDeps(), interactive: (fn) => this.withTerminal(fn) });
|
|
92
118
|
private resolveDone!: () => void;
|
|
93
119
|
readonly done: Promise<void>;
|
|
94
120
|
/** Test hooks. */
|
|
@@ -115,6 +141,10 @@ export class ChatApp {
|
|
|
115
141
|
root.add(this.working);
|
|
116
142
|
this.dialogHost = new BoxRenderable(renderer, { id: "dialog-host", width: "100%", flexShrink: 0, flexDirection: "column" });
|
|
117
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);
|
|
118
148
|
this.inputWrap = new BoxRenderable(renderer, { id: "input-wrap", width: "100%", flexShrink: 0, flexDirection: "column", border: ["top", "bottom"], borderStyle: "single", borderColor: C.accent });
|
|
119
149
|
const inputRow = new BoxRenderable(renderer, { id: "input-row", width: "100%", flexDirection: "row" });
|
|
120
150
|
inputRow.add(new TextRenderable(renderer, { id: "input-prompt", content: `${G.prompt} `, width: 2, fg: C.accent }));
|
|
@@ -160,12 +190,16 @@ export class ChatApp {
|
|
|
160
190
|
});
|
|
161
191
|
|
|
162
192
|
renderer.keyInput.on("keypress", this.onKey);
|
|
163
|
-
renderer.on("resize", () => this.
|
|
193
|
+
renderer.on("resize", () => this.safely("resize", () => {
|
|
194
|
+
this.palette?.render();
|
|
195
|
+
this.renderFooter();
|
|
196
|
+
}));
|
|
164
197
|
this.input.focus();
|
|
165
198
|
this.renderFooter();
|
|
166
199
|
}
|
|
167
200
|
|
|
168
|
-
|
|
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 } = {}) {
|
|
169
203
|
try {
|
|
170
204
|
this.today = spendToday();
|
|
171
205
|
} catch {
|
|
@@ -173,14 +207,25 @@ export class ChatApp {
|
|
|
173
207
|
}
|
|
174
208
|
await this.refreshDetect();
|
|
175
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"}`);
|
|
176
211
|
const first = ok(agent) ? agent : listAdapters(this.config.order).find((a) => ok(a.id))?.id;
|
|
177
212
|
if (!first) throw new Error("no agent with a protocol engine found (need claude, codex, opencode, grok or gemini on PATH)");
|
|
178
213
|
const notes: string[] = [];
|
|
179
214
|
if (first !== agent) notes.push(`${agent} has no protocol engine here; starting ${first}`);
|
|
215
|
+
for (const n of await recoverAwake({ ...defaultAwakeDeps(), interactive: (fn) => this.withTerminal(fn) }).catch(() => [])) notes.push(n);
|
|
216
|
+
if (this.config.awake && this.config.awake !== "off") {
|
|
217
|
+
const st = await this.awake.set(this.config.awake).catch(() => null);
|
|
218
|
+
if (st) notes.push(st.mode === "off" ? "keep-awake could not be turned on" : `keeping this machine awake${st.lid ? ", lid closed included" : ""} (/awake off to stop)`, ...st.notes);
|
|
219
|
+
}
|
|
180
220
|
const stripped = strippedKeys();
|
|
181
221
|
if (stripped.length) notes.push(`${stripped.join(", ")} unset for agents · subscription logins only`);
|
|
182
|
-
|
|
222
|
+
// Card border (2) + padding (2) + transcript padding (2): the logo must fit inside without wrapping.
|
|
223
|
+
this.transcript.header({ title: `codemax v${VERSION}`, subtitle: "one terminal, every coding agent", path: this.cwd, notes, logo: logoLines(this.renderer.width - 6) });
|
|
183
224
|
if ((this.config as any).priceUpdates !== false) void refreshLivePricesIfStale().then((l) => l && resetPriceCache());
|
|
225
|
+
if (this.config.updateCheck !== false)
|
|
226
|
+
void this.updateChecker(VERSION)
|
|
227
|
+
.then((v) => v && this.announceUpdate(v))
|
|
228
|
+
.catch(() => {});
|
|
184
229
|
if (o.pick && !fresh && this.openPicker(first)) return this.done;
|
|
185
230
|
await this.launch(first, { fresh });
|
|
186
231
|
return this.done;
|
|
@@ -188,7 +233,29 @@ export class ChatApp {
|
|
|
188
233
|
|
|
189
234
|
// ---------- keys ----------
|
|
190
235
|
|
|
191
|
-
|
|
236
|
+
/**
|
|
237
|
+
* The UI must never break: a failure while handling one event, key or redraw is
|
|
238
|
+
* shown as a short line and codemax carries on.
|
|
239
|
+
*/
|
|
240
|
+
private uiErrors = 0;
|
|
241
|
+
private safely(where: string, fn: () => void) {
|
|
242
|
+
try {
|
|
243
|
+
fn();
|
|
244
|
+
} catch (e: any) {
|
|
245
|
+
this.uiErrors++;
|
|
246
|
+
if (this.uiErrors <= 5) {
|
|
247
|
+
try {
|
|
248
|
+
this.transcript.error(`display glitch in ${where}: ${String(e?.message ?? e).slice(0, 160)} (codemax keeps going)`);
|
|
249
|
+
} catch {
|
|
250
|
+
/* the screen itself is gone */
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private onKey = (key: KeyEvent) => this.safely("key handling", () => this.handleKey(key));
|
|
257
|
+
|
|
258
|
+
private handleKey(key: KeyEvent) {
|
|
192
259
|
if (key.eventType === "release") return;
|
|
193
260
|
if (this.permission) {
|
|
194
261
|
key.stopPropagation();
|
|
@@ -262,7 +329,7 @@ export class ChatApp {
|
|
|
262
329
|
this.shortcuts.visible = !this.shortcuts.visible;
|
|
263
330
|
return;
|
|
264
331
|
}
|
|
265
|
-
}
|
|
332
|
+
}
|
|
266
333
|
|
|
267
334
|
private isHotkey(key: KeyEvent): boolean {
|
|
268
335
|
if (key.raw && findHotkey(new TextEncoder().encode(key.raw), this.hotkeys)) return true;
|
|
@@ -309,6 +376,10 @@ export class ChatApp {
|
|
|
309
376
|
else this.palette.setFilter(text.slice(1));
|
|
310
377
|
} else if (this.palette && this.paletteKind === "commands") {
|
|
311
378
|
this.closePalette();
|
|
379
|
+
} else if (this.palette && this.paletteKind !== "handoff" && this.paletteKind !== "files" && !text.includes("\n")) {
|
|
380
|
+
// Every picker filters as you type (agents, models, settings…), like Claude Code's.
|
|
381
|
+
this.palette.setFilter(text);
|
|
382
|
+
this.paletteFiltered = text.length > 0;
|
|
312
383
|
}
|
|
313
384
|
if (this.shortcuts.visible && text) this.shortcuts.visible = false;
|
|
314
385
|
}
|
|
@@ -334,9 +405,7 @@ export class ChatApp {
|
|
|
334
405
|
const busy = !!this.active?.turn;
|
|
335
406
|
const immediate = /^\/(quit|exit|q|help|status|usage|cost|diff|clear|switch|agents)\b/.test(text);
|
|
336
407
|
if (busy && !immediate && (text.startsWith("/") || text.startsWith("!"))) {
|
|
337
|
-
this.
|
|
338
|
-
this.transcript.note(`queued until the turn ends: ${text.slice(0, 60)}`, C.dim);
|
|
339
|
-
this.renderFooter();
|
|
408
|
+
this.enqueue(text);
|
|
340
409
|
return;
|
|
341
410
|
}
|
|
342
411
|
if (text.startsWith("/")) return this.command(text);
|
|
@@ -348,8 +417,7 @@ export class ChatApp {
|
|
|
348
417
|
const route = this.routeFor(text);
|
|
349
418
|
if (route && route !== this.active.agent && !this.active.turn) return this.askAgent(route, text, `routed to ${route} by rule`);
|
|
350
419
|
if (this.active.turn) {
|
|
351
|
-
this.
|
|
352
|
-
this.transcript.note(`queued: ${text.slice(0, 80)}${text.length > 80 ? "…" : ""}`, C.dim);
|
|
420
|
+
this.enqueue(text);
|
|
353
421
|
return;
|
|
354
422
|
}
|
|
355
423
|
await this.send(this.active, text);
|
|
@@ -552,24 +620,30 @@ export class ChatApp {
|
|
|
552
620
|
}
|
|
553
621
|
|
|
554
622
|
/** Startup picker: resume a recent session or start fresh. Returns false when there is nothing to pick. */
|
|
623
|
+
/**
|
|
624
|
+
* Startup: choose the agent to continue with. One row per usable agent, most
|
|
625
|
+
* recently used first, resuming its saved session when there is one.
|
|
626
|
+
*/
|
|
555
627
|
private openPicker(defaultAgent: AgentId): boolean {
|
|
556
|
-
const sessions =
|
|
557
|
-
if (!sessions.length) return false;
|
|
558
|
-
sessions.sort((a, b) => b.lastUsedAt - a.lastUsedAt);
|
|
628
|
+
const sessions = this.ledger.sessions();
|
|
559
629
|
const turns = this.ledger.turns();
|
|
560
|
-
const
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
};
|
|
630
|
+
const usable = listAdapters(this.config.order).filter((a) => this.detect.get(a.id)?.installed && this.detect.get(a.id)?.engine);
|
|
631
|
+
if (!usable.length) return false;
|
|
632
|
+
const recent = [...usable].sort((a, b) => (sessions[b.id]?.lastUsedAt ?? 0) - (sessions[a.id]?.lastUsedAt ?? 0));
|
|
633
|
+
const items: PaletteItem[] = recent.map((a, i) => {
|
|
634
|
+
const s = sessions[a.id];
|
|
635
|
+
const last = [...turns].reverse().find((t) => t.agent === a.id && t.role === "user");
|
|
636
|
+
const q = this.quota.get(a.id);
|
|
637
|
+
const bits = [s ? `resume · ${ago(s.lastUsedAt)}${last ? ` · "${last.text.replace(/\s+/g, " ").slice(0, 50)}"` : ""}` : "new session"];
|
|
638
|
+
if (q && quotaLabel(q)) bits.push(quotaLabel(q));
|
|
639
|
+
return { label: a.name, description: bits.join(" · "), value: `resume:${a.id}`, weight: 100 - i };
|
|
568
640
|
});
|
|
569
|
-
|
|
641
|
+
const fresh = recent[0]!;
|
|
642
|
+
items.push({ label: `Start fresh with ${fresh.name}`, description: "new session (history stays in the ledger)", value: `new:${fresh.id}`, weight: 0 });
|
|
643
|
+
defaultAgent = sessions[fresh.id] ? fresh.id : defaultAgent;
|
|
570
644
|
this.paletteKind = "picker";
|
|
571
645
|
this.palette = new Palette(this.renderer, {
|
|
572
|
-
title:
|
|
646
|
+
title: `Choose an agent to continue with · Enter to start · Esc for ${getAdapter(defaultAgent).name}`,
|
|
573
647
|
items,
|
|
574
648
|
onPick: (it) => {
|
|
575
649
|
this.closePalette();
|
|
@@ -578,13 +652,33 @@ export class ChatApp {
|
|
|
578
652
|
},
|
|
579
653
|
onClose: () => {
|
|
580
654
|
this.closePalette();
|
|
581
|
-
void this.launch(
|
|
655
|
+
void this.launch(defaultAgent, { fresh: false });
|
|
582
656
|
},
|
|
583
657
|
}, this.paletteAnchor);
|
|
584
658
|
this.renderer.root.add(this.palette.box);
|
|
585
659
|
return true;
|
|
586
660
|
}
|
|
587
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
|
+
|
|
588
682
|
/** Run one queued entry the same way as if it had just been typed. */
|
|
589
683
|
private async dispatch(active: Active, text: string) {
|
|
590
684
|
if (text.startsWith("!")) return this.shell(text.slice(1).trim());
|
|
@@ -593,11 +687,16 @@ export class ChatApp {
|
|
|
593
687
|
}
|
|
594
688
|
|
|
595
689
|
private async drainQueue(active: Active) {
|
|
596
|
-
while (this.queue.length && this.active === active && !active.turn)
|
|
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
|
+
}
|
|
597
695
|
this.renderFooter();
|
|
598
696
|
}
|
|
599
697
|
|
|
600
698
|
private async send(active: Active, text: string) {
|
|
699
|
+
if (this.closed) return;
|
|
601
700
|
this.transcript.user(text);
|
|
602
701
|
text = this.withAttachments(text);
|
|
603
702
|
const cp = await this.checkpoint(active, text);
|
|
@@ -635,11 +734,55 @@ export class ChatApp {
|
|
|
635
734
|
{ name: "/checkpoints", description: "List working-tree snapshots taken before each turn", run: () => void this.showCheckpoints() },
|
|
636
735
|
{ name: "/ask", description: "Send one message to another agent without switching: /ask codex <msg>", run: (a) => void this.ask(a) },
|
|
637
736
|
{ name: "/budget", description: "Today's spend against your daily budgets", run: () => this.showBudget() },
|
|
737
|
+
{ name: "/update", description: "Update codemax to the latest version", run: () => void this.runUpdate() },
|
|
738
|
+
{ name: "/config", description: "Settings: theme, keep awake, side agent and model, handoffs, checkpoints…", run: () => this.openConfig() },
|
|
739
|
+
{ name: "/side", description: "Side agent that reviews each turn: /side (pick agent and model) · /side codex <model> [auto] · /side off", run: (a) => void this.setSide(a) },
|
|
740
|
+
{ name: "/review", description: "Ask the side agent to review the latest work now", run: () => void this.reviewNow() },
|
|
741
|
+
{ name: "/notes", description: "Notes the side agent left", run: () => this.showNotes() },
|
|
742
|
+
{ name: "/forward", description: "Send the side agent's latest note (or /forward <n>) to the main agent", run: (a) => this.forwardNote(a.trim() ? Number(a) - 1 : undefined) },
|
|
743
|
+
{ name: "/awake", description: "Keep the machine awake while agents work: /awake on | lid | off", run: (a) => void this.setAwake(a) },
|
|
638
744
|
{ name: "/theme", description: "Colour theme: /theme dark | light | auto (saved; restart to apply everywhere)", run: (a) => this.setThemePref(a) },
|
|
639
745
|
{ name: "/quit", description: "Quit codemax", run: () => void this.quit() },
|
|
640
746
|
];
|
|
641
747
|
}
|
|
642
748
|
|
|
749
|
+
/** Run something that needs the real terminal (a sudo password prompt): pause the screen around it. */
|
|
750
|
+
private async withTerminal<T>(fn: () => Promise<T>): Promise<T> {
|
|
751
|
+
try {
|
|
752
|
+
await this.renderer.suspend();
|
|
753
|
+
} catch {
|
|
754
|
+
/* test renderer */
|
|
755
|
+
}
|
|
756
|
+
try {
|
|
757
|
+
return await fn();
|
|
758
|
+
} finally {
|
|
759
|
+
try {
|
|
760
|
+
await this.renderer.resume();
|
|
761
|
+
} catch {
|
|
762
|
+
/* test renderer */
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
private async setAwake(arg: string) {
|
|
768
|
+
const v = arg.trim().toLowerCase();
|
|
769
|
+
if (v !== "on" && v !== "lid" && v !== "off") {
|
|
770
|
+
const s = this.awake.status;
|
|
771
|
+
this.transcript.note(`awake: ${s.mode}${s.lid ? " (lid closed included)" : ""} · /awake on keeps the machine from sleeping · /awake lid also keeps working with the lid closed · /awake off`);
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
const st = await this.awake.set(v);
|
|
775
|
+
try {
|
|
776
|
+
saveConfigKey("awake", st.mode);
|
|
777
|
+
} catch {
|
|
778
|
+
/* read-only home */
|
|
779
|
+
}
|
|
780
|
+
this.config.awake = st.mode;
|
|
781
|
+
this.transcript.note(st.mode === "off" ? (v === "off" ? "awake off: the machine can sleep normally again" : "keep-awake could not be turned on") : `awake ${st.mode}: this machine will not sleep while codemax runs${st.lid ? ", even with the lid closed" : ""}`, st.mode === "off" ? C.dim : C.accent);
|
|
782
|
+
for (const n of st.notes) this.transcript.note(n, C.warn);
|
|
783
|
+
this.renderFooter();
|
|
784
|
+
}
|
|
785
|
+
|
|
643
786
|
private setThemePref(arg: string) {
|
|
644
787
|
const v = arg.trim().toLowerCase();
|
|
645
788
|
if (v !== "dark" && v !== "light" && v !== "auto") {
|
|
@@ -662,11 +805,17 @@ export class ChatApp {
|
|
|
662
805
|
const name = raw!.toLowerCase();
|
|
663
806
|
const b = this.builtins().find((x) => x.name === name || x.name === name.replace(/^\/(exit|q)$/, "/quit"));
|
|
664
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
|
+
}
|
|
665
815
|
if (name === "/exit" || name === "/q") return this.quit();
|
|
666
816
|
if (this.active?.commands.some((c) => `/${c.name}` === name)) {
|
|
667
817
|
if (this.active.turn) {
|
|
668
|
-
this.
|
|
669
|
-
this.transcript.note(`queued ${name} until the turn ends`, C.dim);
|
|
818
|
+
this.enqueue(line);
|
|
670
819
|
return;
|
|
671
820
|
}
|
|
672
821
|
return this.send(this.active, line);
|
|
@@ -676,7 +825,12 @@ export class ChatApp {
|
|
|
676
825
|
|
|
677
826
|
private runCommandValue(value: string, args: string) {
|
|
678
827
|
if (value.startsWith("builtin:")) return this.builtins().find((b) => b.name === value.slice(8))?.run(args);
|
|
679
|
-
if (value.startsWith("agentcmd:"))
|
|
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
|
+
}
|
|
680
834
|
}
|
|
681
835
|
|
|
682
836
|
private pendingShell: string[] = [];
|
|
@@ -724,7 +878,8 @@ export class ChatApp {
|
|
|
724
878
|
this.paletteKind = "modes";
|
|
725
879
|
this.palette = new Palette(this.renderer, {
|
|
726
880
|
title: `Model for ${a.name}`,
|
|
727
|
-
|
|
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 })),
|
|
728
883
|
onPick: (it) => {
|
|
729
884
|
this.closePalette();
|
|
730
885
|
void apply(it.value);
|
|
@@ -834,6 +989,13 @@ export class ChatApp {
|
|
|
834
989
|
const all = this.ledger.usage();
|
|
835
990
|
for (const l of renderUsageTable(all, { title: "Usage for this project" })) this.transcript.note(l);
|
|
836
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)");
|
|
837
999
|
}
|
|
838
1000
|
|
|
839
1001
|
private showStatus() {
|
|
@@ -868,9 +1030,12 @@ export class ChatApp {
|
|
|
868
1030
|
const builtinNames = new Set(this.builtins().map((b) => b.name));
|
|
869
1031
|
const items: PaletteItem[] = [
|
|
870
1032
|
...this.builtins().map((b) => ({ label: b.name, description: b.description, value: `builtin:${b.name}`, weight: 1 })),
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
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
|
+
}),
|
|
874
1039
|
];
|
|
875
1040
|
this.paletteKind = "commands";
|
|
876
1041
|
this.palette = new Palette(this.renderer, {
|
|
@@ -950,10 +1115,15 @@ export class ChatApp {
|
|
|
950
1115
|
this.renderer.root.add(this.palette.box);
|
|
951
1116
|
}
|
|
952
1117
|
|
|
1118
|
+
/** True while text in the prompt is only filtering a picker; it is cleared when the picker closes. */
|
|
1119
|
+
private paletteFiltered = false;
|
|
1120
|
+
|
|
953
1121
|
private closePalette(_focus = true) {
|
|
954
1122
|
this.palette?.destroy();
|
|
955
1123
|
this.palette = null;
|
|
956
1124
|
this.paletteKind = null;
|
|
1125
|
+
if (this.paletteFiltered) this.input.clear();
|
|
1126
|
+
this.paletteFiltered = false;
|
|
957
1127
|
this.input.focus();
|
|
958
1128
|
}
|
|
959
1129
|
|
|
@@ -1035,6 +1205,7 @@ export class ChatApp {
|
|
|
1035
1205
|
}
|
|
1036
1206
|
|
|
1037
1207
|
private async runTurn(active: Active, text: string, o: { record?: boolean; rec?: TurnRecorder; journal?: boolean } = {}) {
|
|
1208
|
+
if (this.closed) return;
|
|
1038
1209
|
const block = this.transcript.agent();
|
|
1039
1210
|
const rec = o.journal === false ? undefined : (o.rec ?? new TurnRecorder(this.ledger.journal, active.agent));
|
|
1040
1211
|
active.turn = { block, tools: new Set(), startedAt: Date.now(), tokens: 0, chars: 0, verb: pick(VERBS), rec };
|
|
@@ -1053,10 +1224,19 @@ export class ChatApp {
|
|
|
1053
1224
|
if (turn && this.config.bellAfterSeconds > 0 && Date.now() - turn.startedAt > this.config.bellAfterSeconds * 1000) process.stdout.write("\x07");
|
|
1054
1225
|
if (turn) {
|
|
1055
1226
|
const dur = fmtDuration(Date.now() - turn.startedAt);
|
|
1056
|
-
|
|
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}`);
|
|
1057
1229
|
const reply = block.text().trim();
|
|
1058
1230
|
if (isLimitNotice(reply)) turn.rec?.markLimit(reply);
|
|
1059
1231
|
turn.rec?.end(stop);
|
|
1232
|
+
// The side agent reviews main-agent turns that did work (not /ask, not summaries).
|
|
1233
|
+
// A turn that only answers a forwarded note is not reviewed: note → turn → review → note
|
|
1234
|
+
// would loop on both plans. Its work is covered by the next review after a user turn.
|
|
1235
|
+
if (turn.rec && this.active === active && !this.side.has(active) && !isForwardedNote(text) && (turn.tools.size > 0 || reply.length > 0)) {
|
|
1236
|
+
const every = this.config.sideAgent?.every ?? 1;
|
|
1237
|
+
this.turnsSinceReview++;
|
|
1238
|
+
if (every > 0 && this.turnsSinceReview >= every) void this.review();
|
|
1239
|
+
}
|
|
1060
1240
|
if (isLimitNotice(reply)) {
|
|
1061
1241
|
// The agent ended the turn with only a limit notice: treat it as running out, and leave
|
|
1062
1242
|
// the user's request unanswered in the ledger so the next agent is asked to finish it.
|
|
@@ -1076,6 +1256,10 @@ export class ChatApp {
|
|
|
1076
1256
|
}
|
|
1077
1257
|
|
|
1078
1258
|
private onEngineEvent(active: Active, e: EngineEvent) {
|
|
1259
|
+
this.safely(`${active.name} event`, () => this.handleEngineEvent(active, e));
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
private handleEngineEvent(active: Active, e: EngineEvent) {
|
|
1079
1263
|
if (this.active !== active && !this.side.has(active) && e.type !== "permission_request") return;
|
|
1080
1264
|
const target = () => active.turn?.block ?? (active.replay ??= this.transcript.agent());
|
|
1081
1265
|
switch (e.type) {
|
|
@@ -1119,32 +1303,20 @@ export class ChatApp {
|
|
|
1119
1303
|
break;
|
|
1120
1304
|
case "usage": {
|
|
1121
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;
|
|
1122
1307
|
if (typeof e.contextUsed === "number" && typeof e.contextSize === "number" && e.contextSize > 0) {
|
|
1123
1308
|
active.context = { used: e.contextUsed, size: e.contextSize };
|
|
1124
1309
|
this.renderFooter();
|
|
1125
1310
|
}
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
agent: active.agent,
|
|
1133
|
-
sessionId: active.engine.session?.id ?? "?",
|
|
1134
|
-
model: e.model ?? active.engine.info?.currentModel,
|
|
1135
|
-
input: e.inputTokens ?? 0,
|
|
1136
|
-
output: e.outputTokens ?? 0,
|
|
1137
|
-
cacheRead: e.cacheReadTokens ?? 0,
|
|
1138
|
-
cacheWrite: e.cacheWriteTokens ?? 0,
|
|
1139
|
-
agentCostUsd: typeof e.costUsd === "number" ? e.costUsd : undefined,
|
|
1140
|
-
});
|
|
1141
|
-
this.ledger.addUsage(rec);
|
|
1142
|
-
this.sessionCost += recordCost(rec).usd;
|
|
1143
|
-
this.sessionTokens += rec.input + rec.output + rec.cacheRead + rec.cacheWrite;
|
|
1144
|
-
addSpend(this.today, rec);
|
|
1145
|
-
this.renderFooter();
|
|
1146
|
-
this.checkBudget(active.agent);
|
|
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() });
|
|
1147
1317
|
}
|
|
1318
|
+
if (typeof e.usedPercent === "number") this.onQuota({ agent: active.agent, usedPercent: e.usedPercent, exhausted: e.usedPercent >= 100, source: "probe", at: Date.now() });
|
|
1319
|
+
this.recordUsage(active.agent, active.engine, e);
|
|
1148
1320
|
break;
|
|
1149
1321
|
}
|
|
1150
1322
|
case "session":
|
|
@@ -1205,6 +1377,399 @@ export class ChatApp {
|
|
|
1205
1377
|
|
|
1206
1378
|
// ---------- working line, footer ----------
|
|
1207
1379
|
|
|
1380
|
+
/** Price and record a usage event (main, /ask and side-agent sessions alike). */
|
|
1381
|
+
private recordUsage(agent: AgentId, engine: Engine, e: Extract<EngineEvent, { type: "usage" }>) {
|
|
1382
|
+
const hasTokens = (e.inputTokens ?? 0) + (e.outputTokens ?? 0) + (e.cacheReadTokens ?? 0) + (e.cacheWriteTokens ?? 0) > 0;
|
|
1383
|
+
// A zero cost with no tokens (free models report cost 0 on every update) is not usage.
|
|
1384
|
+
if (!hasTokens && !((e.costUsd ?? 0) > 0)) return;
|
|
1385
|
+
const rec = priceRecord({
|
|
1386
|
+
ts: Date.now(),
|
|
1387
|
+
agent,
|
|
1388
|
+
sessionId: engine.session?.id ?? "?",
|
|
1389
|
+
model: e.model ?? engine.info?.currentModel,
|
|
1390
|
+
input: e.inputTokens ?? 0,
|
|
1391
|
+
output: e.outputTokens ?? 0,
|
|
1392
|
+
cacheRead: e.cacheReadTokens ?? 0,
|
|
1393
|
+
cacheWrite: e.cacheWriteTokens ?? 0,
|
|
1394
|
+
agentCostUsd: typeof e.costUsd === "number" ? e.costUsd : undefined,
|
|
1395
|
+
});
|
|
1396
|
+
this.ledger.addUsage(rec);
|
|
1397
|
+
this.sessionCost += recordCost(rec).usd;
|
|
1398
|
+
this.sessionTokens += rec.input + rec.output + rec.cacheRead + rec.cacheWrite;
|
|
1399
|
+
addSpend(this.today, rec);
|
|
1400
|
+
this.renderFooter();
|
|
1401
|
+
this.checkBudget(agent);
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
// ---------- side agent ----------
|
|
1405
|
+
|
|
1406
|
+
/**
|
|
1407
|
+
* /side pick the side agent, then its model
|
|
1408
|
+
* /side <agent> [model] [auto|ask]
|
|
1409
|
+
* /side off
|
|
1410
|
+
*/
|
|
1411
|
+
private async setSide(arg: string) {
|
|
1412
|
+
const words = arg.trim().split(/\s+/).filter(Boolean);
|
|
1413
|
+
const cfg = this.config.sideAgent ?? { agent: null, forward: "ask" as const };
|
|
1414
|
+
if (!words.length) return this.openSidePicker();
|
|
1415
|
+
const who = words[0]!.toLowerCase();
|
|
1416
|
+
if (who === "off") {
|
|
1417
|
+
await this.closeReviewer();
|
|
1418
|
+
this.saveSide({ ...cfg, agent: null, model: undefined });
|
|
1419
|
+
this.transcript.note("side agent off", C.dim);
|
|
1420
|
+
return this.renderFooter();
|
|
1421
|
+
}
|
|
1422
|
+
if (!this.detect.get(who as AgentId)?.engine) return this.transcript.note(`no protocol engine for "${who}" · /side to pick one`, C.warn);
|
|
1423
|
+
const rest = words.slice(1);
|
|
1424
|
+
const forward = rest.includes("auto") ? "auto" : rest.includes("ask") ? "ask" : cfg.agent === who ? cfg.forward : "ask";
|
|
1425
|
+
const model = rest.find((w) => w !== "auto" && w !== "ask");
|
|
1426
|
+
await this.enableSide(who as AgentId, { forward, model, pickModel: false });
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
private saveSide(v: Config["sideAgent"]) {
|
|
1430
|
+
this.config.sideAgent = v;
|
|
1431
|
+
try {
|
|
1432
|
+
saveConfigKey("sideAgent", v);
|
|
1433
|
+
} catch {
|
|
1434
|
+
/* read-only home */
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
private async enableSide(agent: AgentId, o: { forward: "ask" | "auto"; model?: string; pickModel: boolean }) {
|
|
1439
|
+
const prev = this.config.sideAgent;
|
|
1440
|
+
if (prev?.agent !== agent || (o.model && o.model !== prev.model)) await this.closeReviewer();
|
|
1441
|
+
// Keep the user's other side-agent settings (review cadence) when switching agent.
|
|
1442
|
+
this.saveSide({ ...prev, agent, forward: o.forward, model: o.model ?? (prev?.agent === agent ? prev.model : undefined) });
|
|
1443
|
+
const name = getAdapter(agent).name;
|
|
1444
|
+
if (agent === this.active?.agent) this.transcript.note(`${name} is also the main agent: both use one ${name} plan, so each review brings the main agent's limit closer. A different subscription (or a cheaper model: /side ${agent} <model>, or fewer reviews in /config) keeps it from running out sooner.`, C.warn);
|
|
1445
|
+
this.transcript.note(`side agent: ${name}${this.config.sideAgent.model ? ` (${this.config.sideAgent.model})` : ""} reviews ${reviewCadence(this.config.sideAgent.every)} and speaks up only when something needs attention${o.forward === "auto" ? "; its notes go to the main agent automatically" : " · /forward sends a note to the main agent"}`, C.accent);
|
|
1446
|
+
this.renderFooter();
|
|
1447
|
+
if (!o.pickModel) return;
|
|
1448
|
+
// Start the reviewer now so its real model list can be offered.
|
|
1449
|
+
const r = await this.ensureReviewer();
|
|
1450
|
+
const models = r?.engine.info?.models ?? [];
|
|
1451
|
+
if (!r || models.length < 2) {
|
|
1452
|
+
if (r && models.length === 1) this.transcript.note(`${name} offers one model on your plan: ${models[0]!.name}`);
|
|
1453
|
+
return;
|
|
1454
|
+
}
|
|
1455
|
+
this.closePalette();
|
|
1456
|
+
this.paletteKind = "modes";
|
|
1457
|
+
this.palette = new Palette(this.renderer, {
|
|
1458
|
+
title: `Model for the side agent (${name}) · Esc keeps ${shortModelName(r.engine.info?.currentModel ?? "default")}`,
|
|
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 })),
|
|
1460
|
+
onPick: (it) => {
|
|
1461
|
+
this.closePalette();
|
|
1462
|
+
void this.setSideModel(it.value);
|
|
1463
|
+
},
|
|
1464
|
+
onClose: () => this.closePalette(),
|
|
1465
|
+
}, this.paletteAnchor);
|
|
1466
|
+
this.renderer.root.add(this.palette.box);
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
private async setSideModel(model: string) {
|
|
1470
|
+
const cfg = this.config.sideAgent;
|
|
1471
|
+
if (!cfg?.agent) return this.transcript.note("no side agent · /side to pick one", C.warn);
|
|
1472
|
+
this.saveSide({ ...cfg, model });
|
|
1473
|
+
const r = this.reviewer;
|
|
1474
|
+
try {
|
|
1475
|
+
if (r?.engine.setModel) await r.engine.setModel(model);
|
|
1476
|
+
this.transcript.note(`side agent model: ${model}`);
|
|
1477
|
+
if (cfg.agent === "cursor") this.transcript.note("Cursor saves this as its default model, so Cursor uses it outside codemax too", C.warn);
|
|
1478
|
+
} catch (e: any) {
|
|
1479
|
+
this.transcript.error(`could not set the side agent's model: ${e?.message ?? e}`);
|
|
1480
|
+
}
|
|
1481
|
+
this.renderFooter();
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
// ---------- settings (/config) ----------
|
|
1485
|
+
|
|
1486
|
+
private settings(): { key: string; label: string; value: string; choices: { value: string; label?: string; description?: string }[]; apply: (v: string) => void | Promise<void> }[] {
|
|
1487
|
+
const c = this.config;
|
|
1488
|
+
const onOff = (b: boolean) => (b ? "on" : "off");
|
|
1489
|
+
const bool = (key: "autoSwitch" | "summarizeOnSwitch" | "checkpoints" | "priceUpdates") => (v: string) => {
|
|
1490
|
+
c[key] = v === "on";
|
|
1491
|
+
saveConfigKey(key, c[key]);
|
|
1492
|
+
};
|
|
1493
|
+
const side = c.sideAgent ?? { agent: null, forward: "ask" as const };
|
|
1494
|
+
const agents = listAdapters(c.order).filter((a) => this.detect.get(a.id)?.engine);
|
|
1495
|
+
return [
|
|
1496
|
+
{ key: "theme", label: "Theme", value: c.theme ?? "auto", choices: [{ value: "auto", description: "follow the terminal's background" }, { value: "dark" }, { value: "light" }], apply: (v) => this.setThemePref(v) },
|
|
1497
|
+
{ key: "awake", label: "Keep awake", value: c.awake ?? "off", choices: [{ value: "off", description: "sleep normally" }, { value: "on", description: "no sleep while codemax runs" }, { value: "lid", description: "also keep working with the lid closed" }], apply: (v) => this.setAwake(v) },
|
|
1498
|
+
{ key: "side", label: "Side agent", value: side.agent ?? "off", choices: [{ value: "off" }, ...agents.map((a) => ({ value: a.id, label: a.name, description: "pick its model next" }))], apply: (v) => (v === "off" ? this.setSide("off") : this.enableSide(v as AgentId, { forward: side.forward, pickModel: true })) },
|
|
1499
|
+
{ key: "sideModel", label: "Side agent model", value: side.agent ? (side.model ?? "default") : "(no side agent)", choices: side.agent ? [{ value: "pick", label: "Choose from its models", description: "starts the side agent to list them" }] : [], apply: async () => { if (side.agent) await this.enableSide(side.agent, { forward: side.forward, pickModel: true }); } },
|
|
1500
|
+
{
|
|
1501
|
+
key: "sideEvery",
|
|
1502
|
+
label: "Side agent reviews",
|
|
1503
|
+
value: reviewCadence(side.every),
|
|
1504
|
+
choices: [{ value: "1", label: "every turn" }, { value: "2", label: "every 2nd turn" }, { value: "3", label: "every 3rd turn" }, { value: "0", label: "only on /review", description: "no automatic reviews" }],
|
|
1505
|
+
apply: (v) => this.saveSide({ ...side, every: Number(v) }),
|
|
1506
|
+
},
|
|
1507
|
+
{ key: "sideForward", label: "Side agent notes", value: side.forward, choices: [{ value: "ask", description: "wait for /forward" }, { value: "auto", description: "send each note to the main agent" }], apply: (v) => this.saveSide({ ...side, forward: v === "auto" ? "auto" : "ask" }) },
|
|
1508
|
+
{ key: "autoSwitch", label: "Offer a handoff at limits", value: onOff(c.autoSwitch), choices: [{ value: "on" }, { value: "off" }], apply: bool("autoSwitch") },
|
|
1509
|
+
{ key: "summarizeOnSwitch", label: "Summary before switching", value: onOff(c.summarizeOnSwitch), choices: [{ value: "on", description: "only for history without a step journal" }, { value: "off" }], apply: bool("summarizeOnSwitch") },
|
|
1510
|
+
{ key: "checkpoints", label: "Snapshots for /undo", value: onOff(c.checkpoints), choices: [{ value: "on" }, { value: "off", description: "also turns off code diffs in handoffs" }], apply: bool("checkpoints") },
|
|
1511
|
+
{
|
|
1512
|
+
key: "bell",
|
|
1513
|
+
label: "Bell after long turns",
|
|
1514
|
+
value: c.bellAfterSeconds ? `${c.bellAfterSeconds}s` : "off",
|
|
1515
|
+
choices: [{ value: "0", label: "off" }, { value: "10", label: "10s" }, { value: "20", label: "20s" }, { value: "60", label: "60s" }],
|
|
1516
|
+
apply: (v) => {
|
|
1517
|
+
c.bellAfterSeconds = Number(v);
|
|
1518
|
+
saveConfigKey("bellAfterSeconds", c.bellAfterSeconds);
|
|
1519
|
+
},
|
|
1520
|
+
},
|
|
1521
|
+
{ key: "updateCheck", label: "Check for codemax updates", value: c.updateCheck === false ? "off" : "on", choices: [{ value: "on", description: "asks npm at most every 12 hours" }, { value: "off" }], apply: (v) => { c.updateCheck = v === "on"; saveConfigKey("updateCheck", c.updateCheck); } },
|
|
1522
|
+
{ key: "priceUpdates", label: "Weekly price list refresh", value: onOff(c.priceUpdates), choices: [{ value: "on" }, { value: "off", description: "stay fully offline" }], apply: bool("priceUpdates") },
|
|
1523
|
+
];
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
/** /config: every setting with its value; pick one to change it. */
|
|
1527
|
+
private openConfig() {
|
|
1528
|
+
const all = this.settings();
|
|
1529
|
+
this.closePalette();
|
|
1530
|
+
this.paletteKind = "modes";
|
|
1531
|
+
this.palette = new Palette(this.renderer, {
|
|
1532
|
+
title: `Settings · Enter to change · saved to ${CONFIG_PATH.replace(homedir(), "~")}`,
|
|
1533
|
+
// Weights keep the settings in this order (the palette otherwise sorts by label).
|
|
1534
|
+
items: all.map((x, i) => ({ label: x.label, description: x.value, value: x.key, disabled: !x.choices.length, weight: 100 - i })),
|
|
1535
|
+
maxRows: 12,
|
|
1536
|
+
onPick: (it) => {
|
|
1537
|
+
const st = all.find((x) => x.key === it.value);
|
|
1538
|
+
if (st) this.openSetting(st);
|
|
1539
|
+
},
|
|
1540
|
+
onClose: () => this.closePalette(),
|
|
1541
|
+
}, this.paletteAnchor);
|
|
1542
|
+
this.renderer.root.add(this.palette.box);
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
private openSetting(st: ReturnType<ChatApp["settings"]>[number]) {
|
|
1546
|
+
this.closePalette();
|
|
1547
|
+
this.paletteKind = "modes";
|
|
1548
|
+
this.palette = new Palette(this.renderer, {
|
|
1549
|
+
title: `${st.label} · now ${st.value} · Esc back to settings`,
|
|
1550
|
+
items: st.choices.map((ch, i) => ({ label: ch.label ?? ch.value, description: `${ch.description ?? ""}${(ch.label ?? ch.value) === st.value || ch.value === st.value ? " (current)" : ""}`, value: ch.value, weight: 100 - i })),
|
|
1551
|
+
onPick: (it) => {
|
|
1552
|
+
this.closePalette();
|
|
1553
|
+
void (async () => {
|
|
1554
|
+
try {
|
|
1555
|
+
await st.apply(it.value);
|
|
1556
|
+
if (!["theme", "awake", "side", "sideModel"].includes(st.key)) this.transcript.note(`${st.label.toLowerCase()}: ${it.label}`);
|
|
1557
|
+
} catch (e: any) {
|
|
1558
|
+
this.transcript.error(`could not change ${st.label.toLowerCase()}: ${e?.message ?? e}`);
|
|
1559
|
+
}
|
|
1560
|
+
this.renderFooter();
|
|
1561
|
+
// Back to the list for the next change, unless a follow-up picker took over.
|
|
1562
|
+
if (!this.palette) this.openConfig();
|
|
1563
|
+
})();
|
|
1564
|
+
},
|
|
1565
|
+
onClose: () => this.openConfig(),
|
|
1566
|
+
}, this.paletteAnchor);
|
|
1567
|
+
this.renderer.root.add(this.palette.box);
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
/** /side with no arguments: pick the agent (then its model). */
|
|
1571
|
+
private openSidePicker() {
|
|
1572
|
+
const cfg = this.config.sideAgent;
|
|
1573
|
+
const items: PaletteItem[] = listAdapters(this.config.order)
|
|
1574
|
+
.filter((a) => this.detect.get(a.id)?.engine)
|
|
1575
|
+
// A different subscription first: the same tool would share (and drain) the main agent's plan.
|
|
1576
|
+
.map((a) => ({ label: a.name + (a.id === cfg?.agent ? " (current)" : ""), description: a.id === this.active?.agent ? "same plan as the main agent: reviews count against its limit" : "its own subscription: reviews do not touch the main agent's limit", value: a.id, weight: a.id === this.active?.agent ? 0 : 10 }));
|
|
1577
|
+
items.push({ label: "Off", description: "no side agent", value: "off" });
|
|
1578
|
+
this.closePalette();
|
|
1579
|
+
this.paletteKind = "modes";
|
|
1580
|
+
this.palette = new Palette(this.renderer, {
|
|
1581
|
+
title: "Side agent · reviews each turn and leaves a note when something needs attention",
|
|
1582
|
+
items,
|
|
1583
|
+
onPick: (it) => {
|
|
1584
|
+
this.closePalette();
|
|
1585
|
+
if (it.value === "off") return void this.setSide("off");
|
|
1586
|
+
void this.enableSide(it.value as AgentId, { forward: cfg?.forward ?? "ask", pickModel: true });
|
|
1587
|
+
},
|
|
1588
|
+
onClose: () => this.closePalette(),
|
|
1589
|
+
}, this.paletteAnchor);
|
|
1590
|
+
this.renderer.root.add(this.palette.box);
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
/** The side agent's session, started on first use with the chosen model. */
|
|
1594
|
+
private async ensureReviewer() {
|
|
1595
|
+
const cfg = this.config.sideAgent;
|
|
1596
|
+
if (!cfg?.agent || this.closed) return null;
|
|
1597
|
+
if (this.reviewer?.agent === cfg.agent) return this.reviewer;
|
|
1598
|
+
await this.closeReviewer();
|
|
1599
|
+
if (this.checkBlockers(cfg.agent).length) return null;
|
|
1600
|
+
const engine = this.engineFactory(cfg.agent);
|
|
1601
|
+
const r = { agent: cfg.agent, name: getAdapter(cfg.agent).name, engine, busy: false, pending: false, since: this.lastReviewBaseline(), primed: false };
|
|
1602
|
+
this.reviewer = r;
|
|
1603
|
+
try {
|
|
1604
|
+
await engine.start({ cwd: this.cwd });
|
|
1605
|
+
if (cfg.model && engine.setModel && engine.info?.currentModel !== cfg.model) await engine.setModel(cfg.model).catch((e: any) => this.transcript.note(`side agent: could not use model ${cfg.model} (${e?.message ?? e}); using ${engine.info?.currentModel ?? "its default"}`, C.warn));
|
|
1606
|
+
return r;
|
|
1607
|
+
} catch (e: any) {
|
|
1608
|
+
if (this.reviewer === r) this.reviewer = null;
|
|
1609
|
+
this.transcript.note(`side agent ${r.name} could not start: ${e?.message ?? e}`, C.warn);
|
|
1610
|
+
return null;
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
private async closeReviewer() {
|
|
1615
|
+
const r = this.reviewer;
|
|
1616
|
+
this.reviewer = null;
|
|
1617
|
+
if (r) await r.engine.close().catch(() => {});
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
/** After a main turn: let the side agent review the new steps (in the background). */
|
|
1621
|
+
private async review() {
|
|
1622
|
+
const cfg = this.config.sideAgent;
|
|
1623
|
+
if (!cfg?.agent || this.closed) return;
|
|
1624
|
+
if (this.reviewer?.busy) {
|
|
1625
|
+
this.reviewer.pending = true;
|
|
1626
|
+
return;
|
|
1627
|
+
}
|
|
1628
|
+
const r = await this.ensureReviewer();
|
|
1629
|
+
if (!r) return;
|
|
1630
|
+
this.turnsSinceReview = 0;
|
|
1631
|
+
r.busy = true;
|
|
1632
|
+
this.renderFooter();
|
|
1633
|
+
try {
|
|
1634
|
+
do {
|
|
1635
|
+
r.pending = false;
|
|
1636
|
+
const steps = this.ledger.journal.steps();
|
|
1637
|
+
const startedAt = Date.now();
|
|
1638
|
+
const context = r.primed ? undefined : await buildBrief({ ledger: this.ledger, cwd: this.cwd, to: r.agent, config: this.config, purpose: "context" }).catch(() => undefined);
|
|
1639
|
+
const prompt = buildReviewPrompt({ steps, since: r.since, mainAgent: this.active?.name ?? "the main agent", reviewer: r.agent, context });
|
|
1640
|
+
if (!prompt) break;
|
|
1641
|
+
let reply = "";
|
|
1642
|
+
let limited = false;
|
|
1643
|
+
const engine = r.engine;
|
|
1644
|
+
const off = engine.onEvent((e) => {
|
|
1645
|
+
try {
|
|
1646
|
+
if (e.type === "text_delta") reply += e.text;
|
|
1647
|
+
else if (e.type === "permission_request") {
|
|
1648
|
+
// A reviewer never acts: refuse anything it asks to do.
|
|
1649
|
+
const no = e.request.options.find((o) => /reject|deny|decline/i.test(`${o.kind} ${o.id}`)) ?? e.request.options.at(-1);
|
|
1650
|
+
if (no) engine.answerPermission(e.request.id, no.id);
|
|
1651
|
+
} else if (e.type === "usage") this.recordUsage(r.agent, engine, e);
|
|
1652
|
+
else if (e.type === "error" && e.kind === "quota") limited = true;
|
|
1653
|
+
} catch {
|
|
1654
|
+
/* a bad event must never break the reviewer or the screen */
|
|
1655
|
+
}
|
|
1656
|
+
});
|
|
1657
|
+
await engine.prompt(prompt).catch(() => "error");
|
|
1658
|
+
off();
|
|
1659
|
+
if (this.closed) return;
|
|
1660
|
+
r.primed = true;
|
|
1661
|
+
r.since = startedAt;
|
|
1662
|
+
if (limited || isLimitNotice(reply)) {
|
|
1663
|
+
this.transcript.note(`side agent ${r.name} hit its usage limit; turning it off for this session`, C.warn);
|
|
1664
|
+
await this.closeReviewer();
|
|
1665
|
+
this.renderFooter();
|
|
1666
|
+
return;
|
|
1667
|
+
}
|
|
1668
|
+
const note = parseNote(reply);
|
|
1669
|
+
if (note && !this.closed) this.addNote(r.agent, r.name, note);
|
|
1670
|
+
} while (r.pending && this.reviewer === r && !this.closed);
|
|
1671
|
+
} finally {
|
|
1672
|
+
if (this.reviewer === r) r.busy = false;
|
|
1673
|
+
this.renderFooter();
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
/** Review from the start of the current task the first time. */
|
|
1678
|
+
private lastReviewBaseline(): number {
|
|
1679
|
+
const req = this.ledger.journal.task().request;
|
|
1680
|
+
return req ? req.ts - 1 : Date.now();
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
private addNote(agent: AgentId, name: string, text: string) {
|
|
1684
|
+
if (this.closed) return;
|
|
1685
|
+
this.notes.push({ ts: Date.now(), agent, name, text });
|
|
1686
|
+
this.ledger.addEvent({ kind: "note", agent, detail: `side agent note: ${text.slice(0, 2000)}` });
|
|
1687
|
+
const main = this.active?.name ?? "the main agent";
|
|
1688
|
+
const auto = this.config.sideAgent?.forward === "auto";
|
|
1689
|
+
const task = this.ledger.journal.task().request?.id ?? "none";
|
|
1690
|
+
const sent = this.autoForwards.get(task) ?? 0;
|
|
1691
|
+
const capped = auto && sent >= AUTO_FORWARD_CAP;
|
|
1692
|
+
this.transcript.noteCard(
|
|
1693
|
+
`⚑ Note from ${name} (side agent)`,
|
|
1694
|
+
text,
|
|
1695
|
+
auto && !capped ? `sent to ${main} automatically` : capped ? `auto-forward paused: ${AUTO_FORWARD_CAP} notes already sent for this task · /forward to send this one` : `/forward sends this to ${main} · /notes lists all notes`,
|
|
1696
|
+
);
|
|
1697
|
+
if (auto && !capped) {
|
|
1698
|
+
this.autoForwards.set(task, sent + 1);
|
|
1699
|
+
this.forwardNote(this.notes.length - 1);
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
private forwardNote(index = this.notes.length - 1) {
|
|
1704
|
+
if (this.closed) return;
|
|
1705
|
+
const n = this.notes[index];
|
|
1706
|
+
const a = this.active;
|
|
1707
|
+
if (!n) return this.transcript.note("no side-agent notes yet");
|
|
1708
|
+
if (!a) return this.transcript.note("no main agent to forward to", C.warn);
|
|
1709
|
+
const msg = forwardMessage(n.text, n.name);
|
|
1710
|
+
if (a.turn || this.switching) {
|
|
1711
|
+
this.enqueue(msg);
|
|
1712
|
+
return;
|
|
1713
|
+
}
|
|
1714
|
+
void this.send(a, msg);
|
|
1715
|
+
}
|
|
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
|
+
|
|
1725
|
+
private announceUpdate(version: string) {
|
|
1726
|
+
if (this.closed) return;
|
|
1727
|
+
this.updateAvailable = version;
|
|
1728
|
+
this.transcript.note(`codemax ${version} is available (you have ${VERSION}) · run /update to install it`, C.accent);
|
|
1729
|
+
this.renderFooter();
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
/** /update: update the way codemax was installed, with its output on the real terminal. */
|
|
1733
|
+
private async runUpdate() {
|
|
1734
|
+
const plan = updatePlan();
|
|
1735
|
+
if (plan.manual) return this.transcript.note(plan.manual, C.warn);
|
|
1736
|
+
if (this.active?.turn) return this.transcript.note("an agent is working; /update when the turn ends", C.warn);
|
|
1737
|
+
this.transcript.note(`updating with ${plan.label}…`);
|
|
1738
|
+
let ok = true;
|
|
1739
|
+
await this.withTerminal(async () => {
|
|
1740
|
+
process.stdout.write(`\ncodemax: updating with ${plan.label}\n\n`);
|
|
1741
|
+
for (const cmd of plan.cmd) {
|
|
1742
|
+
const p = Bun.spawn(platformArgv(cmd), { stdin: "inherit", stdout: "inherit", stderr: "inherit", env: { ...process.env, ...(plan.env ?? {}) } });
|
|
1743
|
+
if ((await p.exited) !== 0) {
|
|
1744
|
+
ok = false;
|
|
1745
|
+
break;
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
process.stdout.write(ok ? "\ncodemax: done. Returning to codemax…\n" : "\ncodemax: the update failed (see above). Returning to codemax…\n");
|
|
1749
|
+
await Bun.sleep(1200);
|
|
1750
|
+
});
|
|
1751
|
+
if (ok) {
|
|
1752
|
+
const to = this.updateAvailable;
|
|
1753
|
+
this.updateAvailable = null;
|
|
1754
|
+
this.transcript.note(`updated${to ? ` to ${to}` : ""} · restart codemax (/quit, then codemax) to use the new version`, C.ok);
|
|
1755
|
+
} else this.transcript.error(`update failed · run it yourself: ${plan.cmd.map((c) => c.join(" ")).join(" && ")}`);
|
|
1756
|
+
this.renderFooter();
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
private async reviewNow() {
|
|
1760
|
+
if (!this.config.sideAgent?.agent) return this.transcript.note("no side agent · /side to pick one", C.warn);
|
|
1761
|
+
this.transcript.note(`asking ${getAdapter(this.config.sideAgent.agent).name} to review…`);
|
|
1762
|
+
const before = this.notes.length;
|
|
1763
|
+
await this.review();
|
|
1764
|
+
if (this.notes.length === before && !this.closed) this.transcript.note("the side agent found nothing worth a note");
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
private showNotes() {
|
|
1768
|
+
if (!this.notes.length) return this.transcript.note(this.config.sideAgent?.agent ? "no notes yet: the side agent only speaks up when something needs attention" : "no side agent · /side codex adds one");
|
|
1769
|
+
this.notes.forEach((n, i) => this.transcript.note(`${i + 1}. ${fmtTime(n.ts)} ${n.name}: ${n.text.split("\n")[0]!.slice(0, 140)}`));
|
|
1770
|
+
this.transcript.note("/forward <n> sends a note to the main agent");
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1208
1773
|
private startSpinner(active: Active) {
|
|
1209
1774
|
this.stopSpinner();
|
|
1210
1775
|
this.spinnerTimer = setInterval(() => {
|
|
@@ -1219,7 +1784,7 @@ export class ChatApp {
|
|
|
1219
1784
|
else {
|
|
1220
1785
|
const elapsed = Date.now() - t.startedAt;
|
|
1221
1786
|
const tip = elapsed > 8000 ? ` ⎿ tip: ${pick(TIPS, Math.floor(t.startedAt / 1000))}` : "";
|
|
1222
|
-
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}`);
|
|
1223
1788
|
}
|
|
1224
1789
|
}, 120);
|
|
1225
1790
|
}
|
|
@@ -1240,6 +1805,7 @@ export class ChatApp {
|
|
|
1240
1805
|
}
|
|
1241
1806
|
|
|
1242
1807
|
private setWorking(text: string | null) {
|
|
1808
|
+
if (this.closed && text) return;
|
|
1243
1809
|
this.working.visible = !!text;
|
|
1244
1810
|
this.working.content = text ?? "";
|
|
1245
1811
|
}
|
|
@@ -1255,6 +1821,10 @@ export class ChatApp {
|
|
|
1255
1821
|
}
|
|
1256
1822
|
|
|
1257
1823
|
private renderFooter() {
|
|
1824
|
+
this.safely("footer", () => this.drawFooter());
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
private drawFooter() {
|
|
1258
1828
|
const a = this.active;
|
|
1259
1829
|
const width = Math.max(20, this.renderer.width - 4);
|
|
1260
1830
|
let color: string = C.dim;
|
|
@@ -1270,6 +1840,10 @@ export class ChatApp {
|
|
|
1270
1840
|
else if (this.queue.length) left.push({ text: `${this.queue.length} queued`, prio: 1 });
|
|
1271
1841
|
else if (!this.shortcuts.visible) left.push({ text: "? for shortcuts", prio: 2 });
|
|
1272
1842
|
const right: { text: string; prio: number }[] = [];
|
|
1843
|
+
if (this.updateAvailable) left.push({ text: `↑ ${this.updateAvailable} available · /update`, prio: 1 });
|
|
1844
|
+
if (this.config.sideAgent?.agent) left.push({ text: `◆ side ${this.config.sideAgent.agent}${this.config.sideAgent.model ? ` ${shortModelName(this.config.sideAgent.model)}` : ""}${this.reviewer?.busy ? " reviewing…" : ""}`, prio: 2 });
|
|
1845
|
+
// Narrow glyph: the footer is measured in characters, and an emoji takes two columns.
|
|
1846
|
+
if (this.awake.mode !== "off") left.push({ text: `● awake${this.awake.status.lid ? " · lid" : ""}`, prio: 1 });
|
|
1273
1847
|
const effort = a ? this.effortOption(a)?.current : undefined;
|
|
1274
1848
|
if (a) right.push({ text: `${a.name}${a.engine.info?.currentModel ? ` · ${shortModelName(a.engine.info.currentModel)}` : ""}${effort ? ` ${effort}` : ""}`, prio: 2 });
|
|
1275
1849
|
if (a?.context) right.push({ text: `ctx ${Math.min(100, Math.round((a.context.used / a.context.size) * 100))}%`, prio: 1 });
|
|
@@ -1345,6 +1919,8 @@ export class ChatApp {
|
|
|
1345
1919
|
|
|
1346
1920
|
async quit() {
|
|
1347
1921
|
this.closed = true;
|
|
1922
|
+
await this.awake.release().catch(() => []);
|
|
1923
|
+
await this.closeReviewer();
|
|
1348
1924
|
this.switching = true;
|
|
1349
1925
|
const a = this.active;
|
|
1350
1926
|
this.closePalette();
|
|
@@ -1377,13 +1953,14 @@ function ago(ts: number): string {
|
|
|
1377
1953
|
return h < 48 ? `${h}h ago` : `${Math.round(h / 24)}d ago`;
|
|
1378
1954
|
}
|
|
1379
1955
|
|
|
1380
|
-
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) {
|
|
1381
1957
|
const renderer = await createCliRenderer({ exitOnCtrlC: false, exitSignals: ["SIGTERM", "SIGHUP"], useMouse: true, targetFps: 60 });
|
|
1382
1958
|
const pref = config.theme ?? "auto";
|
|
1383
1959
|
applyTheme(pref === "auto" ? ((await renderer.waitForThemeMode(250).catch(() => null)) ?? "dark") : pref);
|
|
1384
1960
|
const app = new ChatApp(renderer, config, ledger, cwd);
|
|
1961
|
+
process.once("exit", () => app.awake.releaseSync());
|
|
1385
1962
|
try {
|
|
1386
|
-
await app.start(agent, fresh, { pick });
|
|
1963
|
+
await app.start(agent, fresh, { pick, explicit });
|
|
1387
1964
|
} finally {
|
|
1388
1965
|
renderer.destroy();
|
|
1389
1966
|
try {
|
|
@@ -1420,3 +1997,8 @@ async function copyToClipboard(renderer: CliRenderer, text: string): Promise<boo
|
|
|
1420
1997
|
}
|
|
1421
1998
|
return osc;
|
|
1422
1999
|
}
|
|
2000
|
+
|
|
2001
|
+
function reviewCadence(every: number | undefined): string {
|
|
2002
|
+
const n = every ?? 1;
|
|
2003
|
+
return n === 0 ? "only on /review" : n === 1 ? "every turn" : n === 2 ? "every 2nd turn" : n === 3 ? "every 3rd turn" : `every ${n}th turn`;
|
|
2004
|
+
}
|