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