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.
Files changed (49) hide show
  1. package/AGENTS.md +114 -0
  2. package/README.md +53 -66
  3. package/bin/mini-coder.ts +2 -0
  4. package/demo.gif +0 -0
  5. package/package.json +17 -20
  6. package/src/agent.ts +193 -272
  7. package/src/auth.ts +84 -0
  8. package/src/cli.ts +99 -0
  9. package/src/config.ts +179 -0
  10. package/src/prompt.ts +54 -207
  11. package/src/session.ts +124 -69
  12. package/src/tools/bash.ts +86 -0
  13. package/src/tools/common.ts +32 -0
  14. package/src/tools/edit.ts +41 -0
  15. package/src/tools/index.ts +47 -0
  16. package/src/tools/read.ts +64 -0
  17. package/src/tui/commands.ts +199 -0
  18. package/src/tui/complete.ts +85 -0
  19. package/src/tui/editor.ts +320 -0
  20. package/src/tui/highlight.ts +189 -0
  21. package/src/tui/stream.ts +142 -0
  22. package/src/tui/styles.ts +20 -0
  23. package/src/tui/term.ts +436 -0
  24. package/src/tui/theme.ts +120 -0
  25. package/src/tui/tui.ts +758 -0
  26. package/src/tui/usage.ts +67 -0
  27. package/tsconfig.json +8 -8
  28. package/bin/mc.ts +0 -11
  29. package/bun.lock +0 -350
  30. package/nono-mini-coder.json +0 -42
  31. package/src/args.ts +0 -252
  32. package/src/error-handling.test.ts +0 -163
  33. package/src/git.ts +0 -23
  34. package/src/headless.ts +0 -66
  35. package/src/index.ts +0 -43
  36. package/src/models.ts +0 -191
  37. package/src/oauth.ts +0 -147
  38. package/src/shared.ts +0 -119
  39. package/src/themes.ts +0 -234
  40. package/src/tool-bash.ts +0 -77
  41. package/src/tool-edit.ts +0 -121
  42. package/src/tool-read.ts +0 -100
  43. package/src/tui-components.ts +0 -127
  44. package/src/tui-conversation.ts +0 -218
  45. package/src/tui-editor.ts +0 -29
  46. package/src/tui-overlay.ts +0 -604
  47. package/src/tui.ts +0 -314
  48. package/src/types.ts +0 -194
  49. 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
+ }