codemax-cli 0.0.0-stage → 0.1.1
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/LICENSE +661 -0
- package/NOTICE +27 -0
- package/README.md +150 -2
- package/TRADEMARKS.md +27 -0
- package/bin/codemax +2 -0
- package/bin/codemax.cjs +77 -0
- package/package.json +63 -4
- package/src/adapters/claude.ts +43 -0
- package/src/adapters/codex.ts +101 -0
- package/src/adapters/detect.ts +16 -0
- package/src/adapters/index.ts +21 -0
- package/src/adapters/simple.ts +89 -0
- package/src/adapters/types.ts +87 -0
- package/src/chat/app.ts +1422 -0
- package/src/chat/markdown.ts +77 -0
- package/src/chat/palette.ts +179 -0
- package/src/chat/permission.ts +111 -0
- package/src/chat/theme.ts +158 -0
- package/src/chat/transcript.ts +324 -0
- package/src/cli.ts +209 -0
- package/src/config.ts +96 -0
- package/src/core/brief.ts +314 -0
- package/src/core/budget.ts +42 -0
- package/src/core/checkpoint.ts +139 -0
- package/src/core/doctor.ts +67 -0
- package/src/core/files.ts +62 -0
- package/src/core/git.ts +36 -0
- package/src/core/hotkey.ts +75 -0
- package/src/core/journal.ts +213 -0
- package/src/core/ledger.ts +153 -0
- package/src/core/menu.ts +134 -0
- package/src/core/orchestrator.ts +352 -0
- package/src/core/paths.ts +30 -0
- package/src/core/prices-live.ts +123 -0
- package/src/core/pricing.ts +162 -0
- package/src/core/pty.ts +99 -0
- package/src/core/quota.ts +55 -0
- package/src/core/spawn.ts +20 -0
- package/src/core/terminal.ts +51 -0
- package/src/core/usage.ts +116 -0
- package/src/engines/acp.ts +419 -0
- package/src/engines/codex.ts +330 -0
- package/src/engines/env.ts +17 -0
- package/src/engines/errors.ts +47 -0
- package/src/engines/index.ts +154 -0
- package/src/engines/types.ts +131 -0
- package/src/transcripts/claude-usage.ts +63 -0
- package/src/transcripts/claude.ts +69 -0
- package/src/transcripts/codex.ts +74 -0
- package/src/tui/app.ts +377 -0
- package/src/tui/menu.ts +126 -0
- package/src/tui/pane.ts +79 -0
package/src/chat/app.ts
ADDED
|
@@ -0,0 +1,1422 @@
|
|
|
1
|
+
import { BoxRenderable, TextRenderable, TextareaRenderable, createCliRenderer, type CliRenderer, type KeyEvent } from "@opentui/core";
|
|
2
|
+
import type { Config } from "../config.ts";
|
|
3
|
+
import { hotkeyLabel, saveConfigKey } from "../config.ts";
|
|
4
|
+
import type { AgentId, QuotaStatus, SessionRef } from "../adapters/types.ts";
|
|
5
|
+
import { listAdapters, getAdapter } from "../adapters/index.ts";
|
|
6
|
+
import { Ledger } from "../core/ledger.ts";
|
|
7
|
+
import { buildBrief, buildCatchup } from "../core/brief.ts";
|
|
8
|
+
import { diffStat } from "../core/git.ts";
|
|
9
|
+
import { isExhausted, quotaLabel } from "../core/quota.ts";
|
|
10
|
+
import { findHotkey, parseHotkeys, type HotkeySpec } from "../core/hotkey.ts";
|
|
11
|
+
import { createEngine, hasEngine, NO_PERMISSION_PROMPTS, engineFactories, grokPermissionNote } from "../engines/index.ts";
|
|
12
|
+
import type { Engine, EngineEvent, PermissionRequest } from "../engines/types.ts";
|
|
13
|
+
import { strippedKeys } from "../engines/env.ts";
|
|
14
|
+
import { blockers } from "../core/doctor.ts";
|
|
15
|
+
import { Transcript, type AgentBlock } from "./transcript.ts";
|
|
16
|
+
import { PermissionDialog } from "./permission.ts";
|
|
17
|
+
import { Palette, type PaletteItem } from "./palette.ts";
|
|
18
|
+
import { C, DONE_VERBS, G, TIPS, VERBS, applyTheme, fmtDuration, fmtTime, modeLabel, pick } from "./theme.ts";
|
|
19
|
+
import { priceRecord, recordCost, renderUsageTable, sumCost } from "../core/usage.ts";
|
|
20
|
+
import { fmtUsd, resetPriceCache } from "../core/pricing.ts";
|
|
21
|
+
import { refreshLivePricesIfStale } from "../core/prices-live.ts";
|
|
22
|
+
import { addSpend, budgetLevel, spendToday, type BudgetKey } from "../core/budget.ts";
|
|
23
|
+
import { createCheckpoint, listCheckpoints, restoreCheckpoint, checkpointDiffStat, type Checkpoint } from "../core/checkpoint.ts";
|
|
24
|
+
import { MENTION_RE, attachFiles, listProjectFiles, mentionedFiles } from "../core/files.ts";
|
|
25
|
+
import pkg from "../../package.json" with { type: "json" };
|
|
26
|
+
import { platformArgv, shellArgv } from "../core/spawn.ts";
|
|
27
|
+
import { cleanError, isLimitNotice, parseResetTime } from "../engines/errors.ts";
|
|
28
|
+
import { TurnRecorder } from "../core/journal.ts";
|
|
29
|
+
|
|
30
|
+
const HOTKEY_NAMES: Record<string, string[]> = { "/": ["/", "_", "?"], "^": ["^", "6"] };
|
|
31
|
+
const VERSION = pkg.version;
|
|
32
|
+
const MODE_CYCLE = ["default", "acceptEdits", "plan"];
|
|
33
|
+
|
|
34
|
+
interface Active {
|
|
35
|
+
agent: AgentId;
|
|
36
|
+
name: string;
|
|
37
|
+
engine: Engine;
|
|
38
|
+
turn: { block: AgentBlock; tools: Set<string>; startedAt: number; tokens: number; chars: number; verb: string; rec?: TurnRecorder } | null;
|
|
39
|
+
replay: AgentBlock | null;
|
|
40
|
+
unsubscribe: () => void;
|
|
41
|
+
mode?: string;
|
|
42
|
+
commands: { name: string; description?: string }[];
|
|
43
|
+
/** Context window fill, when the agent reports it. */
|
|
44
|
+
context?: { used: number; size: number };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface Builtin {
|
|
48
|
+
name: string;
|
|
49
|
+
description: string;
|
|
50
|
+
run: (args: string) => void | Promise<void>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* codemax's chat. One engine at a time; everything on screen is codemax's:
|
|
55
|
+
* transcript, working line, prompt, footer, palette, permission dialog.
|
|
56
|
+
*/
|
|
57
|
+
export class ChatApp {
|
|
58
|
+
private active: Active | null = null;
|
|
59
|
+
private palette: Palette | null = null;
|
|
60
|
+
private permission: PermissionDialog | null = null;
|
|
61
|
+
private permissionQueue: { request: PermissionRequest; engine: Engine }[] = [];
|
|
62
|
+
private detect = new Map<AgentId, { installed: boolean; version?: string; engine: boolean }>();
|
|
63
|
+
private quota = new Map<AgentId, QuotaStatus>();
|
|
64
|
+
private hotkeys: HotkeySpec[];
|
|
65
|
+
private transcript: Transcript;
|
|
66
|
+
private working: TextRenderable;
|
|
67
|
+
private input: TextareaRenderable;
|
|
68
|
+
private inputWrap: BoxRenderable;
|
|
69
|
+
private dialogHost: BoxRenderable;
|
|
70
|
+
private footerText: TextRenderable;
|
|
71
|
+
private shortcuts: BoxRenderable;
|
|
72
|
+
private flash = "";
|
|
73
|
+
private flashTimer: ReturnType<typeof setTimeout> | null = null;
|
|
74
|
+
private spinnerTimer: ReturnType<typeof setInterval> | null = null;
|
|
75
|
+
private spinnerFrame = 0;
|
|
76
|
+
private history: string[] = [];
|
|
77
|
+
/** API-equivalent USD spent since codemax started (all agents). */
|
|
78
|
+
private sessionCost = 0;
|
|
79
|
+
private today = new Map<BudgetKey, number>();
|
|
80
|
+
private budgetNotified = new Map<BudgetKey, "warn" | "over">();
|
|
81
|
+
private checkpoints: (Checkpoint & { agent: AgentId })[] = [];
|
|
82
|
+
private projectFiles: string[] | null = null;
|
|
83
|
+
private side = new Set<Active>();
|
|
84
|
+
private sessionTokens = 0;
|
|
85
|
+
private historyIdx = -1;
|
|
86
|
+
private draft = "";
|
|
87
|
+
private queue: string[] = [];
|
|
88
|
+
private lastCtrlC = 0;
|
|
89
|
+
private switching = false;
|
|
90
|
+
/** Set by quit(); async work that resumes afterwards (a handoff being built) stops. */
|
|
91
|
+
private closed = false;
|
|
92
|
+
private resolveDone!: () => void;
|
|
93
|
+
readonly done: Promise<void>;
|
|
94
|
+
/** Test hooks. */
|
|
95
|
+
engineFactory: (agent: AgentId) => Engine = createEngine;
|
|
96
|
+
checkBlockers: (agent: AgentId) => ReturnType<typeof blockers> = blockers;
|
|
97
|
+
/** Copy-on-select target (tests swap it to keep the real clipboard untouched). */
|
|
98
|
+
copyText: (text: string) => Promise<boolean> = (text) => copyToClipboard(this.renderer, text);
|
|
99
|
+
|
|
100
|
+
constructor(
|
|
101
|
+
readonly renderer: CliRenderer,
|
|
102
|
+
private config: Config,
|
|
103
|
+
private ledger: Ledger,
|
|
104
|
+
private cwd: string,
|
|
105
|
+
) {
|
|
106
|
+
this.done = new Promise((r) => (this.resolveDone = r));
|
|
107
|
+
this.hotkeys = parseHotkeys(config.hotkeys);
|
|
108
|
+
for (const [id, q] of Object.entries(ledger.quota())) this.quota.set(id as AgentId, q);
|
|
109
|
+
|
|
110
|
+
const root = new BoxRenderable(renderer, { id: "chat-root", width: "100%", height: "100%", flexDirection: "column" });
|
|
111
|
+
this.transcript = new Transcript(renderer);
|
|
112
|
+
root.add(this.transcript.box);
|
|
113
|
+
this.working = new TextRenderable(renderer, { id: "working", content: "", fg: C.dim, height: 1, width: "100%", flexShrink: 0, wrapMode: "none", truncate: true });
|
|
114
|
+
this.working.visible = false;
|
|
115
|
+
root.add(this.working);
|
|
116
|
+
this.dialogHost = new BoxRenderable(renderer, { id: "dialog-host", width: "100%", flexShrink: 0, flexDirection: "column" });
|
|
117
|
+
root.add(this.dialogHost);
|
|
118
|
+
this.inputWrap = new BoxRenderable(renderer, { id: "input-wrap", width: "100%", flexShrink: 0, flexDirection: "column", border: ["top", "bottom"], borderStyle: "single", borderColor: C.accent });
|
|
119
|
+
const inputRow = new BoxRenderable(renderer, { id: "input-row", width: "100%", flexDirection: "row" });
|
|
120
|
+
inputRow.add(new TextRenderable(renderer, { id: "input-prompt", content: `${G.prompt} `, width: 2, fg: C.accent }));
|
|
121
|
+
this.input = new TextareaRenderable(renderer, {
|
|
122
|
+
id: "chat-input",
|
|
123
|
+
flexGrow: 1,
|
|
124
|
+
height: 1,
|
|
125
|
+
placeholder: 'Try "fix the failing test" · / for commands',
|
|
126
|
+
placeholderColor: C.dim,
|
|
127
|
+
keyBindings: [
|
|
128
|
+
{ name: "return", action: "submit" },
|
|
129
|
+
{ name: "return", shift: true, action: "newline" },
|
|
130
|
+
{ name: "j", ctrl: true, action: "newline" },
|
|
131
|
+
],
|
|
132
|
+
onSubmit: () => void this.submit(),
|
|
133
|
+
} as any);
|
|
134
|
+
this.input.onContentChange = () => this.onInputChanged();
|
|
135
|
+
inputRow.add(this.input);
|
|
136
|
+
this.inputWrap.add(inputRow);
|
|
137
|
+
root.add(this.inputWrap);
|
|
138
|
+
this.shortcuts = new BoxRenderable(renderer, { id: "shortcuts", width: "100%", flexShrink: 0, flexDirection: "column", paddingX: 2 });
|
|
139
|
+
this.shortcuts.visible = false;
|
|
140
|
+
for (const l of [
|
|
141
|
+
"/ for commands shift + tab to cycle permission mode esc to interrupt a turn",
|
|
142
|
+
"/switch to change agent ↑ for history ctrl + c twice to quit",
|
|
143
|
+
"/mode to set permissions shift + ⏎ for newline ? to toggle this help",
|
|
144
|
+
"! to run a shell command /model to pick a model /compact for a handoff summary",
|
|
145
|
+
"@ to attach a file /undo reverts the last turn's edits /ask <agent> <msg> for a one-off",
|
|
146
|
+
"ctrl + o expands output ctrl + ^ switches agent /theme dark | light | auto",
|
|
147
|
+
])
|
|
148
|
+
this.shortcuts.add(new TextRenderable(renderer, { content: l, fg: C.dim, wrapMode: "none", truncate: true }));
|
|
149
|
+
root.add(this.shortcuts);
|
|
150
|
+
const footer = new BoxRenderable(renderer, { id: "footer", width: "100%", height: 1, flexShrink: 0, paddingX: 2 });
|
|
151
|
+
this.footerText = new TextRenderable(renderer, { id: "footer-text", content: "", width: "100%", wrapMode: "none", truncate: true, fg: C.dim });
|
|
152
|
+
footer.add(this.footerText);
|
|
153
|
+
root.add(footer);
|
|
154
|
+
renderer.root.add(root);
|
|
155
|
+
// Select text with the mouse to copy it (like a terminal's copy-on-select).
|
|
156
|
+
renderer.on("selection", (sel: { getSelectedText(): string } | null) => {
|
|
157
|
+
const text = sel?.getSelectedText?.() ?? "";
|
|
158
|
+
if (!text.trim()) return;
|
|
159
|
+
void this.copyText(text).then((ok) => this.setFlash(ok ? `copied ${text.length} chars` : "could not copy: no clipboard available"));
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
renderer.keyInput.on("keypress", this.onKey);
|
|
163
|
+
renderer.on("resize", () => this.palette?.render());
|
|
164
|
+
this.input.focus();
|
|
165
|
+
this.renderFooter();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async start(agent: AgentId, fresh: boolean, o: { pick?: boolean } = {}) {
|
|
169
|
+
try {
|
|
170
|
+
this.today = spendToday();
|
|
171
|
+
} catch {
|
|
172
|
+
/* no usage yet */
|
|
173
|
+
}
|
|
174
|
+
await this.refreshDetect();
|
|
175
|
+
const ok = (a: AgentId) => this.detect.get(a)?.installed && this.detect.get(a)?.engine;
|
|
176
|
+
const first = ok(agent) ? agent : listAdapters(this.config.order).find((a) => ok(a.id))?.id;
|
|
177
|
+
if (!first) throw new Error("no agent with a protocol engine found (need claude, codex, opencode, grok or gemini on PATH)");
|
|
178
|
+
const notes: string[] = [];
|
|
179
|
+
if (first !== agent) notes.push(`${agent} has no protocol engine here; starting ${first}`);
|
|
180
|
+
const stripped = strippedKeys();
|
|
181
|
+
if (stripped.length) notes.push(`${stripped.join(", ")} unset for agents · subscription logins only`);
|
|
182
|
+
this.transcript.header({ title: `codemax v${VERSION}`, subtitle: "one terminal, every coding agent", path: this.cwd, notes });
|
|
183
|
+
if ((this.config as any).priceUpdates !== false) void refreshLivePricesIfStale().then((l) => l && resetPriceCache());
|
|
184
|
+
if (o.pick && !fresh && this.openPicker(first)) return this.done;
|
|
185
|
+
await this.launch(first, { fresh });
|
|
186
|
+
return this.done;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ---------- keys ----------
|
|
190
|
+
|
|
191
|
+
private onKey = (key: KeyEvent) => {
|
|
192
|
+
if (key.eventType === "release") return;
|
|
193
|
+
if (this.permission) {
|
|
194
|
+
key.stopPropagation();
|
|
195
|
+
this.permission.handleKey(key);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (this.palette) {
|
|
199
|
+
if (this.palette.handleKey(key)) {
|
|
200
|
+
key.stopPropagation();
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
// other keys fall through to the input; the palette re-filters on content change
|
|
204
|
+
}
|
|
205
|
+
if (this.isHotkey(key)) {
|
|
206
|
+
key.stopPropagation();
|
|
207
|
+
this.openSwitch();
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (this.isShiftTab(key)) {
|
|
211
|
+
key.stopPropagation();
|
|
212
|
+
void this.cycleMode();
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (key.name === "escape") {
|
|
216
|
+
if (this.active?.turn) {
|
|
217
|
+
key.stopPropagation();
|
|
218
|
+
void this.active.engine.cancel();
|
|
219
|
+
this.setFlash("interrupting…");
|
|
220
|
+
} else if (this.input.plainText) {
|
|
221
|
+
key.stopPropagation();
|
|
222
|
+
this.input.clear();
|
|
223
|
+
}
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (key.name === "o" && key.ctrl) {
|
|
227
|
+
key.stopPropagation();
|
|
228
|
+
this.setFlash(this.transcript.toggleExpanded() ? "showing full output · ctrl+o to collapse" : "output collapsed");
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (key.name === "c" && key.ctrl) {
|
|
232
|
+
key.stopPropagation();
|
|
233
|
+
const now = Date.now();
|
|
234
|
+
if (this.active?.turn) {
|
|
235
|
+
void this.active.engine.cancel();
|
|
236
|
+
this.setFlash("interrupting… (ctrl+c again to quit)");
|
|
237
|
+
} else if (this.input.plainText) {
|
|
238
|
+
this.draft = this.input.plainText;
|
|
239
|
+
this.input.clear();
|
|
240
|
+
this.setFlash("prompt cleared · ↑ to restore · ctrl+c again to quit");
|
|
241
|
+
} else if (now - this.lastCtrlC < 2000) {
|
|
242
|
+
void this.quit();
|
|
243
|
+
return;
|
|
244
|
+
} else this.setFlash("ctrl+c again to quit");
|
|
245
|
+
this.lastCtrlC = now;
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (key.name === "up" && !this.input.plainText.includes("\n") && (this.history.length || this.draft)) {
|
|
249
|
+
if (this.input.plainText === "" || this.historyIdx >= 0) {
|
|
250
|
+
key.stopPropagation();
|
|
251
|
+
this.recall(-1);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
if (key.name === "down" && this.historyIdx >= 0) {
|
|
256
|
+
key.stopPropagation();
|
|
257
|
+
this.recall(1);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
if (key.name === "?" && this.input.plainText === "" && !this.palette) {
|
|
261
|
+
key.stopPropagation();
|
|
262
|
+
this.shortcuts.visible = !this.shortcuts.visible;
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
private isHotkey(key: KeyEvent): boolean {
|
|
268
|
+
if (key.raw && findHotkey(new TextEncoder().encode(key.raw), this.hotkeys)) return true;
|
|
269
|
+
if (!key.ctrl) return false;
|
|
270
|
+
return this.hotkeys.some((h) => (HOTKEY_NAMES[h.name.slice(5)] ?? [h.name.slice(5)]).includes(key.name));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private isShiftTab(key: KeyEvent): boolean {
|
|
274
|
+
return (key.name === "tab" && key.shift) || key.name === "backtab" || key.raw === "\x1b[Z";
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
private recall(dir: -1 | 1) {
|
|
278
|
+
if (this.historyIdx < 0) {
|
|
279
|
+
this.draft = this.input.plainText;
|
|
280
|
+
if (dir === 1) return;
|
|
281
|
+
if (!this.history.length) {
|
|
282
|
+
if (this.draft === "" && this.lastDraft) this.input.setText(this.lastDraft);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
this.historyIdx = this.history.length - 1;
|
|
286
|
+
} else {
|
|
287
|
+
this.historyIdx += dir;
|
|
288
|
+
}
|
|
289
|
+
if (this.historyIdx >= this.history.length) {
|
|
290
|
+
this.historyIdx = -1;
|
|
291
|
+
this.input.setText(this.draft);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
if (this.historyIdx < 0) this.historyIdx = 0;
|
|
295
|
+
this.input.setText(this.history[this.historyIdx]!);
|
|
296
|
+
}
|
|
297
|
+
private lastDraft = "";
|
|
298
|
+
|
|
299
|
+
private onInputChanged() {
|
|
300
|
+
const text = this.input.plainText;
|
|
301
|
+
if (this.historyIdx >= 0 && text !== this.history[this.historyIdx]) this.historyIdx = -1;
|
|
302
|
+
const mention = text.match(MENTION_RE);
|
|
303
|
+
if (mention && !text.startsWith("/")) {
|
|
304
|
+
void this.openFilePalette(mention[2]!);
|
|
305
|
+
return;
|
|
306
|
+
} else if (this.paletteKind === "files") this.closePalette();
|
|
307
|
+
if (text.startsWith("/") && !text.includes("\n") && !text.includes(" ")) {
|
|
308
|
+
if (!this.palette) this.openCommandPalette(text.slice(1));
|
|
309
|
+
else this.palette.setFilter(text.slice(1));
|
|
310
|
+
} else if (this.palette && this.paletteKind === "commands") {
|
|
311
|
+
this.closePalette();
|
|
312
|
+
}
|
|
313
|
+
if (this.shortcuts.visible && text) this.shortcuts.visible = false;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ---------- submit / commands ----------
|
|
317
|
+
|
|
318
|
+
private async submit() {
|
|
319
|
+
const text = this.input.plainText.trim();
|
|
320
|
+
if (!text) return;
|
|
321
|
+
if (this.palette && this.paletteKind === "commands") {
|
|
322
|
+
// Enter on an exact or highlighted command runs it; Enter on a typo does nothing dangerous.
|
|
323
|
+
const cur = this.palette.current;
|
|
324
|
+
const exact = cur && cur.label.toLowerCase() === text.toLowerCase();
|
|
325
|
+
if (cur && (exact || text.length > 1)) {
|
|
326
|
+
this.closePalette();
|
|
327
|
+
this.input.clear();
|
|
328
|
+
return this.runCommandValue(cur.value, "");
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
this.closePalette();
|
|
332
|
+
this.input.clear();
|
|
333
|
+
this.pushHistory(text);
|
|
334
|
+
const busy = !!this.active?.turn;
|
|
335
|
+
const immediate = /^\/(quit|exit|q|help|status|usage|cost|diff|clear|switch|agents)\b/.test(text);
|
|
336
|
+
if (busy && !immediate && (text.startsWith("/") || text.startsWith("!"))) {
|
|
337
|
+
this.queue.push(text);
|
|
338
|
+
this.transcript.note(`queued until the turn ends: ${text.slice(0, 60)}`, C.dim);
|
|
339
|
+
this.renderFooter();
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
if (text.startsWith("/")) return this.command(text);
|
|
343
|
+
if (text.startsWith("!")) return this.shell(text.slice(1).trim());
|
|
344
|
+
if (!this.active) {
|
|
345
|
+
this.transcript.note("no agent running — /switch to start one", C.warn);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
const route = this.routeFor(text);
|
|
349
|
+
if (route && route !== this.active.agent && !this.active.turn) return this.askAgent(route, text, `routed to ${route} by rule`);
|
|
350
|
+
if (this.active.turn) {
|
|
351
|
+
this.queue.push(text);
|
|
352
|
+
this.transcript.note(`queued: ${text.slice(0, 80)}${text.length > 80 ? "…" : ""}`, C.dim);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
await this.send(this.active, text);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Append @file contents and pending shell output to an outgoing message. */
|
|
359
|
+
private withAttachments(text: string): string {
|
|
360
|
+
const files = mentionedFiles(text, this.cwd);
|
|
361
|
+
if (files.length) {
|
|
362
|
+
text += attachFiles(files, this.cwd);
|
|
363
|
+
this.transcript.note(`attached ${files.join(", ")}`, C.dimmer);
|
|
364
|
+
}
|
|
365
|
+
if (this.pendingShell.length) {
|
|
366
|
+
text = `${text}\n\n[shell output the user ran in this repo]\n${this.pendingShell.join("\n\n")}`;
|
|
367
|
+
this.pendingShell = [];
|
|
368
|
+
}
|
|
369
|
+
return text;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
private async checkpoint(active: Active, text: string): Promise<Checkpoint | null> {
|
|
373
|
+
if (!this.config.checkpoints) return null;
|
|
374
|
+
const cp = await createCheckpoint(this.cwd, `before ${active.agent}: ${text.slice(0, 60).replace(/\s+/g, " ")}`).catch(() => null);
|
|
375
|
+
if (cp) this.checkpoints.push({ ...cp, agent: active.agent });
|
|
376
|
+
return cp;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
private async undo() {
|
|
380
|
+
if (this.active?.turn) return this.transcript.note("the agent is working; Esc first, then /undo", C.warn);
|
|
381
|
+
const cp = this.checkpoints.pop() ?? (await listCheckpoints(this.cwd, 1))[0];
|
|
382
|
+
if (!cp) return this.transcript.note("no checkpoint to restore (checkpoints need a git repo)", C.warn);
|
|
383
|
+
const stat = await checkpointDiffStat(this.cwd, cp.sha);
|
|
384
|
+
if (!stat.trim()) {
|
|
385
|
+
this.transcript.note(`nothing changed since the checkpoint "${cp.label}"`);
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
const r = await restoreCheckpoint(this.cwd, cp.sha);
|
|
389
|
+
this.transcript.note(`reverted to the snapshot ${cp.label}`, C.warn);
|
|
390
|
+
for (const l of stat.split("\n")) this.transcript.note(l);
|
|
391
|
+
if (r.removed.length) this.transcript.note(`removed ${r.removed.length} new file(s): ${r.removed.slice(0, 5).join(", ")}${r.removed.length > 5 ? "…" : ""}`);
|
|
392
|
+
this.pendingShell.push("[codemax] The user reverted the file changes from the last turn with /undo. The working tree is back to the state before that turn.");
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
private async showCheckpoints() {
|
|
396
|
+
const list = await listCheckpoints(this.cwd, 10);
|
|
397
|
+
if (!list.length) return this.transcript.note("no checkpoints yet");
|
|
398
|
+
for (const c of list) this.transcript.note(`${fmtTime(c.ts)} ${c.sha.slice(0, 8)} ${c.label}`);
|
|
399
|
+
this.transcript.note("/undo restores the most recent one");
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
private routeFor(text: string): AgentId | null {
|
|
403
|
+
for (const r of this.config.routes ?? []) {
|
|
404
|
+
try {
|
|
405
|
+
if (new RegExp(r.match, "i").test(text) && this.detect.get(r.agent)?.engine) return r.agent;
|
|
406
|
+
} catch {
|
|
407
|
+
/* bad regex in config */
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
return null;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
private async ask(args: string) {
|
|
414
|
+
const [id, ...rest] = args.trim().split(/\s+/);
|
|
415
|
+
const msg = rest.join(" ");
|
|
416
|
+
if (!id || !msg) return this.transcript.note("usage: /ask <agent> <message> e.g. /ask codex review the last commit", C.warn);
|
|
417
|
+
if (!this.detect.get(id as AgentId)?.engine) return this.transcript.note(`no protocol engine for "${id}"`, C.warn);
|
|
418
|
+
await this.askAgent(id as AgentId, msg, `asked ${id}`);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** One message to another agent in a throwaway session; the current agent stays active. */
|
|
422
|
+
private async askAgent(agent: AgentId, text: string, why: string) {
|
|
423
|
+
const blocked = this.checkBlockers(agent);
|
|
424
|
+
if (blocked.length) return this.transcript.error(`${agent} not started: ${blocked[0]!.message}`);
|
|
425
|
+
const adapter = getAdapter(agent);
|
|
426
|
+
const engine = this.engineFactory(agent);
|
|
427
|
+
const side: Active = { agent, name: adapter.name, engine, turn: null, replay: null, unsubscribe: () => {}, commands: [] };
|
|
428
|
+
this.side.add(side);
|
|
429
|
+
side.unsubscribe = engine.onEvent((e) => this.onEngineEvent(side, e));
|
|
430
|
+
this.transcript.divider(`${why} · ${adapter.name}`);
|
|
431
|
+
this.transcript.user(text);
|
|
432
|
+
try {
|
|
433
|
+
await engine.start({ cwd: this.cwd });
|
|
434
|
+
const brief = this.ledger.turns().length ? await buildBrief({ ledger: this.ledger, cwd: this.cwd, to: agent, from: this.active?.agent, config: this.config, purpose: "context" }) : "";
|
|
435
|
+
const body = this.withAttachments(text);
|
|
436
|
+
await this.checkpoint(side, text);
|
|
437
|
+
this.ledger.addTurns([{ ts: Date.now(), agent, sessionId: engine.session?.id ?? "?", role: "user", text }]);
|
|
438
|
+
// One-off answers are not task work: they stay out of the journal (a "done" turn here
|
|
439
|
+
// would split the current task in two).
|
|
440
|
+
await this.runTurn(side, brief ? `${brief}\n\n## This message (answer it; the user stays with ${this.active?.name ?? "another agent"})\n${body}` : body, { journal: false });
|
|
441
|
+
} catch (e: any) {
|
|
442
|
+
this.transcript.error(`${adapter.name}: ${e?.message ?? e}`);
|
|
443
|
+
} finally {
|
|
444
|
+
side.unsubscribe();
|
|
445
|
+
this.side.delete(side);
|
|
446
|
+
await engine.close().catch(() => {});
|
|
447
|
+
if (this.active) this.transcript.divider(`back to ${this.active.name}`);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
private showBudget() {
|
|
452
|
+
const b = this.config.budgets ?? {};
|
|
453
|
+
const keys = Object.keys(b) as BudgetKey[];
|
|
454
|
+
if (!keys.length) return this.transcript.note('no budgets set · add "budgets": {"claude": 20, "all": 50} (USD per day) to ~/.codemax/config.json');
|
|
455
|
+
for (const k of keys) {
|
|
456
|
+
const spent = this.today.get(k) ?? 0;
|
|
457
|
+
this.transcript.note(`${k.padEnd(8)} ${fmtUsd(spent)} of ${fmtUsd(b[k])} today (${Math.round((spent / b[k]!) * 100)}%)`);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
private checkBudget(agent: AgentId) {
|
|
462
|
+
const b = this.config.budgets ?? {};
|
|
463
|
+
for (const k of [agent, "all"] as BudgetKey[]) {
|
|
464
|
+
const level = budgetLevel(this.today.get(k) ?? 0, b[k]);
|
|
465
|
+
if (level === "ok" || this.budgetNotified.get(k) === level || (level === "warn" && this.budgetNotified.get(k) === "over")) continue;
|
|
466
|
+
this.budgetNotified.set(k, level);
|
|
467
|
+
const spent = fmtUsd(this.today.get(k) ?? 0);
|
|
468
|
+
const label = k === "all" ? "all agents" : getAdapter(k).name;
|
|
469
|
+
if (level === "warn") this.transcript.note(`${label} has used ${spent} of its ${fmtUsd(b[k])} daily budget (80%+)`, C.warn);
|
|
470
|
+
else this.offerHandoff(agent, `${label} reached its ${fmtUsd(b[k])} daily budget (${spent})`, k === "all");
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** Best agent to continue on: config order, installed with an engine, not exhausted or over budget. */
|
|
475
|
+
private nextAgent(from: AgentId): AgentId | null {
|
|
476
|
+
const b = this.config.budgets ?? {};
|
|
477
|
+
for (const a of listAdapters(this.config.order)) {
|
|
478
|
+
if (a.id === from) continue;
|
|
479
|
+
const d = this.detect.get(a.id);
|
|
480
|
+
if (!d?.installed || !d.engine) continue;
|
|
481
|
+
if (isExhausted(this.quota.get(a.id))) continue;
|
|
482
|
+
if (budgetLevel(this.today.get(a.id) ?? 0, b[a.id]) === "over") continue;
|
|
483
|
+
return a.id;
|
|
484
|
+
}
|
|
485
|
+
return null;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** One-key handoff offer: Enter continues the task in the next agent. */
|
|
489
|
+
private pendingOffer: { from: AgentId; reason: string; allAgents: boolean } | null = null;
|
|
490
|
+
|
|
491
|
+
private offerHandoff(from: AgentId, reason: string, allAgents = false) {
|
|
492
|
+
// Never interrupt a running turn: show the offer when it ends.
|
|
493
|
+
if (this.active?.turn || this.permission) {
|
|
494
|
+
if (!this.pendingOffer) this.transcript.note(`${reason} · you'll be offered a handoff when this turn ends`, C.warn);
|
|
495
|
+
this.pendingOffer = { from, reason, allAgents };
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
if (this.paletteKind === "handoff") return;
|
|
499
|
+
const next = allAgents ? null : this.nextAgent(from);
|
|
500
|
+
this.transcript.note(reason, C.warn);
|
|
501
|
+
if (!next) {
|
|
502
|
+
this.transcript.note(allAgents ? "every agent shares that budget · raise it in ~/.codemax/config.json to continue" : "no other agent is available · /switch to pick one anyway", C.warn);
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
this.closePalette();
|
|
506
|
+
const items: PaletteItem[] = [
|
|
507
|
+
{ label: `Continue in ${getAdapter(next).name}`, description: "hand over the conversation and the unfinished request", value: next },
|
|
508
|
+
...listAdapters(this.config.order)
|
|
509
|
+
.filter((a) => a.id !== next && a.id !== from && this.detect.get(a.id)?.engine)
|
|
510
|
+
.map((a) => ({ label: `Continue in ${a.name}`, description: isExhausted(this.quota.get(a.id)) ? "out of quota" : "", value: a.id, disabled: isExhausted(this.quota.get(a.id)) })),
|
|
511
|
+
{ label: `Stay on ${getAdapter(from).name}`, description: "keep going here anyway", value: "__stay" },
|
|
512
|
+
];
|
|
513
|
+
this.paletteKind = "handoff";
|
|
514
|
+
this.palette = new Palette(this.renderer, {
|
|
515
|
+
title: `${reason} · Enter to continue in ${getAdapter(next).name}`,
|
|
516
|
+
items,
|
|
517
|
+
onPick: (it) => {
|
|
518
|
+
this.closePalette();
|
|
519
|
+
if (it.value !== "__stay") void this.switchTo(it.value as AgentId, false, { resumeTask: true });
|
|
520
|
+
},
|
|
521
|
+
onClose: () => this.closePalette(),
|
|
522
|
+
}, this.paletteAnchor);
|
|
523
|
+
// keep the offered order (Continue first), not alphabetical
|
|
524
|
+
(this.palette as any).opts.items.forEach((it: PaletteItem, i: number) => (it.weight = 100 - i));
|
|
525
|
+
this.palette.setFilter("");
|
|
526
|
+
this.renderer.root.add(this.palette.box);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
private async openFilePalette(filter: string) {
|
|
530
|
+
if (!this.projectFiles) this.projectFiles = await listProjectFiles(this.cwd).catch(() => []);
|
|
531
|
+
if (this.paletteKind === "files" && this.palette) return this.palette.setFilter(filter);
|
|
532
|
+
this.closePalette(false);
|
|
533
|
+
this.paletteKind = "files";
|
|
534
|
+
this.palette = new Palette(this.renderer, {
|
|
535
|
+
title: "Attach a file · Tab or Enter to insert · Esc to cancel",
|
|
536
|
+
items: this.projectFiles.map((f) => ({ label: f, value: f })),
|
|
537
|
+
filter,
|
|
538
|
+
tabAccepts: true,
|
|
539
|
+
onPick: (it) => {
|
|
540
|
+
const text = this.input.plainText.replace(MENTION_RE, (_m, pre) => `${pre}@${it.value} `);
|
|
541
|
+
this.closePalette();
|
|
542
|
+
this.input.setText(text);
|
|
543
|
+
try {
|
|
544
|
+
(this.input as any).gotoBufferEnd?.();
|
|
545
|
+
} catch {
|
|
546
|
+
/* cursor helper missing */
|
|
547
|
+
}
|
|
548
|
+
},
|
|
549
|
+
onClose: () => this.closePalette(),
|
|
550
|
+
}, this.paletteAnchor);
|
|
551
|
+
this.renderer.root.add(this.palette.box);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** Startup picker: resume a recent session or start fresh. Returns false when there is nothing to pick. */
|
|
555
|
+
private openPicker(defaultAgent: AgentId): boolean {
|
|
556
|
+
const sessions = Object.values(this.ledger.sessions()).filter((s): s is SessionRef => !!s && !!this.detect.get(s.agent)?.engine);
|
|
557
|
+
if (!sessions.length) return false;
|
|
558
|
+
sessions.sort((a, b) => b.lastUsedAt - a.lastUsedAt);
|
|
559
|
+
const turns = this.ledger.turns();
|
|
560
|
+
const items: PaletteItem[] = sessions.map((s, i) => {
|
|
561
|
+
const last = [...turns].reverse().find((t) => t.agent === s.agent && t.role === "user");
|
|
562
|
+
return {
|
|
563
|
+
label: `Resume ${getAdapter(s.agent).name}`,
|
|
564
|
+
description: `${ago(s.lastUsedAt)}${last ? ` · "${last.text.replace(/\s+/g, " ").slice(0, 60)}"` : ""}`,
|
|
565
|
+
value: `resume:${s.agent}`,
|
|
566
|
+
weight: 100 - i,
|
|
567
|
+
};
|
|
568
|
+
});
|
|
569
|
+
items.push({ label: `New session with ${getAdapter(defaultAgent).name}`, description: "start fresh (history stays in the ledger)", value: `new:${defaultAgent}`, weight: 0 });
|
|
570
|
+
this.paletteKind = "picker";
|
|
571
|
+
this.palette = new Palette(this.renderer, {
|
|
572
|
+
title: "Resume a session · Enter to continue · Esc for the most recent",
|
|
573
|
+
items,
|
|
574
|
+
onPick: (it) => {
|
|
575
|
+
this.closePalette();
|
|
576
|
+
const [kind, agent] = it.value.split(":") as ["resume" | "new", AgentId];
|
|
577
|
+
void this.launch(agent, { fresh: kind === "new" });
|
|
578
|
+
},
|
|
579
|
+
onClose: () => {
|
|
580
|
+
this.closePalette();
|
|
581
|
+
void this.launch(sessions[0]!.agent, { fresh: false });
|
|
582
|
+
},
|
|
583
|
+
}, this.paletteAnchor);
|
|
584
|
+
this.renderer.root.add(this.palette.box);
|
|
585
|
+
return true;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/** Run one queued entry the same way as if it had just been typed. */
|
|
589
|
+
private async dispatch(active: Active, text: string) {
|
|
590
|
+
if (text.startsWith("!")) return this.shell(text.slice(1).trim());
|
|
591
|
+
if (text.startsWith("/")) return this.command(text);
|
|
592
|
+
return this.send(active, text);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
private async drainQueue(active: Active) {
|
|
596
|
+
while (this.queue.length && this.active === active && !active.turn) await this.dispatch(active, this.queue.shift()!);
|
|
597
|
+
this.renderFooter();
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
private async send(active: Active, text: string) {
|
|
601
|
+
this.transcript.user(text);
|
|
602
|
+
text = this.withAttachments(text);
|
|
603
|
+
const cp = await this.checkpoint(active, text);
|
|
604
|
+
this.ledger.addTurns([{ ts: Date.now(), agent: active.agent, sessionId: active.engine.session?.id ?? "?", role: "user", text }]);
|
|
605
|
+
const rec = new TurnRecorder(this.ledger.journal, active.agent);
|
|
606
|
+
rec.user(text, cp?.sha);
|
|
607
|
+
await this.runTurn(active, text, { rec });
|
|
608
|
+
await this.drainQueue(active);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
private pushHistory(text: string) {
|
|
612
|
+
if (this.history.at(-1) !== text) this.history.push(text);
|
|
613
|
+
if (this.history.length > 200) this.history.shift();
|
|
614
|
+
this.historyIdx = -1;
|
|
615
|
+
this.draft = "";
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
private builtins(): Builtin[] {
|
|
619
|
+
return [
|
|
620
|
+
{ name: "/switch", description: "Switch to another agent (keeps the conversation)", run: () => this.openSwitch() },
|
|
621
|
+
{ name: "/mode", description: "Set how often the agent asks permission", run: (a) => this.openModes(a) },
|
|
622
|
+
{ name: "/model", description: "Pick the model for the current agent", run: (a) => this.openModels(a) },
|
|
623
|
+
{ name: "/effort", description: "Reasoning effort for the current agent, when it offers one", run: (a) => this.openEffort(a) },
|
|
624
|
+
{ name: "/task", description: "The shared task: request, plan, who did what, where it stopped, files changed", run: () => void this.showTask() },
|
|
625
|
+
{ name: "/compact", description: "Have the agent write a project summary used in every handoff", run: () => void this.compact() },
|
|
626
|
+
{ name: "/diff", description: "Uncommitted changes in this repo", run: () => void this.showDiff() },
|
|
627
|
+
{ name: "/agents", description: "Show agents, sessions and quota", run: () => this.openSwitch() },
|
|
628
|
+
{ name: "/usage", description: "Tokens and API-equivalent cost per agent (like /cost)", run: () => this.showUsage() },
|
|
629
|
+
{ name: "/cost", description: "Alias of /usage", run: () => this.showUsage() },
|
|
630
|
+
{ name: "/status", description: "Session, ledger and quota details", run: () => this.showStatus() },
|
|
631
|
+
{ name: "/clear", description: "Clear the screen (history stays in the ledger)", run: () => this.clearScreen() },
|
|
632
|
+
{ name: "/help", description: "Keyboard shortcuts and commands", run: () => void (this.shortcuts.visible = true) },
|
|
633
|
+
{ name: "/new", description: "Start a fresh session with the current agent", run: () => void (this.active && this.switchTo(this.active.agent, true)) },
|
|
634
|
+
{ name: "/undo", description: "Revert the files changed by the last agent turn", run: () => void this.undo() },
|
|
635
|
+
{ name: "/checkpoints", description: "List working-tree snapshots taken before each turn", run: () => void this.showCheckpoints() },
|
|
636
|
+
{ name: "/ask", description: "Send one message to another agent without switching: /ask codex <msg>", run: (a) => void this.ask(a) },
|
|
637
|
+
{ name: "/budget", description: "Today's spend against your daily budgets", run: () => this.showBudget() },
|
|
638
|
+
{ name: "/theme", description: "Colour theme: /theme dark | light | auto (saved; restart to apply everywhere)", run: (a) => this.setThemePref(a) },
|
|
639
|
+
{ name: "/quit", description: "Quit codemax", run: () => void this.quit() },
|
|
640
|
+
];
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
private setThemePref(arg: string) {
|
|
644
|
+
const v = arg.trim().toLowerCase();
|
|
645
|
+
if (v !== "dark" && v !== "light" && v !== "auto") {
|
|
646
|
+
this.transcript.note(`theme is ${this.config.theme ?? "auto"} · use /theme dark, /theme light or /theme auto`);
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
this.config.theme = v;
|
|
650
|
+
try {
|
|
651
|
+
saveConfigKey("theme", v);
|
|
652
|
+
} catch {
|
|
653
|
+
/* read-only home: still applies for this run */
|
|
654
|
+
}
|
|
655
|
+
if (v !== "auto") applyTheme(v);
|
|
656
|
+
this.transcript.note(`theme set to ${v}; new lines use it now, everything after a restart`, C.accent);
|
|
657
|
+
this.renderFooter();
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
private async command(line: string) {
|
|
661
|
+
const [raw, ...rest] = line.split(/\s+/);
|
|
662
|
+
const name = raw!.toLowerCase();
|
|
663
|
+
const b = this.builtins().find((x) => x.name === name || x.name === name.replace(/^\/(exit|q)$/, "/quit"));
|
|
664
|
+
if (b) return b.run(rest.join(" "));
|
|
665
|
+
if (name === "/exit" || name === "/q") return this.quit();
|
|
666
|
+
if (this.active?.commands.some((c) => `/${c.name}` === name)) {
|
|
667
|
+
if (this.active.turn) {
|
|
668
|
+
this.queue.push(line);
|
|
669
|
+
this.transcript.note(`queued ${name} until the turn ends`, C.dim);
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
return this.send(this.active, line);
|
|
673
|
+
}
|
|
674
|
+
this.transcript.note(`Unknown command: ${name}`, C.warn);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
private runCommandValue(value: string, args: string) {
|
|
678
|
+
if (value.startsWith("builtin:")) return this.builtins().find((b) => b.name === value.slice(8))?.run(args);
|
|
679
|
+
if (value.startsWith("agentcmd:")) return this.command(`/${value.slice(9)}${args ? " " + args : ""}`);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
private pendingShell: string[] = [];
|
|
683
|
+
|
|
684
|
+
/** `!cmd`: run locally, show output, and attach it to the next prompt as context. */
|
|
685
|
+
private async shell(cmd: string) {
|
|
686
|
+
if (!cmd) return;
|
|
687
|
+
this.transcript.user(`! ${cmd}`);
|
|
688
|
+
const p = Bun.spawn(shellArgv(cmd), { cwd: this.cwd, stdout: "pipe", stderr: "pipe" });
|
|
689
|
+
const [out, err] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text()]);
|
|
690
|
+
const code = await p.exited;
|
|
691
|
+
const body = (out + (err ? `\n${err}` : "")).trimEnd() || "(no output)";
|
|
692
|
+
const block = this.transcript.agent();
|
|
693
|
+
block.tool(`sh-${Date.now()}`, `$ ${cmd}${code ? ` (exit ${code})` : ""}`, code ? "failed" : "completed", body);
|
|
694
|
+
this.pendingShell.push(`$ ${cmd}\n${body.slice(0, 4000)}`);
|
|
695
|
+
this.transcript.note("output will be sent with your next message", C.dimmer);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
private async showDiff() {
|
|
699
|
+
const { diffStat } = await import("../core/git.ts");
|
|
700
|
+
for (const l of (await diffStat(this.cwd)).split("\n")) this.transcript.note(l);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
private openModels(arg: string) {
|
|
704
|
+
const a = this.active;
|
|
705
|
+
if (!a) return;
|
|
706
|
+
const models = a.engine.info?.models ?? [];
|
|
707
|
+
if (!a.engine.setModel || !models.length) return this.transcript.note(`${a.name} does not expose model choice over its protocol`, C.dim);
|
|
708
|
+
if (models.length === 1 && !arg) {
|
|
709
|
+
const effort = this.effortOption(a);
|
|
710
|
+
return this.transcript.note(`${a.name} offers one model on your plan: ${models[0]!.name}${effort ? ` · /effort changes its reasoning effort (now ${effort.current})` : ""}`);
|
|
711
|
+
}
|
|
712
|
+
const apply = async (id: string) => {
|
|
713
|
+
try {
|
|
714
|
+
await a.engine.setModel!(id);
|
|
715
|
+
this.transcript.note(`model: ${id}`);
|
|
716
|
+
if (a.agent === "cursor") this.transcript.note("Cursor saves this as its default model, so Cursor uses it outside codemax too", C.warn);
|
|
717
|
+
this.renderFooter();
|
|
718
|
+
} catch (e: any) {
|
|
719
|
+
this.transcript.error(`could not switch model: ${e?.message ?? e}`);
|
|
720
|
+
}
|
|
721
|
+
};
|
|
722
|
+
if (arg) return void apply(arg);
|
|
723
|
+
this.closePalette();
|
|
724
|
+
this.paletteKind = "modes";
|
|
725
|
+
this.palette = new Palette(this.renderer, {
|
|
726
|
+
title: `Model for ${a.name}`,
|
|
727
|
+
items: models.map((m) => ({ label: m.id, description: `${m.name !== m.id ? m.name : ""}${m.id === a.engine.info?.currentModel ? " (current)" : ""}`, value: m.id })),
|
|
728
|
+
onPick: (it) => {
|
|
729
|
+
this.closePalette();
|
|
730
|
+
void apply(it.value);
|
|
731
|
+
},
|
|
732
|
+
onClose: () => this.closePalette(),
|
|
733
|
+
}, this.paletteAnchor);
|
|
734
|
+
this.renderer.root.add(this.palette.box);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
private effortOption(a: Active) {
|
|
738
|
+
return a.engine.info?.options?.find((o) => o.category === "thought_level" || /effort|reasoning/i.test(o.id));
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/** /effort: reasoning effort, for agents that expose it (Grok, Cursor variants). */
|
|
742
|
+
private openEffort(arg: string) {
|
|
743
|
+
const a = this.active;
|
|
744
|
+
if (!a) return;
|
|
745
|
+
const o = this.effortOption(a);
|
|
746
|
+
if (!o || !a.engine.setOption) return this.transcript.note(`${a.name} does not expose reasoning effort`, C.dim);
|
|
747
|
+
const apply = async (value: string) => {
|
|
748
|
+
try {
|
|
749
|
+
await a.engine.setOption!(o.id, value);
|
|
750
|
+
this.transcript.note(`${o.name.toLowerCase()}: ${value}`);
|
|
751
|
+
this.renderFooter();
|
|
752
|
+
} catch (e: any) {
|
|
753
|
+
this.transcript.error(`could not change ${o.name.toLowerCase()}: ${e?.message ?? e}`);
|
|
754
|
+
}
|
|
755
|
+
};
|
|
756
|
+
const want = arg.trim().toLowerCase();
|
|
757
|
+
if (want) {
|
|
758
|
+
const v = o.values.find((x) => x.value.toLowerCase() === want || x.name.toLowerCase() === want);
|
|
759
|
+
if (v) return void apply(v.value);
|
|
760
|
+
return this.transcript.note(`unknown ${o.name.toLowerCase()} "${arg}" (${o.values.map((x) => x.value).join(", ")})`, C.warn);
|
|
761
|
+
}
|
|
762
|
+
this.closePalette();
|
|
763
|
+
this.paletteKind = "modes";
|
|
764
|
+
this.palette = new Palette(this.renderer, {
|
|
765
|
+
title: `${o.name} for ${a.name}`,
|
|
766
|
+
items: o.values.map((v) => ({ label: v.value, description: `${v.name !== v.value ? v.name : ""}${v.value === o.current ? " (current)" : ""}${v.description ? ` · ${v.description}` : ""}`, value: v.value })),
|
|
767
|
+
onPick: (it) => {
|
|
768
|
+
this.closePalette();
|
|
769
|
+
void apply(it.value);
|
|
770
|
+
},
|
|
771
|
+
onClose: () => this.closePalette(),
|
|
772
|
+
}, this.paletteAnchor);
|
|
773
|
+
this.renderer.root.add(this.palette.box);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/** Ask the active agent for a compact project summary and store it for every future handoff. */
|
|
777
|
+
async compact(o: { quiet?: boolean } = {}): Promise<boolean> {
|
|
778
|
+
const a = this.active;
|
|
779
|
+
if (!a) return false;
|
|
780
|
+
if (a.turn) {
|
|
781
|
+
if (!o.quiet) this.transcript.note("the agent is busy; run /compact when the turn ends", C.warn);
|
|
782
|
+
return false;
|
|
783
|
+
}
|
|
784
|
+
const prompt =
|
|
785
|
+
"[codemax] Write a compact project summary for another coding agent that will take over this repository. " +
|
|
786
|
+
"Max 250 words, plain markdown: goal, what is done (files touched), what is in progress, open problems, next step. " +
|
|
787
|
+
"Do not use any tools. Reply with the summary only.";
|
|
788
|
+
this.transcript.note(o.quiet ? `asking ${a.name} for a handoff summary before switching · esc to skip` : `asking ${a.name} for a handoff summary…`);
|
|
789
|
+
const block = this.transcript.agent();
|
|
790
|
+
a.turn = { block, tools: new Set(), startedAt: Date.now(), tokens: 0, chars: 0, verb: "Summarizing" };
|
|
791
|
+
this.startSpinner(a);
|
|
792
|
+
const stop = await a.engine.prompt(prompt);
|
|
793
|
+
a.turn = null;
|
|
794
|
+
this.stopSpinner();
|
|
795
|
+
const text = block.text().trim();
|
|
796
|
+
if (stop === "end_turn" && text.length > 40) {
|
|
797
|
+
this.ledger.writeSummary(`_${a.name}, ${new Date().toISOString().slice(0, 16).replace("T", " ")}_\n\n${text}`);
|
|
798
|
+
block.footer("summary saved · used in every handoff");
|
|
799
|
+
return true;
|
|
800
|
+
}
|
|
801
|
+
block.footer("no summary saved");
|
|
802
|
+
return false;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
private async showTask() {
|
|
806
|
+
const j = this.ledger.journal;
|
|
807
|
+
const steps = j.steps();
|
|
808
|
+
const task = j.task(steps);
|
|
809
|
+
if (!task.request) return this.transcript.note("no task yet · send a request and every agent's steps are recorded here");
|
|
810
|
+
const t = (ts: number) => fmtTime(ts);
|
|
811
|
+
const lastEnd = [...task.steps].reverse().find((s) => s.kind === "end") as Extract<typeof steps[number], { kind: "end" }> | undefined;
|
|
812
|
+
const plan = [...task.steps].reverse().find((s) => s.kind === "plan") as Extract<typeof steps[number], { kind: "plan" }> | undefined;
|
|
813
|
+
const tools = task.steps.filter((s) => s.kind === "tool");
|
|
814
|
+
const byAgent = new Map<string, number>();
|
|
815
|
+
for (const s of tools) byAgent.set(s.agent, (byAgent.get(s.agent) ?? 0) + 1);
|
|
816
|
+
const status = !lastEnd ? "just started"
|
|
817
|
+
: lastEnd.outcome === "done" ? (plan?.entries.some((e) => e.status !== "completed") ? `in progress · ${lastEnd.agent} finished a turn at ${t(lastEnd.ts)}` : `done · ${lastEnd.agent} at ${t(lastEnd.ts)}`)
|
|
818
|
+
: lastEnd.outcome === "limit" ? `stopped · ${lastEnd.agent} hit its limit at ${t(lastEnd.ts)} · /switch to continue elsewhere`
|
|
819
|
+
: lastEnd.outcome === "cancelled" ? `paused · interrupted at ${t(lastEnd.ts)}`
|
|
820
|
+
: `stopped · ${lastEnd.agent} hit an error at ${t(lastEnd.ts)}`;
|
|
821
|
+
this.transcript.note(`task: "${task.request.text.replace(/\s+/g, " ").slice(0, 120)}" · started ${t(task.request.ts)}`, C.accent);
|
|
822
|
+
this.transcript.note(`status: ${status}`);
|
|
823
|
+
if (task.followUps.length) this.transcript.note(`follow-ups: ${task.followUps.length}`);
|
|
824
|
+
this.transcript.note(`steps: ${[...byAgent].map(([a, n]) => `${a} ${n}`).join(" · ") || "none yet"}`);
|
|
825
|
+
if (plan) for (const e of plan.entries) this.transcript.note(`${e.status === "completed" ? G.done_ : e.status === "in_progress" ? G.doing : G.todo} ${e.content}`, e.status === "in_progress" ? C.accent : C.dim);
|
|
826
|
+
const inFlight = (lastEnd?.inFlight ?? []).map((id) => tools.find((s) => s.id === id)).filter(Boolean) as { title: string }[];
|
|
827
|
+
for (const f of inFlight) this.transcript.note(`in flight when it stopped: ${f.title}`, C.warn);
|
|
828
|
+
const stat = task.request.checkpoint ? await checkpointDiffStat(this.cwd, task.request.checkpoint).catch(() => "") : "";
|
|
829
|
+
if (!task.request.checkpoint) this.transcript.note("file changes unknown: snapshots are off (checkpoints: false) or this is not a git repo");
|
|
830
|
+
else this.transcript.note(stat.trim() ? `changed since the task started:\n${stat.trim()}` : "no file changes since the task started");
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
private showUsage() {
|
|
834
|
+
const all = this.ledger.usage();
|
|
835
|
+
for (const l of renderUsageTable(all, { title: "Usage for this project" })) this.transcript.note(l);
|
|
836
|
+
this.transcript.note(`this codemax session: ${fmtUsd(this.sessionCost)} · all time here: ${fmtUsd(sumCost(all))} · prices in ~/.codemax/prices.json`);
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
private showStatus() {
|
|
840
|
+
const a = this.active;
|
|
841
|
+
const lines = [
|
|
842
|
+
`agent: ${a ? `${a.name}${a.engine.info?.currentModel ? ` · ${a.engine.info.currentModel}` : ""}` : "none"}`,
|
|
843
|
+
`session: ${a?.engine.session?.id ?? "-"} mode: ${a?.mode ?? "-"}`,
|
|
844
|
+
`ledger: ${this.ledger.dir} (${this.ledger.turns().length} turns)`,
|
|
845
|
+
];
|
|
846
|
+
const q = [...this.quota.values()].map((x) => `${x.agent} ${quotaLabel(x)}`.trim()).filter((s) => !/\s$/.test(s) && s.includes(" "));
|
|
847
|
+
if (q.length) lines.push(`quota: ${q.join(", ")}`);
|
|
848
|
+
for (const l of lines) this.transcript.note(l);
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
private clearScreen() {
|
|
852
|
+
const old = this.transcript;
|
|
853
|
+
const parent = old.box.parent;
|
|
854
|
+
const fresh = new Transcript(this.renderer);
|
|
855
|
+
parent?.add(fresh.box, 0);
|
|
856
|
+
old.box.destroyRecursively();
|
|
857
|
+
this.transcript = fresh;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// ---------- palette ----------
|
|
861
|
+
|
|
862
|
+
private paletteKind: "commands" | "switch" | "modes" | "files" | "handoff" | "picker" | null = null;
|
|
863
|
+
|
|
864
|
+
private paletteAnchor = () => ({ top: this.inputWrap.y, width: this.renderer.width });
|
|
865
|
+
|
|
866
|
+
private openCommandPalette(filter: string) {
|
|
867
|
+
this.closePalette();
|
|
868
|
+
const builtinNames = new Set(this.builtins().map((b) => b.name));
|
|
869
|
+
const items: PaletteItem[] = [
|
|
870
|
+
...this.builtins().map((b) => ({ label: b.name, description: b.description, value: `builtin:${b.name}`, weight: 1 })),
|
|
871
|
+
...(this.active?.commands ?? [])
|
|
872
|
+
.filter((c) => !builtinNames.has(`/${c.name}`))
|
|
873
|
+
.map((c) => ({ label: `/${c.name}`, description: c.description ?? `${this.active!.name} command`, value: `agentcmd:${c.name}` })),
|
|
874
|
+
];
|
|
875
|
+
this.paletteKind = "commands";
|
|
876
|
+
this.palette = new Palette(this.renderer, {
|
|
877
|
+
items,
|
|
878
|
+
filter,
|
|
879
|
+
onPick: (it) => {
|
|
880
|
+
this.closePalette();
|
|
881
|
+
this.input.clear();
|
|
882
|
+
void this.runCommandValue(it.value, "");
|
|
883
|
+
},
|
|
884
|
+
onClose: () => this.closePalette(),
|
|
885
|
+
}, this.paletteAnchor);
|
|
886
|
+
this.renderer.root.add(this.palette.box);
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
private switchItems(): PaletteItem[] {
|
|
890
|
+
const sessions = this.ledger.sessions();
|
|
891
|
+
return listAdapters(this.config.order).map((a) => {
|
|
892
|
+
const d = this.detect.get(a.id);
|
|
893
|
+
const s = sessions[a.id];
|
|
894
|
+
const q = this.quota.get(a.id);
|
|
895
|
+
const usable = !!d?.installed && !!d?.engine;
|
|
896
|
+
const bits: string[] = [];
|
|
897
|
+
if (!d?.installed) bits.push("not installed");
|
|
898
|
+
else if (!d.engine) bits.push("no protocol · use codemax --tui");
|
|
899
|
+
else {
|
|
900
|
+
if (d.version) bits.push(d.version);
|
|
901
|
+
bits.push(s ? `session ${ago(s.lastUsedAt)}` : "new session");
|
|
902
|
+
if (q && quotaLabel(q)) bits.push(quotaLabel(q));
|
|
903
|
+
if (NO_PERMISSION_PROMPTS.has(a.id)) bits.push("acts without asking");
|
|
904
|
+
}
|
|
905
|
+
return { label: a.name + (a.id === this.active?.agent ? " (current)" : ""), description: bits.join(" · "), value: a.id, disabled: !usable };
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
openSwitch() {
|
|
910
|
+
this.closePalette();
|
|
911
|
+
const items = this.switchItems();
|
|
912
|
+
this.paletteKind = "switch";
|
|
913
|
+
this.palette = new Palette(this.renderer, {
|
|
914
|
+
title: "Switch agent · Enter to hand off the conversation · Esc to cancel",
|
|
915
|
+
items,
|
|
916
|
+
onPick: (it) => {
|
|
917
|
+
this.closePalette();
|
|
918
|
+
const id = it.value as AgentId;
|
|
919
|
+
if (id === this.active?.agent) return;
|
|
920
|
+
void this.switchTo(id, false);
|
|
921
|
+
},
|
|
922
|
+
onClose: () => this.closePalette(),
|
|
923
|
+
}, this.paletteAnchor);
|
|
924
|
+
this.renderer.root.add(this.palette.box);
|
|
925
|
+
const p = this.palette;
|
|
926
|
+
void this.refreshDetect().then(() => this.palette === p && p.setItems(this.switchItems()));
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
private openModes(arg: string) {
|
|
930
|
+
const a = this.active;
|
|
931
|
+
if (!a) return;
|
|
932
|
+
const modes = a.engine.info?.modes ?? [];
|
|
933
|
+
if (!modes.length || !a.engine.setMode) return this.transcript.note(`${a.name} has no permission modes to switch`, C.dim);
|
|
934
|
+
if (arg) {
|
|
935
|
+
const m = modes.find((x) => x.id === arg || x.name.toLowerCase() === arg.toLowerCase());
|
|
936
|
+
if (m) return void a.engine.setMode(m.id);
|
|
937
|
+
return this.transcript.note(`unknown mode "${arg}" (${modes.map((x) => x.id).join(", ")})`, C.warn);
|
|
938
|
+
}
|
|
939
|
+
this.closePalette();
|
|
940
|
+
this.paletteKind = "modes";
|
|
941
|
+
this.palette = new Palette(this.renderer, {
|
|
942
|
+
title: "Permission mode · shift+tab cycles these",
|
|
943
|
+
items: modes.map((m) => ({ label: m.id, description: `${m.name}${m.id === a.mode ? " (current)" : ""}${m.description ? " · " + m.description : ""}`, value: m.id })),
|
|
944
|
+
onPick: (it) => {
|
|
945
|
+
this.closePalette();
|
|
946
|
+
void a.engine.setMode!(it.value);
|
|
947
|
+
},
|
|
948
|
+
onClose: () => this.closePalette(),
|
|
949
|
+
}, this.paletteAnchor);
|
|
950
|
+
this.renderer.root.add(this.palette.box);
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
private closePalette(_focus = true) {
|
|
954
|
+
this.palette?.destroy();
|
|
955
|
+
this.palette = null;
|
|
956
|
+
this.paletteKind = null;
|
|
957
|
+
this.input.focus();
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
private async cycleMode() {
|
|
961
|
+
const a = this.active;
|
|
962
|
+
if (!a?.engine.setMode) return this.setFlash("this agent has no permission modes");
|
|
963
|
+
const ids = (a.engine.info?.modes ?? []).map((m) => m.id);
|
|
964
|
+
const cycle = MODE_CYCLE.filter((m) => ids.includes(m));
|
|
965
|
+
const order = cycle.length ? cycle : ids;
|
|
966
|
+
if (!order.length) return;
|
|
967
|
+
const next = order[(order.indexOf(a.mode ?? "") + 1) % order.length]!;
|
|
968
|
+
await a.engine.setMode(next);
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// ---------- engine lifecycle ----------
|
|
972
|
+
|
|
973
|
+
private async launch(agent: AgentId, o: { fresh: boolean; from?: AgentId; reason?: string }) {
|
|
974
|
+
const adapter = getAdapter(agent);
|
|
975
|
+
const blocked = this.checkBlockers(agent);
|
|
976
|
+
if (blocked.length) {
|
|
977
|
+
for (const b of blocked) this.transcript.error(`${adapter.name} not started: ${b.message}${b.fix ? ` Fix: ${b.fix.description} (codemax doctor --fix)` : ""}`);
|
|
978
|
+
this.openSwitch();
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
const engine = this.engineFactory(agent);
|
|
982
|
+
const saved = this.ledger.sessions()[agent];
|
|
983
|
+
const resume = !o.fresh && saved ? { agent, id: saved.id, cwd: this.cwd, startedAt: saved.startedAt, lastUsedAt: saved.lastUsedAt } : undefined;
|
|
984
|
+
const active: Active = { agent, name: adapter.name, engine, turn: null, replay: null, unsubscribe: () => {}, commands: [] };
|
|
985
|
+
active.unsubscribe = engine.onEvent((e) => this.onEngineEvent(active, e));
|
|
986
|
+
this.active = active;
|
|
987
|
+
this.setWorking(`${pick(G.spinner, 0)} Starting ${adapter.name}…`);
|
|
988
|
+
try {
|
|
989
|
+
const info = await engine.start({ cwd: this.cwd, resume });
|
|
990
|
+
active.mode = info.currentMode;
|
|
991
|
+
if (engine.session) this.ledger.saveSession({ agent, id: engine.session.id, cwd: this.cwd, startedAt: engine.session.startedAt, lastUsedAt: Date.now() } as SessionRef);
|
|
992
|
+
this.ledger.addEvent({ kind: o.from ? "switch" : "start", agent: o.from, to: agent, detail: o.reason });
|
|
993
|
+
const resumed = !!resume && engine.session?.id === resume.id;
|
|
994
|
+
active.replay = null;
|
|
995
|
+
const version = this.detect.get(agent)?.version ?? info.version;
|
|
996
|
+
const model = info.currentModel ? ` · ${info.currentModel}` : "";
|
|
997
|
+
this.transcript.divider(`${adapter.name}${version ? ` ${version}` : ""}${model} · ${resumed ? "resumed" : "new session"}`);
|
|
998
|
+
if (NO_PERMISSION_PROMPTS.has(agent)) this.transcript.note(grokPermissionNote() ?? `${adapter.name} acts without asking permission`, C.warn);
|
|
999
|
+
this.setWorking(null);
|
|
1000
|
+
this.renderFooter();
|
|
1001
|
+
this.setTitle();
|
|
1002
|
+
// Only brief the agent when there is something to hand over: a brief with
|
|
1003
|
+
// nothing in it costs a turn (and quota) for no context.
|
|
1004
|
+
const hasHistory = this.ledger.turns().length > 0 || this.ledger.journal.steps().some((s) => s.kind === "user");
|
|
1005
|
+
if (hasHistory) {
|
|
1006
|
+
const input = { ledger: this.ledger, cwd: this.cwd, to: agent, from: o.from, reason: o.reason, config: this.config };
|
|
1007
|
+
// A resumed session already remembers its own work: tell it only what others did since.
|
|
1008
|
+
const catchup = resumed ? await buildCatchup(input) : null;
|
|
1009
|
+
const packet = resumed ? catchup : await buildBrief(input);
|
|
1010
|
+
if (this.closed || this.active !== active) return;
|
|
1011
|
+
if (packet) {
|
|
1012
|
+
const task = this.ledger.journal.task();
|
|
1013
|
+
const what = resumed ? "catching it up on what the other agents did" : task.request ? `handing over the task: ${task.steps.filter((s) => s.kind === "tool").length} steps, the plan and the code diff` : "handing over the conversation and the repo state";
|
|
1014
|
+
this.transcript.note(`${what} (${packet.length} chars)`);
|
|
1015
|
+
// Snapshot first: the new agent may edit files in this turn, and both /undo and a
|
|
1016
|
+
// later catch-up need the state from before it started.
|
|
1017
|
+
const cp = await this.checkpoint(active, `handoff to ${agent}`);
|
|
1018
|
+
const rec = new TurnRecorder(this.ledger.journal, agent);
|
|
1019
|
+
rec.handoff({ from: o.from, reason: o.reason, mode: resumed ? "catchup" : "packet", chars: packet.length, checkpoint: cp?.sha });
|
|
1020
|
+
await this.runTurn(active, packet, { record: false, rec });
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
await this.drainQueue(active);
|
|
1024
|
+
} catch (e: any) {
|
|
1025
|
+
this.setWorking(null);
|
|
1026
|
+
this.transcript.error(`${adapter.name} failed to start: ${e?.message ?? e}`);
|
|
1027
|
+
if (this.active === active) {
|
|
1028
|
+
active.unsubscribe();
|
|
1029
|
+
await engine.close().catch(() => {});
|
|
1030
|
+
this.active = null;
|
|
1031
|
+
this.renderFooter();
|
|
1032
|
+
this.openSwitch();
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
private async runTurn(active: Active, text: string, o: { record?: boolean; rec?: TurnRecorder; journal?: boolean } = {}) {
|
|
1038
|
+
const block = this.transcript.agent();
|
|
1039
|
+
const rec = o.journal === false ? undefined : (o.rec ?? new TurnRecorder(this.ledger.journal, active.agent));
|
|
1040
|
+
active.turn = { block, tools: new Set(), startedAt: Date.now(), tokens: 0, chars: 0, verb: pick(VERBS), rec };
|
|
1041
|
+
this.startSpinner(active);
|
|
1042
|
+
this.setTitle(`✳ ${active.name} working`);
|
|
1043
|
+
const stop = await active.engine.prompt(text);
|
|
1044
|
+
if (this.closed) {
|
|
1045
|
+
active.turn?.rec?.end(stop);
|
|
1046
|
+
active.turn = null;
|
|
1047
|
+
return;
|
|
1048
|
+
}
|
|
1049
|
+
const turn = active.turn;
|
|
1050
|
+
active.turn = null;
|
|
1051
|
+
this.stopSpinner();
|
|
1052
|
+
this.setTitle();
|
|
1053
|
+
if (turn && this.config.bellAfterSeconds > 0 && Date.now() - turn.startedAt > this.config.bellAfterSeconds * 1000) process.stdout.write("\x07");
|
|
1054
|
+
if (turn) {
|
|
1055
|
+
const dur = fmtDuration(Date.now() - turn.startedAt);
|
|
1056
|
+
block.footer(stop === "cancelled" ? `Interrupted after ${dur}` : `${pick(DONE_VERBS, turn.startedAt)} for ${dur} · done ${fmtTime(Date.now())}`);
|
|
1057
|
+
const reply = block.text().trim();
|
|
1058
|
+
if (isLimitNotice(reply)) turn.rec?.markLimit(reply);
|
|
1059
|
+
turn.rec?.end(stop);
|
|
1060
|
+
if (isLimitNotice(reply)) {
|
|
1061
|
+
// The agent ended the turn with only a limit notice: treat it as running out, and leave
|
|
1062
|
+
// the user's request unanswered in the ledger so the next agent is asked to finish it.
|
|
1063
|
+
this.onQuota({ agent: active.agent, exhausted: true, source: "probe", detail: reply.slice(0, 120), resetsAt: parseResetTime(reply), at: Date.now() });
|
|
1064
|
+
} else if (o.record !== false) {
|
|
1065
|
+
if (reply || turn.tools.size) {
|
|
1066
|
+
this.ledger.addTurns([{ ts: Date.now(), agent: active.agent, sessionId: active.engine.session?.id ?? "?", role: "assistant", text: reply, tools: turn.tools.size ? [...turn.tools] : undefined }]);
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
this.renderFooter();
|
|
1071
|
+
const offer = this.pendingOffer;
|
|
1072
|
+
if (offer && this.active === active) {
|
|
1073
|
+
this.pendingOffer = null;
|
|
1074
|
+
this.offerHandoff(offer.from, offer.reason, offer.allAgents);
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
private onEngineEvent(active: Active, e: EngineEvent) {
|
|
1079
|
+
if (this.active !== active && !this.side.has(active) && e.type !== "permission_request") return;
|
|
1080
|
+
const target = () => active.turn?.block ?? (active.replay ??= this.transcript.agent());
|
|
1081
|
+
switch (e.type) {
|
|
1082
|
+
case "text_delta":
|
|
1083
|
+
if (active.turn) active.turn.chars += e.text.length;
|
|
1084
|
+
active.turn?.rec?.textDelta(e.text, e.messageId);
|
|
1085
|
+
target().append(e.text, e.messageId);
|
|
1086
|
+
break;
|
|
1087
|
+
case "thought_delta":
|
|
1088
|
+
if (active.turn) {
|
|
1089
|
+
active.turn.chars += e.text.length;
|
|
1090
|
+
active.turn.verb = "Thinking";
|
|
1091
|
+
}
|
|
1092
|
+
active.turn?.rec?.thoughtDelta(e.text);
|
|
1093
|
+
target().thought(e.text, e.messageId);
|
|
1094
|
+
break;
|
|
1095
|
+
case "plan":
|
|
1096
|
+
active.turn?.rec?.plan(e.entries);
|
|
1097
|
+
target().plan(e.entries);
|
|
1098
|
+
break;
|
|
1099
|
+
case "tool_call":
|
|
1100
|
+
active.turn?.tools.add(e.title.split(/[\s(]/)[0] ?? e.title);
|
|
1101
|
+
active.turn?.rec?.tool(e.id, { title: e.title, kind: e.kind, status: e.status, input: e.input, output: e.output, files: e.files });
|
|
1102
|
+
target().tool(e.id, e.title, e.status, e.detail);
|
|
1103
|
+
break;
|
|
1104
|
+
case "tool_update":
|
|
1105
|
+
active.turn?.rec?.tool(e.id, { title: e.title, status: e.status, input: e.input, output: e.output, files: e.files });
|
|
1106
|
+
target().toolUpdate(e.id, e.status, e.title, e.detail);
|
|
1107
|
+
break;
|
|
1108
|
+
case "permission_request":
|
|
1109
|
+
this.permissionQueue.push({ request: e.request, engine: active.engine });
|
|
1110
|
+
this.showNextPermission();
|
|
1111
|
+
break;
|
|
1112
|
+
case "mode": {
|
|
1113
|
+
active.mode = e.modeId;
|
|
1114
|
+
this.renderFooter();
|
|
1115
|
+
break;
|
|
1116
|
+
}
|
|
1117
|
+
case "commands":
|
|
1118
|
+
active.commands = e.commands.filter((c) => !c.name.startsWith("__"));
|
|
1119
|
+
break;
|
|
1120
|
+
case "usage": {
|
|
1121
|
+
if (active.turn && typeof e.outputTokens === "number") active.turn.tokens += e.outputTokens;
|
|
1122
|
+
if (typeof e.contextUsed === "number" && typeof e.contextSize === "number" && e.contextSize > 0) {
|
|
1123
|
+
active.context = { used: e.contextUsed, size: e.contextSize };
|
|
1124
|
+
this.renderFooter();
|
|
1125
|
+
}
|
|
1126
|
+
if (typeof e.usedPercent === "number") this.onQuota({ agent: active.agent, usedPercent: e.usedPercent, exhausted: e.usedPercent >= 100, source: "probe", at: Date.now() });
|
|
1127
|
+
const hasTokens = (e.inputTokens ?? 0) + (e.outputTokens ?? 0) + (e.cacheReadTokens ?? 0) + (e.cacheWriteTokens ?? 0) > 0;
|
|
1128
|
+
// A zero cost with no tokens (free models report cost 0 on every update) is not usage.
|
|
1129
|
+
if (hasTokens || (e.costUsd ?? 0) > 0) {
|
|
1130
|
+
const rec = priceRecord({
|
|
1131
|
+
ts: Date.now(),
|
|
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);
|
|
1147
|
+
}
|
|
1148
|
+
break;
|
|
1149
|
+
}
|
|
1150
|
+
case "session":
|
|
1151
|
+
if (this.side.has(active)) break; // /ask sessions are throwaway, not resumable
|
|
1152
|
+
this.ledger.saveSession({ agent: active.agent, id: e.ref.id, cwd: this.cwd, startedAt: e.ref.startedAt, lastUsedAt: Date.now() } as SessionRef);
|
|
1153
|
+
break;
|
|
1154
|
+
case "error":
|
|
1155
|
+
this.transcript.error(`${active.name}: ${e.message.slice(0, 500)}`);
|
|
1156
|
+
if (e.kind === "quota") active.turn?.rec?.markLimit(cleanError(e.message));
|
|
1157
|
+
else active.turn?.rec?.markError(cleanError(e.message));
|
|
1158
|
+
if (e.kind === "quota") this.onQuota({ agent: active.agent, exhausted: true, source: "probe", detail: e.message.slice(0, 120), resetsAt: parseResetTime(e.message), at: Date.now() });
|
|
1159
|
+
if (e.kind === "crashed" && this.active === active && !this.switching) {
|
|
1160
|
+
active.turn = null;
|
|
1161
|
+
this.stopSpinner();
|
|
1162
|
+
this.openSwitch();
|
|
1163
|
+
}
|
|
1164
|
+
break;
|
|
1165
|
+
case "log":
|
|
1166
|
+
if (e.level !== "info") this.transcript.note(`${active.name}: ${e.message}`, e.level === "error" ? C.error : C.dim);
|
|
1167
|
+
break;
|
|
1168
|
+
default:
|
|
1169
|
+
break;
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
private showNextPermission() {
|
|
1174
|
+
if (this.permission || !this.permissionQueue.length) return;
|
|
1175
|
+
const { request, engine } = this.permissionQueue.shift()!;
|
|
1176
|
+
const name = this.active?.engine === engine ? this.active.name : "the agent";
|
|
1177
|
+
this.permission = new PermissionDialog(this.renderer, request, name, (choice) => {
|
|
1178
|
+
this.permission?.destroy();
|
|
1179
|
+
this.permission = null;
|
|
1180
|
+
const opt = request.options.find((o) => o.id === choice.optionId);
|
|
1181
|
+
const fallbackReject = request.options.find((o) => o.kind.startsWith("reject"))?.id ?? request.options[0]!.id;
|
|
1182
|
+
engine.answerPermission(request.id, choice.optionId ?? fallbackReject);
|
|
1183
|
+
const allowed = !!opt && opt.kind.startsWith("allow");
|
|
1184
|
+
if (!allowed) this.transcript.note(`declined: ${request.title.length > 70 ? request.title.slice(0, 67) + "…" : request.title}`, C.warn);
|
|
1185
|
+
if (choice.tellAgent) {
|
|
1186
|
+
this.input.placeholder = `Tell ${name} what to do differently…`;
|
|
1187
|
+
setTimeout(() => (this.input.placeholder = 'Try "fix the failing test" · / for commands'), 15000);
|
|
1188
|
+
}
|
|
1189
|
+
this.input.focus();
|
|
1190
|
+
this.showNextPermission();
|
|
1191
|
+
});
|
|
1192
|
+
this.dialogHost.add(this.permission.box);
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
private onQuota(q: QuotaStatus) {
|
|
1196
|
+
const prev = this.quota.get(q.agent);
|
|
1197
|
+
this.quota.set(q.agent, q);
|
|
1198
|
+
this.ledger.saveQuota(q);
|
|
1199
|
+
this.renderFooter();
|
|
1200
|
+
if (isExhausted(q) && !isExhausted(prev)) {
|
|
1201
|
+
this.ledger.addEvent({ kind: "quota", agent: q.agent, detail: q.detail });
|
|
1202
|
+
if (this.config.autoSwitch && (!this.active || this.active.agent === q.agent)) this.offerHandoff(q.agent, `${getAdapter(q.agent).name} is out of quota${q.resetsAt ? ` until ${fmtTime(q.resetsAt)}` : ""}`);
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
// ---------- working line, footer ----------
|
|
1207
|
+
|
|
1208
|
+
private startSpinner(active: Active) {
|
|
1209
|
+
this.stopSpinner();
|
|
1210
|
+
this.spinnerTimer = setInterval(() => {
|
|
1211
|
+
const t = active.turn;
|
|
1212
|
+
if (!t) return this.stopSpinner();
|
|
1213
|
+
this.spinnerFrame++;
|
|
1214
|
+
const glyph = G.spinner[this.spinnerFrame % G.spinner.length];
|
|
1215
|
+
const secs = fmtDuration(Date.now() - t.startedAt);
|
|
1216
|
+
const approx = t.tokens || Math.round(t.chars / 4);
|
|
1217
|
+
const tok = approx ? ` · ↓ ${t.tokens ? "" : "~"}${approx} tokens` : "";
|
|
1218
|
+
if (this.permission) this.setWorking(`${glyph} Waiting for your approval (${secs}) · esc to say no`);
|
|
1219
|
+
else {
|
|
1220
|
+
const elapsed = Date.now() - t.startedAt;
|
|
1221
|
+
const tip = elapsed > 8000 ? ` ⎿ tip: ${pick(TIPS, Math.floor(t.startedAt / 1000))}` : "";
|
|
1222
|
+
this.setWorking(`${glyph} ${t.verb}… (${secs}${tok}) · esc to interrupt${tip}`);
|
|
1223
|
+
}
|
|
1224
|
+
}, 120);
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
private setTitle(text?: string) {
|
|
1228
|
+
const base = `codemax · ${this.active?.name ?? "no agent"} · ${this.cwd.split("/").pop()}`;
|
|
1229
|
+
try {
|
|
1230
|
+
this.renderer.setTerminalTitle(text ? `${text} · ${this.cwd.split("/").pop()}` : base);
|
|
1231
|
+
} catch {
|
|
1232
|
+
/* test renderer */
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
private stopSpinner() {
|
|
1237
|
+
if (this.spinnerTimer) clearInterval(this.spinnerTimer);
|
|
1238
|
+
this.spinnerTimer = null;
|
|
1239
|
+
this.setWorking(null);
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
private setWorking(text: string | null) {
|
|
1243
|
+
this.working.visible = !!text;
|
|
1244
|
+
this.working.content = text ?? "";
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
private setFlash(text: string) {
|
|
1248
|
+
this.flash = text;
|
|
1249
|
+
this.renderFooter();
|
|
1250
|
+
if (this.flashTimer) clearTimeout(this.flashTimer);
|
|
1251
|
+
this.flashTimer = setTimeout(() => {
|
|
1252
|
+
this.flash = "";
|
|
1253
|
+
this.renderFooter();
|
|
1254
|
+
}, 4000);
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
private renderFooter() {
|
|
1258
|
+
const a = this.active;
|
|
1259
|
+
const width = Math.max(20, this.renderer.width - 4);
|
|
1260
|
+
let color: string = C.dim;
|
|
1261
|
+
const mode = a ? modeLabel(a.name, a.mode) : null;
|
|
1262
|
+
if (mode) color = mode.color;
|
|
1263
|
+
const canCycle = !!a?.engine.setMode && (a.engine.info?.modes.length ?? 0) > 1;
|
|
1264
|
+
// Pieces in priority order; low-priority ones are dropped until the line fits.
|
|
1265
|
+
const left: { text: string; prio: number }[] = [];
|
|
1266
|
+
if (mode) left.push({ text: `${mode.glyph} ${mode.text}`, prio: 0 });
|
|
1267
|
+
else left.push({ text: "no agent · /switch", prio: 0 });
|
|
1268
|
+
if (canCycle) left.push({ text: "shift+tab to cycle", prio: 3 });
|
|
1269
|
+
if (this.flash) left.push({ text: this.flash, prio: 1 });
|
|
1270
|
+
else if (this.queue.length) left.push({ text: `${this.queue.length} queued`, prio: 1 });
|
|
1271
|
+
else if (!this.shortcuts.visible) left.push({ text: "? for shortcuts", prio: 2 });
|
|
1272
|
+
const right: { text: string; prio: number }[] = [];
|
|
1273
|
+
const effort = a ? this.effortOption(a)?.current : undefined;
|
|
1274
|
+
if (a) right.push({ text: `${a.name}${a.engine.info?.currentModel ? ` · ${shortModelName(a.engine.info.currentModel)}` : ""}${effort ? ` ${effort}` : ""}`, prio: 2 });
|
|
1275
|
+
if (a?.context) right.push({ text: `ctx ${Math.min(100, Math.round((a.context.used / a.context.size) * 100))}%`, prio: 1 });
|
|
1276
|
+
const bk: BudgetKey | null = a && this.config.budgets?.[a.agent] ? a.agent : this.config.budgets?.all ? "all" : null;
|
|
1277
|
+
if (bk) right.push({ text: `${fmtUsd(this.today.get(bk) ?? 0)}/${fmtUsd(this.config.budgets[bk])} today`, prio: 1 });
|
|
1278
|
+
else if (this.sessionCost > 0 || this.sessionTokens > 0) right.push({ text: `${fmtUsd(this.sessionCost)} /usage`, prio: 1 });
|
|
1279
|
+
const quotas = [...this.quota.values()].map((q) => [q.agent, quotaLabel(q)] as const).filter(([, l]) => l).map(([ag, l]) => `${ag} ${l}`).slice(0, 2);
|
|
1280
|
+
if (quotas.length) right.push({ text: quotas.join(" · "), prio: 2 });
|
|
1281
|
+
right.push({ text: `${this.config.hotkeys.map((h) => h.toLowerCase()).join(" · ")} agents`, prio: 4 });
|
|
1282
|
+
const compose = (l: typeof left, r: typeof right) => {
|
|
1283
|
+
const ls = l.map((x) => x.text).join(" · ");
|
|
1284
|
+
const rs = r.map((x) => x.text).join(" · ");
|
|
1285
|
+
return { ls, rs, gap: width - ls.length - rs.length };
|
|
1286
|
+
};
|
|
1287
|
+
let L = left, R = right;
|
|
1288
|
+
let c = compose(L, R);
|
|
1289
|
+
for (const prio of [4, 3, 2, 1]) {
|
|
1290
|
+
if (c.gap >= 2) break;
|
|
1291
|
+
L = L.filter((x) => x.prio !== prio);
|
|
1292
|
+
R = R.filter((x) => x.prio !== prio);
|
|
1293
|
+
c = compose(L, R);
|
|
1294
|
+
}
|
|
1295
|
+
this.footerText.content = c.gap >= 2 ? c.ls + " ".repeat(c.gap) + c.rs : `${c.ls} ${c.rs}`.slice(0, width);
|
|
1296
|
+
this.footerText.fg = this.flash ? C.dim : color;
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
// ---------- detection / switching / quit ----------
|
|
1300
|
+
|
|
1301
|
+
private async refreshDetect() {
|
|
1302
|
+
await Promise.all(
|
|
1303
|
+
listAdapters(this.config.order).map(async (a) => {
|
|
1304
|
+
const d = await a.detect();
|
|
1305
|
+
const f = engineFactories[a.id];
|
|
1306
|
+
const engine = !!f && (await f.available().catch(() => false));
|
|
1307
|
+
this.detect.set(a.id, { installed: d.installed, version: d.version, engine });
|
|
1308
|
+
}),
|
|
1309
|
+
);
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
private async switchTo(agent: AgentId, fresh: boolean, o: { resumeTask?: boolean } = {}) {
|
|
1313
|
+
if (!hasEngine(agent)) return this.transcript.note(`${agent} has no protocol engine; run codemax --tui for it`, C.warn);
|
|
1314
|
+
this.switching = true;
|
|
1315
|
+
const prev = this.active;
|
|
1316
|
+
let from: AgentId | undefined;
|
|
1317
|
+
const pq = prev ? this.quota.get(prev.agent) : undefined;
|
|
1318
|
+
const nearLimit = o.resumeTask || isExhausted(pq) || (typeof pq?.usedPercent === "number" && pq.usedPercent >= 85);
|
|
1319
|
+
// The step journal hands over the whole task; a summary turn would only spend the
|
|
1320
|
+
// outgoing agent's quota. Summaries are still written for older, journal-less ledgers.
|
|
1321
|
+
const journaled = this.ledger.journal.steps().some((s) => s.kind === "user");
|
|
1322
|
+
if (prev && !prev.turn && this.config.summarizeOnSwitch && !nearLimit && !journaled && this.ledger.turns().length >= 4) {
|
|
1323
|
+
const timer = setTimeout(() => void prev.engine.cancel(), 30_000);
|
|
1324
|
+
await this.compact({ quiet: true }).catch(() => false);
|
|
1325
|
+
clearTimeout(timer);
|
|
1326
|
+
}
|
|
1327
|
+
if (prev) {
|
|
1328
|
+
from = prev.agent;
|
|
1329
|
+
if (prev.turn) await prev.engine.cancel().catch(() => {});
|
|
1330
|
+
prev.unsubscribe();
|
|
1331
|
+
this.active = null;
|
|
1332
|
+
this.stopSpinner();
|
|
1333
|
+
await prev.engine.close().catch(() => {});
|
|
1334
|
+
this.ledger.addEvent({ kind: "note", agent: from, detail: "stopped for switch", diffStat: await diffStat(this.cwd) });
|
|
1335
|
+
}
|
|
1336
|
+
const fromQ = from ? this.quota.get(from) : undefined;
|
|
1337
|
+
const overBudget = from && budgetLevel(this.today.get(from) ?? 0, this.config.budgets?.[from]) === "over";
|
|
1338
|
+
// Only ask the next agent to finish a request when one is actually unanswered.
|
|
1339
|
+
const lastTurn = this.ledger.turns().at(-1);
|
|
1340
|
+
const pending = lastTurn?.role === "user" ? "; finish the last user request" : "";
|
|
1341
|
+
const reason = fromQ && isExhausted(fromQ) ? `${from} ran out of quota${pending ? " mid-task" : ""}${pending}` : overBudget ? `${from} hit its daily budget${pending}` : o.resumeTask ? `${from} stopped${pending}` : undefined;
|
|
1342
|
+
await this.launch(agent, { fresh, from, reason });
|
|
1343
|
+
this.switching = false;
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
async quit() {
|
|
1347
|
+
this.closed = true;
|
|
1348
|
+
this.switching = true;
|
|
1349
|
+
const a = this.active;
|
|
1350
|
+
this.closePalette();
|
|
1351
|
+
this.permission?.destroy();
|
|
1352
|
+
this.permission = null;
|
|
1353
|
+
this.stopSpinner();
|
|
1354
|
+
if (a) {
|
|
1355
|
+
this.active = null;
|
|
1356
|
+
a.unsubscribe();
|
|
1357
|
+
if (a.turn) await a.engine.cancel().catch(() => {});
|
|
1358
|
+
await a.engine.close().catch(() => {});
|
|
1359
|
+
this.ledger.addEvent({ kind: "exit", agent: a.agent, detail: "codemax quit", diffStat: await diffStat(this.cwd) });
|
|
1360
|
+
}
|
|
1361
|
+
this.renderer.keyInput.off("keypress", this.onKey);
|
|
1362
|
+
this.resolveDone();
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
function shortModelName(m: string): string {
|
|
1367
|
+
// Cursor appends settings in brackets: "claude-opus-5-5[context=300k,effort=medium]".
|
|
1368
|
+
const name = m.replace(/\[.*\]$/, "").replace(/^claude-/, "").replace(/^(anthropic|openai|xai|google|opencode)\//, "");
|
|
1369
|
+
return !name || name === "default" ? "auto" : name;
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
function ago(ts: number): string {
|
|
1373
|
+
const m = Math.round((Date.now() - ts) / 60_000);
|
|
1374
|
+
if (m < 1) return "just now";
|
|
1375
|
+
if (m < 60) return `${m}m ago`;
|
|
1376
|
+
const h = Math.round(m / 60);
|
|
1377
|
+
return h < 48 ? `${h}h ago` : `${Math.round(h / 24)}d ago`;
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
export async function runChat(config: Config, ledger: Ledger, cwd: string, agent: AgentId, fresh: boolean, pick = false) {
|
|
1381
|
+
const renderer = await createCliRenderer({ exitOnCtrlC: false, exitSignals: ["SIGTERM", "SIGHUP"], useMouse: true, targetFps: 60 });
|
|
1382
|
+
const pref = config.theme ?? "auto";
|
|
1383
|
+
applyTheme(pref === "auto" ? ((await renderer.waitForThemeMode(250).catch(() => null)) ?? "dark") : pref);
|
|
1384
|
+
const app = new ChatApp(renderer, config, ledger, cwd);
|
|
1385
|
+
try {
|
|
1386
|
+
await app.start(agent, fresh, { pick });
|
|
1387
|
+
} finally {
|
|
1388
|
+
renderer.destroy();
|
|
1389
|
+
try {
|
|
1390
|
+
await renderer.closed;
|
|
1391
|
+
} catch {
|
|
1392
|
+
/* output already gone */
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
process.stdout.write(`codemax: bye. ledger at ${ledger.dir}\n`);
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
/** Copy text: the OS clipboard tool when there is one, plus OSC 52 (works over SSH and in most terminals). */
|
|
1399
|
+
async function copyToClipboard(renderer: CliRenderer, text: string): Promise<boolean> {
|
|
1400
|
+
let osc = false;
|
|
1401
|
+
try {
|
|
1402
|
+
osc = renderer.copyToClipboardOSC52(text);
|
|
1403
|
+
} catch {
|
|
1404
|
+
/* terminal without OSC 52 */
|
|
1405
|
+
}
|
|
1406
|
+
const tools: string[][] =
|
|
1407
|
+
process.platform === "darwin" ? [["pbcopy"]]
|
|
1408
|
+
: process.platform === "win32" ? [["clip"]]
|
|
1409
|
+
: [["wl-copy"], ["xclip", "-selection", "clipboard"], ["xsel", "--clipboard", "--input"]];
|
|
1410
|
+
for (const cmd of tools) {
|
|
1411
|
+
if (!Bun.which(cmd[0]!)) continue;
|
|
1412
|
+
try {
|
|
1413
|
+
const p = Bun.spawn(platformArgv(cmd), { stdin: "pipe", stdout: "ignore", stderr: "ignore" });
|
|
1414
|
+
p.stdin.write(text);
|
|
1415
|
+
await p.stdin.end();
|
|
1416
|
+
if ((await p.exited) === 0) return true;
|
|
1417
|
+
} catch {
|
|
1418
|
+
/* try the next tool */
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
return osc;
|
|
1422
|
+
}
|