mini-coder 0.7.4 → 0.8.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/AGENTS.md +114 -0
- package/README.md +53 -66
- package/bin/mini-coder.ts +2 -0
- package/demo.gif +0 -0
- package/package.json +17 -20
- package/src/agent.ts +193 -272
- package/src/auth.ts +84 -0
- package/src/cli.ts +99 -0
- package/src/config.ts +179 -0
- package/src/prompt.ts +54 -207
- package/src/session.ts +124 -69
- package/src/tools/bash.ts +86 -0
- package/src/tools/common.ts +32 -0
- package/src/tools/edit.ts +41 -0
- package/src/tools/index.ts +47 -0
- package/src/tools/read.ts +64 -0
- package/src/tui/commands.ts +199 -0
- package/src/tui/complete.ts +85 -0
- package/src/tui/editor.ts +320 -0
- package/src/tui/highlight.ts +189 -0
- package/src/tui/stream.ts +142 -0
- package/src/tui/styles.ts +20 -0
- package/src/tui/term.ts +436 -0
- package/src/tui/theme.ts +120 -0
- package/src/tui/tui.ts +758 -0
- package/src/tui/usage.ts +67 -0
- package/tsconfig.json +8 -8
- package/bin/mc.ts +0 -11
- package/bun.lock +0 -350
- package/nono-mini-coder.json +0 -42
- package/src/args.ts +0 -252
- package/src/error-handling.test.ts +0 -163
- package/src/git.ts +0 -23
- package/src/headless.ts +0 -66
- package/src/index.ts +0 -43
- package/src/models.ts +0 -191
- package/src/oauth.ts +0 -147
- package/src/shared.ts +0 -119
- package/src/themes.ts +0 -234
- package/src/tool-bash.ts +0 -77
- package/src/tool-edit.ts +0 -121
- package/src/tool-read.ts +0 -100
- package/src/tui-components.ts +0 -127
- package/src/tui-conversation.ts +0 -218
- package/src/tui-editor.ts +0 -29
- package/src/tui-overlay.ts +0 -604
- package/src/tui.ts +0 -314
- package/src/types.ts +0 -194
- package/src/update.ts +0 -171
package/src/tui/tui.ts
ADDED
|
@@ -0,0 +1,758 @@
|
|
|
1
|
+
import process from "node:process";
|
|
2
|
+
import {
|
|
3
|
+
clampThinkingLevel,
|
|
4
|
+
type Api,
|
|
5
|
+
type AssistantMessage,
|
|
6
|
+
type AuthEvent,
|
|
7
|
+
type AuthPrompt,
|
|
8
|
+
type JsonObject,
|
|
9
|
+
type Message,
|
|
10
|
+
type Model,
|
|
11
|
+
type ModelThinkingLevel,
|
|
12
|
+
type UserMessage,
|
|
13
|
+
} from "@earendil-works/pi-ai";
|
|
14
|
+
import { assistantText, runAgentTurn, type AgentEvent, type AgentOptions, type Phase } from "../agent.ts";
|
|
15
|
+
import { acceptsImages, toolSchemas } from "../tools/index.ts";
|
|
16
|
+
import { Terminal, expandTabs, sanitize, wrapLine, type Key } from "./term.ts";
|
|
17
|
+
import { Editor } from "./editor.ts";
|
|
18
|
+
import { completeCommand, findCommand, type Command, type CommandContext } from "./commands.ts";
|
|
19
|
+
import { completePath } from "./complete.ts";
|
|
20
|
+
import { MarkdownStream, TailStream, type BodyLine, type StreamRenderer } from "./stream.ts";
|
|
21
|
+
import { blue, cyan, dim, green, red, teal } from "./styles.ts";
|
|
22
|
+
import { DIFF_ADD, DIFF_DELETE, NORMAL_BG, sgrBg, sgrPlain } from "./theme.ts";
|
|
23
|
+
import { contextUsageLine, estimateContextTokens } from "./usage.ts";
|
|
24
|
+
|
|
25
|
+
/** Status-row spinner frames; the only animation in the TUI. */
|
|
26
|
+
const SPINNER = [..."⠀⠁⠂⠃⠄⠅⠆⠇⡀⡁⡂⡃⡄⡅⡆⡇⠈⠉⠊⠋⠌⠍⠎⠏⡈⡉⡊⡋⡌⡍⡎⡏⠐⠑⠒⠓⠔⠕⠖⠗⡐⡑⡒⡓⡔⡕⡖⡗⠘⠙⠚⠛⠜⠝⠞⠟⡘⡙⡚⡛⡜⡝⡞⡟⠠⠡⠢⠣⠤⠥⠦⠧⡠⡡⡢⡣⡤⡥⡦⡧⠨⠩⠪⠫⠬⠭⠮⠯⡨⡩⡪⡫⡬⡭⡮⡯⠰⠱⠲⠳⠴⠵⠶⠷⡰⡱⡲⡳⡴⡵⡶⡷⠸⠹⠺⠻⠼⠽⠾⠿⡸⡹⡺⡻⡼⡽⡾⡿⢀⢁⢂⢃⢄⢅⢆⢇⣀⣁⣂⣃⣄⣅⣆⣇⢈⢉⢊⢋⢌⢍⢎⢏⣈⣉⣊⣋⣌⣍⣎⣏⢐⢑⢒⢓⢔⢕⢖⢗⣐⣑⣒⣓⣔⣕⣖⣗⢘⢙⢚⢛⢜⢝⢞⢟⣘⣙⣚⣛⣜⣝⣞⣟⢠⢡⢢⢣⢤⢥⢦⢧⣠⣡⣢⣣⣤⣥⣦⣧⢨⢩⢪⢫⢬⢭⢮⢯⣨⣩⣪⣫⣬⣭⣮⣯⢰⢱⢲⢳⢴⢵⢶⢷⣰⣱⣲⣳⣴⣵⣶⣷⢸⢹⢺⢻⢼⢽⢾⢿⣸⣹⣺⣻⣼⣽⣾⣿"];
|
|
27
|
+
const SPINNER_MS = 120;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Display-only elision for tool bodies, deliberately worded differently from
|
|
31
|
+
* the model-facing `... output truncated ...` marker in `tools/bash.ts`.
|
|
32
|
+
* `edit` is exempt: its diffs are always shown in full.
|
|
33
|
+
*/
|
|
34
|
+
const MAX_BODY_ROWS = 12;
|
|
35
|
+
const ELIDED_HEAD = 4;
|
|
36
|
+
const ELIDED_TAIL = 4;
|
|
37
|
+
|
|
38
|
+
const BODY_PREFIX = " | ";
|
|
39
|
+
const ERROR_PREFIX = " ! ";
|
|
40
|
+
/** The chrome prefix, styled: dim for output, red for errors; text stays `Normal`. */
|
|
41
|
+
const BODY_CHROME = dim(BODY_PREFIX);
|
|
42
|
+
const ERROR_CHROME = red(ERROR_PREFIX);
|
|
43
|
+
const EXIT_LINE = /^exit code: (.+)$/;
|
|
44
|
+
const EDIT_HEADER = /^(Index: |={3,}$|--- |\+\+\+ )/;
|
|
45
|
+
|
|
46
|
+
/** The `-> <name>` head of a call line, in the tool accent; the args stay `Normal`. */
|
|
47
|
+
function callHead(name: string): string {
|
|
48
|
+
return teal(`-> ${name}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function callSummary(name: string, args: JsonObject): string {
|
|
52
|
+
if (name === "bash" && typeof args.command === "string") return args.command.replace(/\s*\n\s*/g, " ");
|
|
53
|
+
if ((name === "edit" || name === "read") && typeof args.path === "string") return args.path;
|
|
54
|
+
return JSON.stringify(args);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Unified-diff styling for one `edit` body line; file headers are stripped.
|
|
59
|
+
* Additions and deletions carry the `DiffAdd`/`DiffDelete` background, so every
|
|
60
|
+
* cell of the line — the trailing ones `paintRow` erases included — is tinted.
|
|
61
|
+
*/
|
|
62
|
+
function diffLine(line: string): BodyLine {
|
|
63
|
+
if (line.startsWith("@@")) return { text: line, style: cyan };
|
|
64
|
+
if (line.startsWith("+")) return { text: line, style: green, bg: DIFF_ADD };
|
|
65
|
+
if (line.startsWith("-")) return { text: line, style: red, bg: DIFF_DELETE };
|
|
66
|
+
if (line.startsWith("\\ No newline")) return { text: line, style: dim };
|
|
67
|
+
if (line.startsWith(" ")) return { text: line, style: dim };
|
|
68
|
+
return { text: line };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Display rewrite of a tool result, by tool name. */
|
|
72
|
+
function resultLines(name: string, text: string, isError: boolean): BodyLine[] {
|
|
73
|
+
const lines = text.trimEnd().split("\n");
|
|
74
|
+
let diff = false;
|
|
75
|
+
if (name === "bash") {
|
|
76
|
+
const exit = EXIT_LINE.exec(lines[lines.length - 1]);
|
|
77
|
+
if (exit !== null) {
|
|
78
|
+
lines.pop();
|
|
79
|
+
if (isError) lines.push(red(`exit ${exit[1]}`));
|
|
80
|
+
}
|
|
81
|
+
} else if (name === "edit" && /^(edited|created) /.test(lines[0])) {
|
|
82
|
+
lines.shift();
|
|
83
|
+
// The call line already names the path; drop the repeated diff file header.
|
|
84
|
+
while (lines.length > 0 && EDIT_HEADER.test(lines[0])) lines.shift();
|
|
85
|
+
diff = true;
|
|
86
|
+
}
|
|
87
|
+
return lines.map((line) => (diff ? diffLine(line) : { text: line }));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Wraps plain text, then styles each row: styling after the break is what lets a
|
|
92
|
+
* continuation row inherit its source line's style. A continuation row carries
|
|
93
|
+
* no marker; empty rows stay unstyled.
|
|
94
|
+
*/
|
|
95
|
+
function renderRows(lines: BodyLine[], width: number): string[] {
|
|
96
|
+
return lines.flatMap((line) => {
|
|
97
|
+
const { style, bg } = line;
|
|
98
|
+
const wrapped = wrapLine(expandTabs(sanitize(line.text)), width);
|
|
99
|
+
return wrapped.map((row) => {
|
|
100
|
+
if (row === "") return row;
|
|
101
|
+
const styled = style === undefined ? row : style(row);
|
|
102
|
+
// The row's own background opens and closes it. A markdown span that
|
|
103
|
+
// carries a background (a heading tint, inline code) can still be open
|
|
104
|
+
// where the row ends, and `paintRow`'s trailing erase fills with whatever
|
|
105
|
+
// background is current — so the row has to hand it back its own.
|
|
106
|
+
const rowBg = sgrBg(bg ?? NORMAL_BG);
|
|
107
|
+
return `${rowBg}${styled}${rowBg}`;
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Paints one written row: the palette's pair, the row's own styling, then
|
|
114
|
+
* `ESC[K` — erase to the end of the line, which the terminal does *with the
|
|
115
|
+
* row's current background*. The row's trailing cells take the row's own
|
|
116
|
+
* background (the diff tints included) without a glyph being written for them,
|
|
117
|
+
* so a short row has no cell left to the host. Padding with spaces would look
|
|
118
|
+
* the same, but a terminal that reflows on a narrower resize would then wrap
|
|
119
|
+
* every row and lose the live region's anchor.
|
|
120
|
+
*/
|
|
121
|
+
function paintRow(line: string): string {
|
|
122
|
+
return `${sgrPlain()}${line}\x1b[K`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** `renderRows` plus the display-only elision applied to long tool bodies. */
|
|
126
|
+
function bodyRows(lines: BodyLine[], width: number): string[] {
|
|
127
|
+
const rows = renderRows(lines, width);
|
|
128
|
+
if (rows.length > MAX_BODY_ROWS) {
|
|
129
|
+
const hidden = rows.length - ELIDED_HEAD - ELIDED_TAIL;
|
|
130
|
+
return [
|
|
131
|
+
...rows.slice(0, ELIDED_HEAD),
|
|
132
|
+
dim(`... ${hidden} lines not shown ...`),
|
|
133
|
+
...rows.slice(rows.length - ELIDED_TAIL),
|
|
134
|
+
];
|
|
135
|
+
}
|
|
136
|
+
return rows;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
class LiveRegion {
|
|
140
|
+
private rows = 0;
|
|
141
|
+
private cursorUp = 0;
|
|
142
|
+
|
|
143
|
+
/** Rows the region currently occupies on screen. */
|
|
144
|
+
get height(): number {
|
|
145
|
+
return this.rows;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
clear(): string {
|
|
149
|
+
if (this.rows === 0) return "";
|
|
150
|
+
let out = "";
|
|
151
|
+
if (this.cursorUp > 0) out += `\x1b[${this.cursorUp}B`;
|
|
152
|
+
if (this.rows > 1) out += `\x1b[${this.rows - 1}A`;
|
|
153
|
+
// `ESC[J` erases with the *current* background, so the palette's is set
|
|
154
|
+
// first: without it the erase bites host-coloured holes and the region
|
|
155
|
+
// flashes the terminal's background while typing.
|
|
156
|
+
out += `\r${sgrPlain()}\x1b[J`;
|
|
157
|
+
this.rows = 0;
|
|
158
|
+
this.cursorUp = 0;
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* `lines` are already styled; the live region adds no attributes of its own.
|
|
164
|
+
* `above` is written between the clear and the body, so scrollback lands
|
|
165
|
+
* where the region was.
|
|
166
|
+
*/
|
|
167
|
+
draw(lines: string[], cursorRow: number, cursorCol: number, above: string): string {
|
|
168
|
+
let out = this.clear();
|
|
169
|
+
out += above;
|
|
170
|
+
for (let i = 0; i < lines.length; i++) {
|
|
171
|
+
if (i > 0) out += "\r\n";
|
|
172
|
+
out += lines[i];
|
|
173
|
+
}
|
|
174
|
+
const up = lines.length - 1 - cursorRow;
|
|
175
|
+
if (up > 0) out += `\x1b[${up}A`;
|
|
176
|
+
out += "\r";
|
|
177
|
+
if (cursorCol > 0) out += `\x1b[${cursorCol + 1}G`;
|
|
178
|
+
this.rows = lines.length;
|
|
179
|
+
this.cursorUp = up;
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
class Tui {
|
|
185
|
+
private readonly opts: AgentOptions;
|
|
186
|
+
private readonly term: Terminal;
|
|
187
|
+
private readonly editor = new Editor();
|
|
188
|
+
private readonly live = new LiveRegion();
|
|
189
|
+
private readonly messages: Message[] = [];
|
|
190
|
+
readonly done: Promise<void>;
|
|
191
|
+
|
|
192
|
+
/** A running command's cancellation, and its one pending prompt. */
|
|
193
|
+
private commandAbort: AbortController | null = null;
|
|
194
|
+
private pendingPrompt: {
|
|
195
|
+
prompt: AuthPrompt;
|
|
196
|
+
resolve: (value: string) => void;
|
|
197
|
+
reject: (error: Error) => void;
|
|
198
|
+
} | null = null;
|
|
199
|
+
|
|
200
|
+
private resolveExit: () => void = () => {};
|
|
201
|
+
private phase: Phase = "idle";
|
|
202
|
+
private detail: string | undefined;
|
|
203
|
+
private writingTool: string | undefined;
|
|
204
|
+
private active = false;
|
|
205
|
+
private paused = false;
|
|
206
|
+
private pauseRequested = false;
|
|
207
|
+
private steeringResolve: ((text: string) => void) | null = null;
|
|
208
|
+
private abort: AbortController | null = null;
|
|
209
|
+
private renderScheduled = false;
|
|
210
|
+
private closed = false;
|
|
211
|
+
|
|
212
|
+
// Scrollback: lines accumulate here and are written above the live region.
|
|
213
|
+
private scroll = "";
|
|
214
|
+
private wrote = false;
|
|
215
|
+
private lastBlank = false;
|
|
216
|
+
private separator = false;
|
|
217
|
+
|
|
218
|
+
// In-flight stream state, never persisted: all display-only.
|
|
219
|
+
private readonly reply: StreamRenderer = new MarkdownStream();
|
|
220
|
+
private readonly activity: StreamRenderer = new TailStream();
|
|
221
|
+
private pendingCalls: BodyLine[] = [];
|
|
222
|
+
private streamed = "";
|
|
223
|
+
private turnStart = 0;
|
|
224
|
+
private frame = 0;
|
|
225
|
+
private spinner: NodeJS.Timeout | undefined;
|
|
226
|
+
|
|
227
|
+
constructor(opts: AgentOptions) {
|
|
228
|
+
this.opts = opts;
|
|
229
|
+
this.term = new Terminal({
|
|
230
|
+
onKey: (key) => this.handleKey(key),
|
|
231
|
+
// A reflow moves the region but leaves the cursor on the line it was on,
|
|
232
|
+
// so the region's top is still `cursorUp` rows above it. Redraw over the
|
|
233
|
+
// old region instead of dropping the anchor, which would leave it behind.
|
|
234
|
+
onResize: () => this.render(),
|
|
235
|
+
});
|
|
236
|
+
this.done = new Promise((resolve) => {
|
|
237
|
+
this.resolveExit = resolve;
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
start(): void {
|
|
242
|
+
this.term.start();
|
|
243
|
+
process.on("SIGINT", this.onSignal);
|
|
244
|
+
process.on("SIGTERM", this.onSignal);
|
|
245
|
+
this.pushBanner();
|
|
246
|
+
this.render();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private onSignal = (): void => this.exit();
|
|
250
|
+
|
|
251
|
+
private handleKey(key: Key): void {
|
|
252
|
+
if (key.type === "eof") {
|
|
253
|
+
if (!this.active && this.commandAbort === null && this.editor.text() === "") this.exit();
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (key.type === "interrupt") {
|
|
257
|
+
if (this.active) this.cancel();
|
|
258
|
+
else if (this.commandAbort !== null) this.commandAbort.abort();
|
|
259
|
+
else if (this.editor.text() !== "") {
|
|
260
|
+
this.editor.clear();
|
|
261
|
+
this.render();
|
|
262
|
+
}
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
if (key.type === "escape") {
|
|
266
|
+
if (this.active && !this.paused) {
|
|
267
|
+
this.pauseRequested = true;
|
|
268
|
+
this.render();
|
|
269
|
+
}
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
if (key.type === "tab") {
|
|
273
|
+
const completed = completeCommand(this.editor.text());
|
|
274
|
+
if (completed !== null) {
|
|
275
|
+
this.editor.setText(completed);
|
|
276
|
+
} else {
|
|
277
|
+
this.editor.completeWord((word) => completePath(word, process.cwd()));
|
|
278
|
+
}
|
|
279
|
+
this.render();
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
const result = this.editor.handle(key);
|
|
283
|
+
if (result === "submit") this.submit();
|
|
284
|
+
else if (result === "changed") this.render();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
private submit(): void {
|
|
288
|
+
const text = this.editor.text();
|
|
289
|
+
if (this.active) {
|
|
290
|
+
if (this.paused && this.steeringResolve !== null) {
|
|
291
|
+
this.editor.clear();
|
|
292
|
+
if (text.length > 0) this.commitUser(text);
|
|
293
|
+
this.resolveSteering(text);
|
|
294
|
+
}
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
if (this.pendingPrompt !== null) {
|
|
298
|
+
this.editor.clear();
|
|
299
|
+
this.render();
|
|
300
|
+
this.answerPrompt(text);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
if (this.commandAbort !== null) return;
|
|
304
|
+
if (text.trim() === "") return;
|
|
305
|
+
const found = findCommand(text);
|
|
306
|
+
if (found !== null) {
|
|
307
|
+
this.editor.clear();
|
|
308
|
+
this.runCommand(found.command, found.args);
|
|
309
|
+
this.render();
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
this.editor.clear();
|
|
313
|
+
const message: UserMessage = { role: "user", content: text, timestamp: Date.now() };
|
|
314
|
+
this.messages.push(message);
|
|
315
|
+
this.opts.session.appendMessage(message);
|
|
316
|
+
this.commitUser(text);
|
|
317
|
+
this.startTurn();
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Runs one command with a fresh context. Lines submitted while it runs route
|
|
322
|
+
* to a pending prompt; Ctrl+C aborts its signal. No agent turn may start
|
|
323
|
+
* until it settles. Errors surface as an error line.
|
|
324
|
+
*/
|
|
325
|
+
private runCommand(command: Command, args: string): void {
|
|
326
|
+
const abort = new AbortController();
|
|
327
|
+
this.commandAbort = abort;
|
|
328
|
+
const ctx: CommandContext = {
|
|
329
|
+
models: this.opts.models,
|
|
330
|
+
model: this.opts.model,
|
|
331
|
+
select: (model) => this.select(model),
|
|
332
|
+
setThinking: (level) => this.setThinking(level),
|
|
333
|
+
signal: abort.signal,
|
|
334
|
+
write: (lines) => {
|
|
335
|
+
this.separator = true;
|
|
336
|
+
this.commitLines(lines.map((text) => ({ text })));
|
|
337
|
+
this.separator = true;
|
|
338
|
+
},
|
|
339
|
+
prompt: (prompt) => this.ask(prompt),
|
|
340
|
+
notify: (event) => this.notify(event),
|
|
341
|
+
};
|
|
342
|
+
void (async () => {
|
|
343
|
+
try {
|
|
344
|
+
await command.run(ctx, args);
|
|
345
|
+
} catch (error) {
|
|
346
|
+
this.separator = true;
|
|
347
|
+
this.push(red(`! ${(error as Error).message}`));
|
|
348
|
+
this.separator = true;
|
|
349
|
+
} finally {
|
|
350
|
+
this.commandAbort = null;
|
|
351
|
+
this.pendingPrompt = null;
|
|
352
|
+
this.editor.setMasked(false);
|
|
353
|
+
this.render();
|
|
354
|
+
}
|
|
355
|
+
})();
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** The startup and selection banner: provider, model, and thinking effort. */
|
|
359
|
+
private pushBanner(): void {
|
|
360
|
+
this.separator = true;
|
|
361
|
+
this.push(`mini-coder · ${this.opts.model.provider}/${this.opts.model.id} · ${this.opts.thinkingEffort}`);
|
|
362
|
+
this.separator = true;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Switches the running session to `model`: derived state follows — the
|
|
367
|
+
* thinking effort is re-clamped, and `read`'s image behaviour is rebuilt for
|
|
368
|
+
* the new model. The status line and context readout read `opts.model`, so
|
|
369
|
+
* they update on the next render.
|
|
370
|
+
*/
|
|
371
|
+
private select(model: Model<Api>): void {
|
|
372
|
+
this.opts.model = model;
|
|
373
|
+
this.opts.thinkingEffort = clampThinkingLevel(model, this.opts.thinkingEffort);
|
|
374
|
+
this.opts.tools = toolSchemas(this.opts.toolNames, acceptsImages(model));
|
|
375
|
+
this.pushBanner();
|
|
376
|
+
this.render();
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Sets the running session's thinking level, clamped to the current model. */
|
|
380
|
+
private setThinking(level: ModelThinkingLevel): void {
|
|
381
|
+
this.opts.thinkingEffort = clampThinkingLevel(this.opts.model, level);
|
|
382
|
+
this.pushBanner();
|
|
383
|
+
this.render();
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Commits a prompt and returns a promise resolving with the next submitted line. */
|
|
387
|
+
private ask(prompt: AuthPrompt): Promise<string> {
|
|
388
|
+
if (this.pendingPrompt !== null) return Promise.reject(new Error("a prompt is already pending"));
|
|
389
|
+
this.separator = true;
|
|
390
|
+
this.push(prompt.message);
|
|
391
|
+
if (prompt.type === "select") {
|
|
392
|
+
for (let i = 0; i < prompt.options.length; i++) this.push(` ${i + 1}. ${prompt.options[i].label}`);
|
|
393
|
+
} else if (prompt.placeholder !== undefined) {
|
|
394
|
+
this.push(` (${prompt.placeholder})`);
|
|
395
|
+
}
|
|
396
|
+
this.separator = true;
|
|
397
|
+
this.editor.setMasked(prompt.type === "secret");
|
|
398
|
+
this.render();
|
|
399
|
+
|
|
400
|
+
return new Promise<string>((resolve, reject) => {
|
|
401
|
+
const flow = this.commandAbort?.signal;
|
|
402
|
+
const onAbort = (): void => {
|
|
403
|
+
settle(() => reject(new Error("cancelled")));
|
|
404
|
+
};
|
|
405
|
+
const settle = (fn: () => void): void => {
|
|
406
|
+
prompt.signal?.removeEventListener("abort", onAbort);
|
|
407
|
+
flow?.removeEventListener("abort", onAbort);
|
|
408
|
+
this.editor.setMasked(false);
|
|
409
|
+
this.pendingPrompt = null;
|
|
410
|
+
this.render();
|
|
411
|
+
fn();
|
|
412
|
+
};
|
|
413
|
+
if (prompt.signal?.aborted === true || flow?.aborted === true) {
|
|
414
|
+
onAbort();
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
prompt.signal?.addEventListener("abort", onAbort);
|
|
418
|
+
flow?.addEventListener("abort", onAbort);
|
|
419
|
+
this.pendingPrompt = {
|
|
420
|
+
prompt,
|
|
421
|
+
resolve: (value) => settle(() => resolve(value)),
|
|
422
|
+
reject: (error) => settle(() => reject(error)),
|
|
423
|
+
};
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** Turns the submitted line into the answer: a `select` maps to its option id. */
|
|
428
|
+
private answerPrompt(text: string): void {
|
|
429
|
+
const pending = this.pendingPrompt;
|
|
430
|
+
if (pending === null) return;
|
|
431
|
+
if (pending.prompt.type !== "select") {
|
|
432
|
+
pending.resolve(text);
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
const trimmed = text.trim();
|
|
436
|
+
const index = Number.parseInt(trimmed, 10);
|
|
437
|
+
const byIndex = String(index) === trimmed ? pending.prompt.options[index - 1] : undefined;
|
|
438
|
+
const chosen = byIndex ?? pending.prompt.options.find((option) => option.id === trimmed || option.label === trimmed);
|
|
439
|
+
if (chosen === undefined) pending.reject(new Error(`invalid selection: ${trimmed}`));
|
|
440
|
+
else pending.resolve(chosen.id);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
private notify(event: AuthEvent): void {
|
|
444
|
+
this.separator = true;
|
|
445
|
+
switch (event.type) {
|
|
446
|
+
case "info":
|
|
447
|
+
this.push(event.message);
|
|
448
|
+
for (const link of event.links ?? []) this.push(link.label === undefined ? link.url : `${link.label}: ${link.url}`);
|
|
449
|
+
break;
|
|
450
|
+
case "auth_url":
|
|
451
|
+
this.push(event.url);
|
|
452
|
+
if (event.instructions !== undefined) this.push(event.instructions);
|
|
453
|
+
break;
|
|
454
|
+
case "device_code":
|
|
455
|
+
this.push(event.verificationUri);
|
|
456
|
+
this.push(`code: ${event.userCode}`);
|
|
457
|
+
if (event.expiresInSeconds !== undefined) this.push(`expires in ${event.expiresInSeconds}s`);
|
|
458
|
+
break;
|
|
459
|
+
case "progress":
|
|
460
|
+
this.push(event.message);
|
|
461
|
+
break;
|
|
462
|
+
}
|
|
463
|
+
this.separator = true;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
private startTurn(): void {
|
|
467
|
+
this.active = true;
|
|
468
|
+
this.abort = new AbortController();
|
|
469
|
+
this.phase = "preparing";
|
|
470
|
+
this.detail = undefined;
|
|
471
|
+
this.turnStart = Date.now();
|
|
472
|
+
this.frame = 0;
|
|
473
|
+
this.spinner = setInterval(() => {
|
|
474
|
+
this.frame++;
|
|
475
|
+
this.render();
|
|
476
|
+
}, SPINNER_MS);
|
|
477
|
+
void this.runTurn();
|
|
478
|
+
this.render();
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
private async runTurn(): Promise<void> {
|
|
482
|
+
try {
|
|
483
|
+
await runAgentTurn({
|
|
484
|
+
...this.opts,
|
|
485
|
+
messages: this.messages,
|
|
486
|
+
signal: this.abort!.signal,
|
|
487
|
+
interaction: {
|
|
488
|
+
isPauseRequested: () => this.pauseRequested,
|
|
489
|
+
clearPause: () => {
|
|
490
|
+
this.pauseRequested = false;
|
|
491
|
+
},
|
|
492
|
+
requestSteering: () =>
|
|
493
|
+
new Promise<string>((resolve) => {
|
|
494
|
+
this.paused = true;
|
|
495
|
+
this.steeringResolve = resolve;
|
|
496
|
+
this.render();
|
|
497
|
+
}),
|
|
498
|
+
},
|
|
499
|
+
onEvent: (event) => this.handleAgentEvent(event),
|
|
500
|
+
});
|
|
501
|
+
} catch (error) {
|
|
502
|
+
this.separator = true;
|
|
503
|
+
this.push(red(`! ${(error as Error).message}`));
|
|
504
|
+
} finally {
|
|
505
|
+
this.active = false;
|
|
506
|
+
this.abort = null;
|
|
507
|
+
this.pauseRequested = false;
|
|
508
|
+
this.resolveSteering("");
|
|
509
|
+
this.paused = false;
|
|
510
|
+
if (this.spinner) clearInterval(this.spinner);
|
|
511
|
+
this.spinner = undefined;
|
|
512
|
+
this.render();
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
private cancel(): void {
|
|
517
|
+
this.resolveSteering("");
|
|
518
|
+
this.abort?.abort();
|
|
519
|
+
this.render();
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** Answers a pending steering prompt; "" leaves the turn ending. */
|
|
523
|
+
private resolveSteering(text: string): void {
|
|
524
|
+
const resolve = this.steeringResolve;
|
|
525
|
+
if (resolve === null) return;
|
|
526
|
+
this.steeringResolve = null;
|
|
527
|
+
this.paused = false;
|
|
528
|
+
resolve(text);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
private handleAgentEvent(event: AgentEvent): void {
|
|
532
|
+
if (this.closed) return;
|
|
533
|
+
switch (event.type) {
|
|
534
|
+
case "phase":
|
|
535
|
+
this.phase = event.phase;
|
|
536
|
+
this.detail = event.detail;
|
|
537
|
+
if (event.phase === "pausing") this.paused = true;
|
|
538
|
+
break;
|
|
539
|
+
case "text":
|
|
540
|
+
this.activity.reset();
|
|
541
|
+
this.streamed += event.delta;
|
|
542
|
+
this.commitLines(this.reply.feed(event.delta));
|
|
543
|
+
break;
|
|
544
|
+
case "reasoning":
|
|
545
|
+
if (this.reply.pending().length === 0) this.activity.feed(event.delta);
|
|
546
|
+
break;
|
|
547
|
+
case "toolCall":
|
|
548
|
+
// Hold the call line until its result arrives: a message may carry
|
|
549
|
+
// several calls, all announced before any of them runs, and each call
|
|
550
|
+
// line pairs with its own output in execution order.
|
|
551
|
+
this.writingTool = undefined;
|
|
552
|
+
this.commitLines(this.reply.flush());
|
|
553
|
+
this.activity.reset();
|
|
554
|
+
this.pendingCalls.push({ text: `${callHead(event.name)} ${callSummary(event.name, event.arguments)}` });
|
|
555
|
+
break;
|
|
556
|
+
case "toolCallStart":
|
|
557
|
+
this.writingTool = event.name;
|
|
558
|
+
break;
|
|
559
|
+
case "toolOutput":
|
|
560
|
+
this.activity.feed(event.chunk);
|
|
561
|
+
break;
|
|
562
|
+
case "message":
|
|
563
|
+
this.commitMessage(event.message);
|
|
564
|
+
break;
|
|
565
|
+
case "toolResult":
|
|
566
|
+
this.activity.reset();
|
|
567
|
+
this.commitToolResult(event.name, event.text, event.isError);
|
|
568
|
+
break;
|
|
569
|
+
case "error":
|
|
570
|
+
this.endTurn(red(`! ${event.message}`));
|
|
571
|
+
break;
|
|
572
|
+
case "cancelled":
|
|
573
|
+
this.endTurn(red("! cancelled"));
|
|
574
|
+
break;
|
|
575
|
+
case "complete":
|
|
576
|
+
this.endTurn(dim(`[complete · ${this.elapsed()}s]`));
|
|
577
|
+
break;
|
|
578
|
+
}
|
|
579
|
+
this.render();
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/** A terminal state: clears the status row and any in-flight preview, then commits. */
|
|
583
|
+
private endTurn(line: string): void {
|
|
584
|
+
this.phase = "idle";
|
|
585
|
+
this.detail = undefined;
|
|
586
|
+
this.writingTool = undefined;
|
|
587
|
+
this.paused = false;
|
|
588
|
+
this.activity.reset();
|
|
589
|
+
this.commitLines(this.reply.flush());
|
|
590
|
+
this.flushCalls();
|
|
591
|
+
this.separator = true;
|
|
592
|
+
this.push(line);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
private elapsed(): number {
|
|
596
|
+
return Math.max(0, Math.floor((Date.now() - this.turnStart) / 1000));
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
private commitUser(text: string): void {
|
|
600
|
+
this.separator = true;
|
|
601
|
+
this.commitLines(text.split("\n").map((line) => ({ text: line, style: blue })));
|
|
602
|
+
this.separator = true;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/** Commits a line the model emitted without streaming it, plus the in-flight tail. */
|
|
606
|
+
private commitMessage(message: AssistantMessage): void {
|
|
607
|
+
this.activity.reset();
|
|
608
|
+
this.commitLines(this.reply.flush());
|
|
609
|
+
const text = assistantText(message);
|
|
610
|
+
if (text.trim() !== "" && !this.streamed.includes(text)) {
|
|
611
|
+
this.commitLines([...this.reply.feed(text.trimEnd()), ...this.reply.flush()]);
|
|
612
|
+
}
|
|
613
|
+
this.streamed = "";
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
private commitToolResult(name: string, text: string, isError: boolean): void {
|
|
617
|
+
const call = this.pendingCalls.shift();
|
|
618
|
+
this.separator = true;
|
|
619
|
+
if (call !== undefined) this.commitLines([call]);
|
|
620
|
+
const width = Math.max(1, this.term.width - BODY_PREFIX.length);
|
|
621
|
+
const lines = resultLines(name, text, isError);
|
|
622
|
+
// Diffs are shown in full; other tool bodies stay elided.
|
|
623
|
+
const rows = name === "edit" ? renderRows(lines, width) : bodyRows(lines, width);
|
|
624
|
+
for (let i = 0; i < rows.length; i++) {
|
|
625
|
+
const prefix = isError && i === rows.length - 1 ? ERROR_CHROME : BODY_CHROME;
|
|
626
|
+
this.push(prefix + rows[i]);
|
|
627
|
+
}
|
|
628
|
+
this.separator = true;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/** Commits logical lines through the same wrap-and-style step tool bodies use. */
|
|
632
|
+
private commitLines(lines: BodyLine[]): void {
|
|
633
|
+
for (const row of renderRows(lines, Math.max(1, this.term.width))) this.push(row);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/** Commits any call line whose result never arrived, e.g. after a cancel. */
|
|
637
|
+
private flushCalls(): void {
|
|
638
|
+
for (const call of this.pendingCalls) {
|
|
639
|
+
this.separator = true;
|
|
640
|
+
this.commitLines([call]);
|
|
641
|
+
}
|
|
642
|
+
this.pendingCalls = [];
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* Appends one scrollback line. A block boundary owes exactly one blank line,
|
|
647
|
+
* and two blank lines never appear in a row.
|
|
648
|
+
*/
|
|
649
|
+
private push(line: string): void {
|
|
650
|
+
line = sanitize(line);
|
|
651
|
+
const blank = line === "" || (this.separator && this.wrote);
|
|
652
|
+
this.separator = false;
|
|
653
|
+
if (blank && this.wrote && !this.lastBlank) {
|
|
654
|
+
// A separator is a row the app occupies, so it is painted like any other:
|
|
655
|
+
// a bare newline would leave the host terminal showing through it.
|
|
656
|
+
this.scroll += `${paintRow("")}\r\n`;
|
|
657
|
+
this.lastBlank = true;
|
|
658
|
+
}
|
|
659
|
+
if (line !== "") {
|
|
660
|
+
this.scroll += `${paintRow(line)}\r\n`;
|
|
661
|
+
this.wrote = true;
|
|
662
|
+
this.lastBlank = false;
|
|
663
|
+
}
|
|
664
|
+
this.render();
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
private render(): void {
|
|
668
|
+
if (this.closed || this.renderScheduled) return;
|
|
669
|
+
this.renderScheduled = true;
|
|
670
|
+
setTimeout(() => {
|
|
671
|
+
this.renderScheduled = false;
|
|
672
|
+
this.draw();
|
|
673
|
+
}, 16);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* The one row between the in-flight lines and the editor: the phase while a
|
|
678
|
+
* turn is active, and the context readout always. The readout carries its own
|
|
679
|
+
* emphasis, so only the phase half is dimmed.
|
|
680
|
+
*/
|
|
681
|
+
private statusLine(): string {
|
|
682
|
+
const usage = contextUsageLine(
|
|
683
|
+
estimateContextTokens(this.messages, this.opts.systemPrompt, this.opts.tools),
|
|
684
|
+
this.opts.model,
|
|
685
|
+
);
|
|
686
|
+
if (this.paused || this.phase === "pausing") {
|
|
687
|
+
return `${dim("paused - type steering, Enter to submit")} · ${usage}`;
|
|
688
|
+
}
|
|
689
|
+
if (!this.active || this.phase === "idle") return usage;
|
|
690
|
+
const label =
|
|
691
|
+
this.phase === "preparing"
|
|
692
|
+
? "preparing"
|
|
693
|
+
: this.phase === "waitingModel"
|
|
694
|
+
? "waiting for provider"
|
|
695
|
+
: this.phase === "streaming"
|
|
696
|
+
? this.writingTool !== undefined
|
|
697
|
+
? `writing ${this.writingTool}`
|
|
698
|
+
: "streaming"
|
|
699
|
+
: `running ${this.detail ?? "tool"}`;
|
|
700
|
+
return `${dim(`${SPINNER[this.frame % SPINNER.length]} ${label} · ${this.elapsed()}s`)} · ${usage}`;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
private draw(): void {
|
|
704
|
+
if (this.closed) return;
|
|
705
|
+
const width = Math.max(1, this.term.width);
|
|
706
|
+
const height = Math.max(1, this.term.height - 1);
|
|
707
|
+
|
|
708
|
+
const status = wrapLine(this.statusLine(), width);
|
|
709
|
+
|
|
710
|
+
let inflight = this.reply.pending();
|
|
711
|
+
if (inflight.length === 0) inflight = this.activity.pending();
|
|
712
|
+
const rows = renderRows(inflight, width);
|
|
713
|
+
|
|
714
|
+
// Short on space: clip the in-flight rows first, then the editor viewport,
|
|
715
|
+
// which never drops below one row. The status rows are always kept.
|
|
716
|
+
const keep = Math.max(0, Math.min(rows.length, height - status.length - 1));
|
|
717
|
+
const body = rows.slice(rows.length - keep);
|
|
718
|
+
const editor = this.editor.render(width, Math.max(1, height - status.length - body.length));
|
|
719
|
+
|
|
720
|
+
const lines = [...body, ...status, ...editor.rows].map(paintRow);
|
|
721
|
+
let cursorRow = status.length + body.length + editor.cursorRow;
|
|
722
|
+
if (cursorRow >= lines.length) cursorRow = lines.length - 1;
|
|
723
|
+
|
|
724
|
+
// Scrollback and the live region are written as one frame. Clearing the
|
|
725
|
+
// region and restoring it in separate writes leaves it blank in between.
|
|
726
|
+
// A commit on a region filling every row above the last scrolls the
|
|
727
|
+
// terminal; the re-anchor newline goes after the scrollback, not before the
|
|
728
|
+
// erase, because `clear()` measures from the previous frame's cursor and
|
|
729
|
+
// moving the cursor down first lands the erase one row below the region.
|
|
730
|
+
const scroll = this.scroll;
|
|
731
|
+
this.scroll = "";
|
|
732
|
+
const reanchor = scroll !== "" && this.live.height >= this.term.height - 1;
|
|
733
|
+
this.term.write(this.live.draw(lines, cursorRow, editor.cursorCol, scroll + (reanchor ? "\r\n" : "")));
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
private exit(): void {
|
|
737
|
+
if (this.closed) return;
|
|
738
|
+
this.closed = true;
|
|
739
|
+
this.abort?.abort();
|
|
740
|
+
this.commandAbort?.abort();
|
|
741
|
+
if (this.spinner) clearInterval(this.spinner);
|
|
742
|
+
this.spinner = undefined;
|
|
743
|
+
// Draws are deferred, so anything pushed since the last frame is still here.
|
|
744
|
+
this.term.write(this.live.clear() + this.scroll);
|
|
745
|
+
this.scroll = "";
|
|
746
|
+
this.term.stop();
|
|
747
|
+
this.opts.session.close();
|
|
748
|
+
process.off("SIGINT", this.onSignal);
|
|
749
|
+
process.off("SIGTERM", this.onSignal);
|
|
750
|
+
this.resolveExit();
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
export async function runTui(opts: AgentOptions): Promise<void> {
|
|
755
|
+
const tui = new Tui(opts);
|
|
756
|
+
tui.start();
|
|
757
|
+
return tui.done;
|
|
758
|
+
}
|