ucode-agent 1.64.0 → 1.67.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.
Files changed (63) hide show
  1. package/LICENSE +662 -21
  2. package/LICENSE-DOCS +404 -0
  3. package/NOTICE +47 -0
  4. package/README.md +104 -13
  5. package/package.json +4 -3
  6. package/skills/LICENSE +19 -0
  7. package/src/core/attach.js +1 -0
  8. package/src/core/commands.js +1 -0
  9. package/src/core/context.js +1 -0
  10. package/src/core/csscheck.js +1 -0
  11. package/src/core/doctor.js +1 -0
  12. package/src/core/failure.js +1 -0
  13. package/src/core/genericcheck.js +1 -0
  14. package/src/core/git.js +1 -0
  15. package/src/core/headless.js +1 -0
  16. package/src/core/history.js +1 -0
  17. package/src/core/htmlcheck.js +1 -0
  18. package/src/core/jslogic.js +1 -0
  19. package/src/core/lessons.js +1 -0
  20. package/src/core/livelog.js +1 -0
  21. package/src/core/login.js +1 -0
  22. package/src/core/loop.js +4064 -3887
  23. package/src/core/mcp.js +1 -0
  24. package/src/core/mcpcli.js +1 -0
  25. package/src/core/openable.js +1 -0
  26. package/src/core/opener.js +1 -0
  27. package/src/core/provider.js +1 -0
  28. package/src/core/relink.js +1 -0
  29. package/src/core/scope.js +1 -0
  30. package/src/core/settings.js +1 -0
  31. package/src/core/skills.js +1 -0
  32. package/src/core/snapshot.js +1 -0
  33. package/src/core/stuck.js +1 -0
  34. package/src/core/terminal.js +257 -0
  35. package/src/core/tests.js +1 -0
  36. package/src/core/undo.js +1 -0
  37. package/src/core/updater.js +1 -0
  38. package/src/core/version.js +1 -0
  39. package/src/core/voice.js +1 -0
  40. package/src/core/window.js +1 -0
  41. package/src/tools/blocks.js +1 -0
  42. package/src/tools/browser.js +1 -0
  43. package/src/tools/cache.js +1 -0
  44. package/src/tools/deploy.js +1 -0
  45. package/src/tools/files.js +1 -0
  46. package/src/tools/fuzzy.js +1 -0
  47. package/src/tools/index.js +5 -0
  48. package/src/tools/rename.js +1 -0
  49. package/src/tools/scaffold.js +1 -0
  50. package/src/tools/search.js +1 -0
  51. package/src/tools/shared.js +2 -1
  52. package/src/tools/shell.js +1 -0
  53. package/src/tools/symbols.js +1 -0
  54. package/src/tools/types.js +1 -0
  55. package/src/tools/web.js +1 -0
  56. package/src/ui/activity.js +3 -2
  57. package/src/ui/markdown.js +1 -0
  58. package/src/ui/plain.js +4 -3
  59. package/src/ui/screen.js +2012 -1833
  60. package/src/ui/theme.js +151 -11
  61. package/templates/LICENSE +23 -0
  62. package/ucode.js +1 -0
  63. package/LICENSE-APACHE +0 -202
package/src/ui/screen.js CHANGED
@@ -1,1833 +1,2012 @@
1
- /**
2
- * screen.js — the full-screen interface.
3
- *
4
- * Used whenever stdout is a real terminal. Everything else — piped input, CI,
5
- * `echo ... | ucode` — falls back to plain.js, which is why both exist.
6
- *
7
- * The layout, top to bottom:
8
- *
9
- * ╭──────────────────────────────────────────────────╮
10
- * │ UCODE wordmark dir / keys │
11
- * ╰──────────────────────────────────────────────────╯
12
- *
13
- * the conversation, scrolling with the wheel or PgUp
14
- *
15
- * ╭──────────────────────────────────────────────────╮
16
- * │ › what you are typing, growing downward as it │
17
- * │ │
18
- * │ ◆ Build · Nemotron 3 Ultra 4% │
19
- * ╰──────────────────────────────────────────────────╯
20
- *
21
- * Both boxes are drawn rather than ruled off, because a box says "this is a
22
- * thing you use" where a horizontal rule only says "something changes here".
23
- *
24
- * The status sits inside the input box rather than under it: it describes the
25
- * thing you are typing into, so it belongs within the same border. It carries
26
- * three facts and no more — which mode is live, which model is answering, and
27
- * how full the window is. Anything else down there competes with what the user
28
- * is actually looking at, which is what they just typed.
29
- *
30
- * The transcript is a buffer of pre-rendered lines and the whole frame is
31
- * repainted whenever anything changes. At terminal sizes that is cheap, and
32
- * it rules out every partial-update bug at once.
33
- */
34
-
35
- import { appendFile } from 'node:fs/promises';
36
- import { homedir } from 'node:os';
37
- import path from 'node:path';
38
- import chalk from 'chalk';
39
- import {
40
- theme, blue, sky, deep, dim, edge, ADDED, REMOVED, BANNER, BANNER_WIDTH, SPINNER,
41
- boxTop, boxBottom, boxRow, visLen, padVis, clip, wrapAnsi,
42
- shortenPath, asLabel, ensureColour, planLine, bare, narration, narrationMark, groupKind, groupLabel, groupTarget, runLine, planRows, tidyReply, trimAnswer,
43
- bannerPaint, RAIL, modeChip, ADD_CHIP, micChip, asNarrationLine } from './theme.js';
44
- import { FRAME_MS, spinnerGlyph, formatDuration, doneLine, workingLine, bannerSweep, SWEEP_MS } from './activity.js';
45
- import { gitBranch } from '../core/git.js';
46
- import { renderer, render } from './markdown.js';
47
- import { badge } from './plain.js';
48
- import { VERSION } from '../core/version.js';
49
-
50
- /**
51
- * One line of narration, in the model's own words: "Reading screen.js".
52
- * Anything longer than this is prose, and prose belongs in the answer.
53
- */
54
- export const MAX_LABEL = 120;
55
-
56
- export function isLabel(text) {
57
- const t = String(text ?? '').trim();
58
- return t.length > 0 && t.length <= MAX_LABEL && !t.includes('\n');
59
- }
60
-
61
- export const COMMANDS = [
62
- '/help', '/model', '/models', '/session', '/sessions', '/resume',
63
- '/new', '/remember', '/skills', '/clear', '/search', '/copy', '/exit',
64
- '/stats', '/doctor', '/deploy', '/look', '/undo', '/mic',
65
- ];
66
-
67
- // ANSI ----------------------------------------------------------------------
68
- const ESC = '\x1b';
69
- const ALT_ON = `${ESC}[?1049h`;
70
- const ALT_OFF = `${ESC}[?1049l`;
71
-
72
- /**
73
- * Mouse setup, decided by measurement rather than by documentation.
74
- *
75
- * 1007 is alternate scroll: inside the alternate screen the terminal turns
76
- * wheel events into arrow keys. On Windows that is the only way a wheel ever
77
- * reaches the program, because ConPTY forwards no mouse input at all — a probe
78
- * that enabled every tracking mode received nothing from a scroll.
79
- *
80
- * And mouse tracking suppresses alternate scroll. So on Windows tracking is
81
- * deliberately not requested: it delivers nothing there, and asking for it
82
- * would cost the wheel. Elsewhere tracking works, so the mode chip is
83
- * clickable on those platforms.
84
- */
85
- const TRACK = process.platform === 'win32'
86
- ? '' : `${ESC}[?1000h${ESC}[?1002h${ESC}[?1015h${ESC}[?1006h`;
87
- const UNTRACK = process.platform === 'win32'
88
- ? '' : `${ESC}[?1006l${ESC}[?1015l${ESC}[?1002l${ESC}[?1000l`;
89
-
90
- const PASTE_ON = `${ESC}[?2004h`;
91
- const PASTE_OFF = `${ESC}[?2004l`;
92
- const MOUSE_ON = `${ESC}[?1007h${TRACK}`;
93
- const MOUSE_OFF = `${UNTRACK}${ESC}[?1007l`;
94
- const HIDE = `${ESC}[?25l`;
95
- const SHOW = `${ESC}[?25h`;
96
- const HOME = `${ESC}[H`;
97
- const CLEAR_LINE = `${ESC}[K`;
98
- /** Written out rather than inline, so no edit can turn it into a real break. */
99
- const NEWLINE = String.fromCharCode(10);
100
- const at = (row, col) => `${ESC}[${row};${col}H`;
101
- const title = (t) => `${ESC}]0;${t}\x07`;
102
-
103
- /**
104
- * Every paint is wrapped in these. Autowrap off means a row that is one cell
105
- * wider than we counted loses its last cell instead of wrapping onto the next
106
- * row and scrolling the whole frame up — that scroll was the glitch. The
107
- * synchronized-update pair makes terminals that support it show the frame in
108
- * one go; the rest ignore it.
109
- */
110
- const PAINT_BEGIN = `${ESC}[?2026h${ESC}[?7l`;
111
- const PAINT_END = `${ESC}[?7h${ESC}[?2026l`;
112
-
113
- /**
114
- * Text as it may be drawn: colour codes kept, everything that moves the cursor
115
- * gone. A carriage return from a CRLF file sent the padding back over the
116
- * line, a tab took eight cells while it was counted as one, and a clear-screen
117
- * from a tool's output wiped the frame mid-paint.
118
- *
119
- * The SGR colour codes (\x1b[..m) are matched first and kept. An earlier
120
- * version only excluded them from the CSI branch, so the bare-\x1b fallback
121
- * stripped the ESC off every colour code and left "[36m" littered across
122
- * coloured lines — visible only in a real terminal, never in tests, which is
123
- * why the glitch survived the suite.
124
- */
125
- function printable(text) {
126
- return String(text)
127
- .replace(/\t/g, ' ')
128
- .replace(/\x1b\[[0-9;]*m|(\x1b(?:\[[0-?]*[ -\/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)?|[()#][0-9A-Za-z]|[\x30-\x7e])?|[\x00-\x09\x0b-\x1a\x1c-\x1f\x7f])/g, (m, bad) => (bad ? '' : m));
129
- }
130
-
131
- /**
132
- * Fixed rows below the header: the gap under it, the gap above the input box,
133
- * the input box's two borders, the blank row inside it, and the status row.
134
- */
135
- const CHROME_BELOW = 6;
136
-
137
- /** How long one sentence of reasoning holds the line before the next takes it. */
138
- const THOUGHT_HOLD_MS = 1100;
139
-
140
- /** Reasoning that is about the request rather than about the work. */
141
- const RESTATEMENT = /^(?:the user|they|so the user|user)|^(?:i (?:need|should|will need) to (?:understand|figure|work out|check what))|^(?:let me (?:understand|re-?read|look at the (?:request|prompt)))|^(?:the (?:request|prompt|task) (?:is|asks|says))/i;
142
-
143
- /** The window size asked of macOS Terminal when it opens smaller than this. */
144
- const MAC_COLS = 120;
145
- const MAC_ROWS = 34;
146
-
147
- /** The wordmark only earns its place with room for the facts column beside it. */
148
- const WORDMARK_NEEDS = BANNER_WIDTH + 30;
149
-
150
- /** What the empty input box says before anything is typed. */
151
- const PLACEHOLDER = 'Ask anything…';
152
-
153
- /**
154
- * What it says instead while the agent has the turn.
155
- *
156
- * The status row beside it already says "esc to stop", so this carries the
157
- * half nothing else on screen does: that the box is still live, and a line
158
- * typed into it now is kept and sent when the turn ends rather than lost. The
159
- * short form is for a terminal too narrow to hold the sentence, where a cut
160
- * one would read as a glitch.
161
- */
162
- const WORKING_HINT = 'Working… type to queue your next message';
163
- const WORKING_HINT_SHORT = 'Working…';
164
- const WORKING_HINT_NEEDS = WORKING_HINT.length + 8;
165
-
166
- /**
167
- * Three things to try, under the box, on a screen with nothing on it yet.
168
- *
169
- * A wordmark over an empty field is handsome and tells you nothing you can act
170
- * on — the first thing a new user has to do is guess what this accepts. Three
171
- * greyed lines answer that in one glance, and they say something about the
172
- * range of it too: build something new, understand something that exists,
173
- * change something small. They are dim, and they are gone the moment anything
174
- * is on the screen.
175
- */
176
- const SUGGESTIONS = [
177
- 'build me a landing page for a coffee shop',
178
- 'explain what this project does and how it fits together',
179
- 'add a dark mode toggle that remembers the choice',
180
- ];
181
-
182
- /** The width of the `try` label, so the three lines share one left edge. */
183
- const SUGGEST_LABEL = 6;
184
-
185
- /** Rows the suggestions occupy under the box: one of air, then the three. */
186
- const SUGGEST_ROWS = SUGGESTIONS.length + 1;
187
-
188
- export class Screen {
189
- constructor({ cwd, input = process.stdin, output = process.stdout } = {}) {
190
- this.cwd = cwd;
191
- this.input = input;
192
- this.output = output;
193
-
194
- this.lines = []; // the rendered transcript
195
- this.scroll = 0; // rows scrolled up from the bottom
196
- this.buffer = ''; // what is being typed
197
- this.cursor = 0;
198
- this.history = [];
199
- this.historyIndex = -1;
200
-
201
- this.status = { busy: false, text: '', frame: 0, since: 0 };
202
- this.facts = {};
203
- this.model = '';
204
-
205
- this.waiters = [];
206
- this.queue = [];
207
- this.closed = false;
208
-
209
- // 'build' may edit and run; 'plan' is read-only. Ctrl+B swaps them, and
210
- // the chip is clickable wherever the terminal forwards clicks.
211
- this.mode = 'build';
212
- this.chipTo = 0;
213
- // The "+ file" button: its columns on the status row, and the files it has
214
- // added so far, which go with the next message. Ctrl+O does the same.
215
- this.plusFrom = 0;
216
- this.plusTo = 0;
217
- this.attachments = [];
218
- this.onAttach = null;
219
- // The mic button: null when idle, else 'starting' | 'listening' | 'writing'.
220
- // Ctrl+T starts and stops it; while it listens, enter stops and sends.
221
- this.micFrom = 0;
222
- this.micTo = 0;
223
- this.listening = null;
224
- this.onMic = null;
225
- this.onInterrupt = null;
226
- this.onModeChange = null;
227
- this.spinTimer = null;
228
- this.paintedBusy = false; // whose turn the frame on screen was drawn for
229
- this.paintedLines = null; // transcript length at the last paint; growth since then belongs underneath a scrolled-back view
230
- this.activity = null; // the turn in flight: when it began, how many steps
231
- this.tick = 0; // animation frames painted, for the spinner
232
- this.intro = 0; // when the launch sweep began, 0 once it is over
233
- this.introTimer = null;
234
- this.pendingPrompt = null;
235
- this.lastPrompt = ''; // the last thing the user said, for the echo check
236
- this.facts.branch = gitBranch(cwd);
237
-
238
- this.cols = output.columns || 80;
239
- this.rows = output.rows || 24;
240
- this.md = renderer(this.width());
241
- }
242
-
243
- // -- lifecycle -----------------------------------------------------------
244
-
245
- async start() {
246
- ensureColour(this.output);
247
- this.output.write(ALT_ON + MOUSE_ON + PASTE_ON + HIDE + title(`ucode — ${path.basename(this.cwd)}`));
248
- this.input.setRawMode?.(true);
249
- this.input.resume();
250
- this.input.setEncoding('utf8');
251
- this.input.on('data', (chunk) => this.onData(chunk));
252
-
253
- this.onResize = () => {
254
- this.cols = this.output.columns || 80;
255
- this.rows = this.output.rows || 24;
256
- this.md = renderer(this.width());
257
- this.render();
258
- };
259
- this.output.on('resize', this.onResize);
260
-
261
- // macOS Terminal opens at 80×24, and with the header and the input box
262
- // that leaves a letterbox for the conversation. Ask for more room — never
263
- // less — and give the window back its own size on the way out.
264
- if (process.env.TERM_PROGRAM === 'Apple_Terminal' && !this.grownFrom
265
- && (this.cols < MAC_COLS || this.rows < MAC_ROWS)) {
266
- this.grownFrom = [this.rows, this.cols];
267
- this.output.write(`${ESC}[8;${Math.max(this.rows, MAC_ROWS)};${Math.max(this.cols, MAC_COLS)}t`);
268
- }
269
-
270
- this.render();
271
- this.startIntro();
272
- }
273
-
274
- /**
275
- * The light that crosses the wordmark once, at launch.
276
- *
277
- * Half a second, on the start screen only, and abandoned the instant there is
278
- * anything else to look at. Below 256 colours there are no shades to fade
279
- * through, so it is skipped rather than flickered.
280
- */
281
- startIntro() {
282
- if (!this.output.isTTY || chalk.level < 2 || !this.welcoming()) return;
283
- this.intro = Date.now();
284
- this.introTimer = setInterval(() => {
285
- if (this.closed || !this.welcoming() || Date.now() - this.intro >= SWEEP_MS) this.stopIntro();
286
- else this.render();
287
- }, FRAME_MS);
288
- this.introTimer.unref?.();
289
- }
290
-
291
- stopIntro() {
292
- if (this.introTimer) clearInterval(this.introTimer);
293
- this.introTimer = null;
294
- if (!this.intro) return;
295
- this.intro = 0;
296
- if (!this.closed) this.render();
297
- }
298
-
299
- stop() {
300
- this.activity = null;
301
- if (this.introTimer) clearInterval(this.introTimer);
302
- this.introTimer = null;
303
- this.intro = 0;
304
- this.stopSpinner();
305
- this.stopTimer();
306
- this.output.off?.('resize', this.onResize);
307
- this.input.setRawMode?.(false);
308
- this.input.pause();
309
- this.output.write(PASTE_OFF + MOUSE_OFF + ALT_OFF + SHOW);
310
- if (this.grownFrom) {
311
- this.output.write(`${ESC}[8;${this.grownFrom[0]};${this.grownFrom[1]}t`);
312
- this.grownFrom = null;
313
- }
314
- }
315
-
316
- close() {
317
- if (this.closed) return;
318
- this.closed = true;
319
- this.stop();
320
- while (this.waiters.length) this.waiters.shift()(null);
321
- }
322
-
323
- /**
324
- * Every column the terminal has.
325
- *
326
- * Capped at 100 for a release, and it was wrong: on a wide monitor the frame
327
- * sat in the left half of the screen with the rest of it empty, which reads
328
- * as the window having failed to open rather than as a measured column. The
329
- * interface fills what it is given. Prose inside it is still held to 100 by
330
- * the markdown renderer, which is where that limit belongs — the boxes are
331
- * the shape of the window, not of a paragraph.
332
- */
333
- width() {
334
- return Math.max(30, this.cols);
335
- }
336
-
337
- /** Usable width inside a box: two borders and a space of padding each side. */
338
- inner() {
339
- return Math.max(8, this.width() - 4);
340
- }
341
-
342
- // -- transcript ----------------------------------------------------------
343
-
344
- /**
345
- * Append without painting.
346
- *
347
- * Anything replacing a region of the transcript has to build the whole
348
- * region and then render once. Painting between the delete and the re-add
349
- * puts a frame on screen with the text missing, and at streaming speed that
350
- * reads as flicker.
351
- */
352
- add(text = '') {
353
- const width = this.width();
354
- for (const raw of printable(text).split('\n')) {
355
- if (visLen(raw) <= width) this.lines.push(raw);
356
- else for (const wrapped of wrapAnsi(raw, width)) this.lines.push(wrapped);
357
- }
358
- }
359
-
360
- push(text = '') {
361
- this.add(text);
362
- this.soon();
363
- }
364
-
365
- /**
366
- * Collapse a burst of pushes into one frame.
367
- *
368
- * Printing a list one line at a time repaints the screen per line — a model
369
- * list of fifty entries drew a hundred frames back to back, which is visible
370
- * as a cascade. A microtask runs before any I/O, so everything pushed in one
371
- * synchronous stretch becomes a single render, while a push after an await
372
- * still paints immediately.
373
- */
374
- soon() {
375
- if (this.queued) return;
376
- this.queued = true;
377
- queueMicrotask(() => {
378
- this.queued = false;
379
- this.render();
380
- });
381
- }
382
-
383
- write(text = '') { this.push(text); }
384
- blank() { this.push(''); }
385
- note(text) { this.push(dim(` ${text}`)); }
386
-
387
- clearScreen() {
388
- this.lines = [];
389
- this.scroll = 0;
390
- this.paintedLines = null;
391
- this.render();
392
- }
393
-
394
- /**
395
- * The reply, at full strength, with room either side.
396
- *
397
- * `closing` says this is the last thing the turn will say. It is then also
398
- * the last thing left on screen, and what the whole session reads like
399
- * afterwards, so it is cut to eight lines — see trimAnswer.
400
- */
401
- assistant(text, { closing = false, replay = false, silent = false } = {}) {
402
- if (!text?.trim()) return;
403
- // A replayed answer goes back exactly as it was first shown: no tidying,
404
- // no trimming, no re-read of the request. Tidy-up exists to keep a live
405
- // answer short; on a resumed one it rewrites history, which is why a
406
- // resumed session read like only fragments had survived.
407
- const tidy = tidyReply(text, 4, this.lastPrompt);
408
- const body = replay ? String(text) : (closing ? trimAnswer(tidy) : tidy);
409
- if (!body.trim()) return;
410
- this.endRun();
411
- this.add('');
412
-
413
- // No bullet, and no indent. A mark on every reply made the answer read as
414
- // one more step in the list above it, and on "Hey! How can I help you
415
- // today?" it was a bullet on a greeting. The answer already wins the page
416
- // by being the only thing on it at full strength; it does not also need to
417
- // be labelled.
418
- this.add(render(this.md, body));
419
-
420
- this.add('');
421
- if (!silent) this.render();
422
- }
423
-
424
- /**
425
- * Something the user said, marked down its left edge in the same blue as the
426
- * box it was typed into.
427
- *
428
- * A long session is mostly the agent's output — tool calls, diffs, answers.
429
- * Your own messages are the landmarks you scroll back looking for, so they
430
- * get a mark of their own. It was a full box, and forty turns of that is a
431
- * ladder of rules across the page: two horizontal lines per message, each as
432
- * loud as the input box, none of them saying anything the rail does not.
433
- */
434
- userMessage(text, { silent = false } = {}) {
435
- // Kept so the reply can be checked against it: an answer that opens by
436
- // saying the request back is repeating the line directly above it.
437
- this.lastPrompt = String(text ?? '');
438
- this.scroll = 0; // sending something is the one thing that jumps to the bottom
439
- const room = Math.max(8, this.width() - 2); // the rail and the space after it
440
-
441
- const rows = [];
442
- for (const paragraph of String(text).replace(/\r/g, '').split('\n')) {
443
- for (const line of wrapAnsi(paragraph, room)) rows.push(line);
444
- }
445
-
446
- // Room between what you asked for and what came back: without it the reply
447
- // starts against your own message and the two read as one block of text.
448
- this.add('');
449
- for (const row of rows) this.add(`${blue(RAIL)} ${chalk.white(row)}`);
450
- this.add('');
451
- this.add('');
452
- if (!silent) this.render();
453
- }
454
-
455
- /**
456
- * A tool call, as it happens: "● Listing src".
457
- *
458
- * This lives in the transcript rather than only on the status line. The
459
- * status line overwrites itself and is empty by the end of the turn, so work
460
- * announced only there scrolls past unseen — and the diff underneath ends up
461
- * with nothing above it explaining where it came from.
462
- */
463
- toolCall(label) {
464
- // U+25CF, not U+23FA: the latter carries emoji presentation, which Windows
465
- // Terminal draws as a white circle on a blue tile.
466
- // Trimmed here rather than at paint time: runLine hands back a coloured
467
- // string, and asLabel's regexes run off the end of one of those into the
468
- // escape sequence instead of the last word.
469
- const clean = asLabel(label);
470
- const kind = groupKind(clean);
471
- // One line per kind of work for as long as the model is working on one
472
- // thing. Reading, writing and reading again used to draw six lines that
473
- // said three things; now the "Reading files" line it already has is the
474
- // one that counts up, wherever it sits.
475
- const run = (this.segment ??= new Map()).get(kind);
476
-
477
- if (run && this.lines[run.at] !== undefined) {
478
- run.count++;
479
- run.label = clean;
480
- run.targets.push(groupTarget(clean));
481
- this.run = run;
482
- this.paintRun();
483
- } else {
484
- const fresh = {
485
- kind, count: 1, at: 0, label: clean,
486
- targets: [groupTarget(clean)], added: 0, removed: 0,
487
- };
488
- this.push(`${narrationMark(kind)} ${runLine(fresh)}`);
489
- fresh.at = this.lines.length - 1;
490
- this.run = fresh;
491
- this.segment.set(kind, fresh);
492
- }
493
- this.updateSpinner(label);
494
- }
495
-
496
- /**
497
- * The model speaking — or a plan, or a failure — ends the segment.
498
- *
499
- * Up to that point a kind of work keeps one line and counts up on it. After
500
- * it, the next read is a new piece of work and deserves its own line, which
501
- * is what makes the transcript read as a sequence of things done rather
502
- * than a set of running totals.
503
- */
504
- /**
505
- * Stop adding to the current run, but keep the lines already on screen.
506
- *
507
- * A kind of work gets one line for the whole turn. Starting a fresh set
508
- * whenever the model spoke meant "Creating Tide from the HTML starter" five
509
- * times down the page and "Reading files" four, each saying the same thing
510
- * about a different moment. One line that counts up says all of it and
511
- * costs one row.
512
- */
513
- endRun() { this.run = null; }
514
-
515
- /** A new turn starts with a clean page's worth of lines. */
516
- newSegment() { this.run = null; this.segment = new Map(); }
517
-
518
- /** Redraw the run's single line from what it has accumulated. */
519
- /**
520
- * Redraw the run's single line from what it has accumulated.
521
- *
522
- * While its step is still running the text shimmers, which is the only
523
- * thing on screen saying "this is happening now" once the per-step result
524
- * lines are gone. It settles to plain dim the moment the step finishes, so
525
- * the finished ones above stay quiet.
526
- */
527
- paintRun() {
528
- if (!this.run) return;
529
- this.lines[this.run.at] = `${narrationMark(this.run.kind)} ${runLine(this.run)}`;
530
- this.render();
531
- }
532
-
533
- /**
534
- * A change, as its two numbers.
535
- *
536
- * The diff itself used to go into the transcript. A 539-line file printed
537
- * there buries the answer under a copy of something already on disk, so
538
- * what is kept is the shape of the change: how much arrived, how much left.
539
- */
540
- /**
541
- * A one-word verdict on the step that just ran — "clean", "3 to fix".
542
- *
543
- * Same rule as diffStat: it goes on the line that named the step. Nothing
544
- * goes underneath a bullet, and a result line per tool doubles the height of
545
- * the transcript to say "ok".
546
- */
547
- runStat(text) {
548
- if (!this.run || !text) return;
549
- this.run.stat = text;
550
- this.paintRun();
551
- }
552
-
553
- diffStat({ added = 0, removed = 0 } = {}) {
554
- if (!this.run) return;
555
- this.run.added += added;
556
- this.run.removed += removed;
557
- this.paintRun();
558
- }
559
-
560
- /** The checklist, when the model updates it. One line, wrapped if it must. */
561
- plan(items) {
562
- const rows = planRows(items);
563
- if (!rows.length) return;
564
- this.endRun(); // a plan is not another step of whatever came before
565
- for (const row of rows) this.push(row);
566
- }
567
-
568
- /**
569
- * What came of a step.
570
- *
571
- * Nothing goes underneath the bullet any more: a line of its own for every
572
- * result doubles the height of the transcript to say "ok". The bullet
573
- * already names the step, and a change adds its numbers to that same line.
574
- * Only a failure earns a line of its own.
575
- */
576
- toolResult() {}
577
-
578
- /**
579
- * Something went wrong, and the model is the one who can do anything about it.
580
- *
581
- * A red line of machinery — a failed edit, a command that exited non-zero —
582
- * reads as the tool being broken, when almost always it is a step the model
583
- * corrects on its own a second later. It goes to the model; the screen stays
584
- * for what is being built. Whatever is genuinely unrecoverable surfaces as
585
- * the model saying so in words, which is the form worth reading.
586
- */
587
- toolFailed() {
588
- this.endRun();
589
- }
590
-
591
- /**
592
- * The change itself, under the result.
593
- *
594
- * A line-number gutter, then the sign and the code tinted right across the
595
- * row. The numbers are the point: a diff you cannot navigate from is a
596
- * picture of a change rather than a record of one.
597
- */
598
- diff(lines) {
599
- const gutter = 6;
600
- // Two spaces of indent, the gutter, one space, then the tint fills the
601
- // rest. One column over and every row wraps, splitting the whole diff.
602
- const room = Math.max(12, this.width() - gutter - 3);
603
-
604
- for (const line of lines) {
605
- // A file heading in a multi-file write.
606
- if (line.startsWith('~')) {
607
- this.add(` ${dim(' '.repeat(gutter))} ${sky(line.slice(1))}`);
608
- continue;
609
- }
610
-
611
- const added = line.startsWith('+');
612
- const rest = line.slice(1);
613
- // Tools emit "<line>| <text>". A row with no number is the "12 more
614
- // lines" note, which is not part of the change, so it stays dim.
615
- const parsed = /^(\d+)\|\s?([\s\S]*)$/.exec(rest);
616
- if (!parsed) {
617
- this.add(` ${dim(' '.repeat(gutter))} ${dim(rest)}`);
618
- continue;
619
- }
620
-
621
- const [, number, body] = parsed;
622
- const tint = added ? ADDED : REMOVED;
623
- this.add(
624
- ` ${dim(number.padStart(gutter))} ` +
625
- // Tabs would leave the tint ending short of the row, so they widen.
626
- tint(padVis(clip(`${added ? '+' : '-'} ${body.replace(/\t/g, ' ')}`, room), room))
627
- );
628
- }
629
- this.render(); // a sixteen-line diff is one frame, not sixteen
630
- }
631
-
632
- /** Captured output under a command, dimmed so it reads as evidence. */
633
- commandOutput(lines) {
634
- for (const line of lines) this.add(` ${dim(line)}`);
635
- this.render();
636
- }
637
-
638
- /**
639
- * A running command's output, live — on the status line and nowhere else.
640
- *
641
- * Only the newest line, gone as soon as the next arrives. Appending each one
642
- * instead would mean a test run leaving sixty lines of "ok" in the
643
- * conversation permanently, which is noise the moment it scrolls. What
644
- * survives a command is decided when it ends: nothing if it worked, the tail
645
- * if it did not.
646
- */
647
- progress(lines) {
648
- const last = lines[lines.length - 1]?.trim();
649
- if (last) this.updateSpinner(last);
650
- }
651
-
652
- /**
653
- * The model's own account of the step it is taking, before it takes it.
654
- *
655
- * Not called status(): `this.status` holds the spinner state, and a method
656
- * of the same name would be shadowed by it on every instance.
657
- */
658
- /**
659
- * What the model says beside a tool call — "I will build the app now" — is
660
- * not an answer, and printing it made a build read like a running commentary
661
- * nobody asked for. It moves the spinner, and nothing stays on screen: the
662
- * only prose in the transcript is the answer at the end.
663
- */
664
- narrate(text) {
665
- const line = asLabel(text);
666
- if (line) this.updateSpinner(line);
667
- }
668
-
669
- // -- streaming -----------------------------------------------------------
670
- // A reply is collected while it arrives and shown once it is complete — and
671
- // only if it turns out to be the answer. Painted as it streamed, every
672
- // "Let me build this" flashed up and vanished again when a tool call
673
- // followed it. The spinner keeps running meanwhile.
674
-
675
- streamBegin() {
676
- this.streamAt = this.lines.length;
677
- this.streamBuf = '';
678
- }
679
-
680
- streamDelta(delta) {
681
- if (this.streamAt === undefined) this.streamBegin();
682
- this.streamBuf += delta;
683
- }
684
-
685
- /**
686
- * Finish a streamed reply.
687
- *
688
- * `asLabel` says the text turned out to be narration ahead of a tool call
689
- * rather than an answer, in which case one short line folds down into the
690
- * status line it was always meant to be.
691
- */
692
- streamEnd({ asNarration = false, closing = false } = {}) {
693
- if (this.streamAt === undefined) return '';
694
- const text = this.streamBuf;
695
- this.lines.length = this.streamAt;
696
- this.streamAt = undefined;
697
- this.streamBuf = '';
698
-
699
- // Mid-build the model's prose is commentary on work that has not happened
700
- // yet, so it is condensed to one line rather than printed whole — long or
701
- // short, it never becomes a block of text above the file being written.
702
- if (asNarration) {
703
- const line = asNarrationLine(text);
704
- if (line) this.narrate(line);
705
- else this.render();
706
- }
707
- else if (text.trim()) { this.stopSpinner(); this.assistant(text, { closing }); }
708
- else this.render();
709
- return text;
710
- }
711
-
712
- // -- thinking ------------------------------------------------------------
713
- // A reasoning model does all its working before it says anything. None of it
714
- // is printed: it is long, repetitive, and guesses drawn from it read worse
715
- // than silence. The spinner counts the seconds so the wait is visibly alive,
716
- // and the transcript gets one line afterwards saying how long it took.
717
-
718
- /**
719
- * The model's reasoning does not go on screen.
720
- *
721
- * It was surfaced here to fill the wait before the first tool call, and what
722
- * it actually filled it with was the model talking to itself: "I need to
723
- * build this", "The user wants a tasks app". Nobody needs their own request
724
- * read back to them, and half-formed working-out is not something to publish.
725
- * What the model *says* is its reply, and that is the only thing shown.
726
- */
727
- thinkingDelta() {}
728
-
729
- thinkingEnd() {}
730
-
731
- error(err, { debug = false } = {}) {
732
- const known = err && typeof err === 'object' && err.attempted;
733
- this.push('');
734
- if (known) {
735
- this.push(`${theme.error('✗')} ${chalk.white(`Failed while ${err.attempted}.`)}`);
736
- this.push(` ${err.failed}`);
737
- if (err.fix) this.push(` ${blue('→')} ${err.fix}`);
738
- if (err.kind) this.push(dim(` (${err.kind})`));
739
- } else {
740
- this.push(`${theme.error('✗')} ${chalk.white('Something broke inside ucode.')}`);
741
- this.push(` ${err?.message ?? String(err)}`);
742
- this.push(` ${blue('→')} That is a bug in ucode rather than in your project. Re-run with --debug.`);
743
- }
744
- if (debug) {
745
- const stack = (known && err.cause?.stack) || err?.stack;
746
- if (stack) this.push(dim(stack));
747
- }
748
- this.push('');
749
- }
750
-
751
- // -- header --------------------------------------------------------------
752
-
753
- setFacts(facts) {
754
- this.facts = { ...this.facts, ...facts };
755
- if (facts.model) this.model = facts.model;
756
- this.render();
757
- }
758
-
759
- /** Same shape as the plain UI's header(), so the loop needs no branch. */
760
- header({ cwd, model, used, limit, title: sessionTitle }) {
761
- if (cwd && cwd !== this.facts.cwd) this.facts.branch = gitBranch(cwd);
762
- this.setFacts({
763
- cwd,
764
- model,
765
- title: sessionTitle,
766
- percent: limit > 0 ? Math.min(100, Math.round((used / limit) * 100)) : 0,
767
- });
768
- }
769
-
770
- /**
771
- * How many rows the header box occupies.
772
- *
773
- * The frame has to be exactly as tall as the terminal or every row below the
774
- * shortfall is off by that much — including the one the caret is parked on.
775
- * So this is derived, never assumed.
776
- */
777
- headerHeight() {
778
- return this.width() >= WORDMARK_NEEDS ? BANNER.length + 2 : 5;
779
- }
780
-
781
- headerLines() {
782
- const width = this.width();
783
- const inner = width - 2; // between the borders
784
-
785
- if (width < WORDMARK_NEEDS) {
786
- // Too narrow for the wordmark: stack it rather than wrap it into noise.
787
- const rows = [
788
- ` ${blue.bold('U C O D E')} ${dim('terminal coding agent')}`,
789
- ` ${dim('dir'.padEnd(8))}${chalk.white(clip(shortenPath(this.facts.cwd ?? this.cwd, inner - 12), inner - 12))}`,
790
- ];
791
- return [boxTop(width), ...rows.map((r) => boxRow(r, width)), boxBottom(width)];
792
- }
793
-
794
- // Two spaces of padding, the wordmark, a gap, then the facts column.
795
- //
796
- // Only what you cannot work out by looking, and never what the status row
797
- // already carries: the model and how full the window is live down there,
798
- // next to each other, and a second copy up here would be a second place to
799
- // keep in sync for no reader who needed it. What is left is where you are,
800
- // which branch that is on, which build you are running, and the two keys
801
- // worth knowing before you have typed anything.
802
- //
803
- // Every row has something on it. Three blank rows and a credit floating
804
- // under them read as a column that was meant to be filled and was not.
805
- const room = Math.max(8, inner - BANNER_WIDTH - 6);
806
- const value = (v) => clip(String(v), Math.max(4, room - 10));
807
- const branch = this.facts.branch;
808
-
809
- // The facts run together from the top with nothing between them, the credit
810
- // sits on the last row, and whatever is left over is the gap between the
811
- // two. Holding a row empty in the middle of the list — which is what a
812
- // fixed six-row layout did when a fact was missing — reads as a line that
813
- // failed to draw rather than as spacing.
814
- const facts = [
815
- ['dir', value(shortenPath(this.facts.cwd ?? this.cwd, room - 10))],
816
- branch && ['branch', value(branch)],
817
- VERSION && ['version', value(this.facts.update ? `${VERSION} → ${this.facts.update} next start` : VERSION)],
818
- ['keys', value('/help · ctrl+t mic · ctrl+o file · ctrl+b plan · esc stops')],
819
- ].filter(Boolean).slice(0, BANNER.length - 1);
820
-
821
- while (facts.length < BANNER.length - 1) facts.push(['', '']);
822
- facts.push(['', 'made with ❤ by om dixit']);
823
-
824
- const rows = BANNER.map((art, i) => {
825
- const [label, text] = facts[i] ?? ['', ''];
826
- const right = label
827
- ? `${dim(label.padEnd(10))}${chalk.white(text)}`
828
- : (text ? dim(text) : '');
829
- return ` ${bannerPaint(i)(art)} ${right}`;
830
- });
831
-
832
- // The frame is lit the way the wordmark inside it is: brightest along the
833
- // top rule, settling to deep at the bottom. Eight rows of box for six of
834
- // banner, so the borders take the two ends of the same ramp and the box
835
- // reads as one object with a light above it rather than as a rule someone
836
- // drew around a picture.
837
- const depth = BANNER.length + 2;
838
- return [
839
- boxTop(width, bannerPaint(0, depth)),
840
- ...rows.map((r, i) => boxRow(r, width, bannerPaint(i + 1, depth))),
841
- boxBottom(width, bannerPaint(depth - 1, depth)),
842
- ];
843
- }
844
-
845
- // -- input box -----------------------------------------------------------
846
-
847
- /** The typed line, wrapped to the inside of a box `width` characters across. */
848
- /**
849
- * The typed text, laid out as rows inside the box.
850
- *
851
- * A line break in the buffer is a row of its own before any wrapping is
852
- * considered. Slicing the text into fixed widths without looking for one
853
- * put the newline into the frame instead, and the terminal obeyed it — the
854
- * pasted text walked out of the box and over the transcript beside it.
855
- *
856
- * `starts` records where each row begins in the text, so the caret can be
857
- * placed by looking up rather than by counting characters a second way and
858
- * hoping the two agree.
859
- */
860
- inputLines(width = this.inner()) {
861
- const prefix = this.pendingPrompt ? `${this.pendingPrompt} ` : '› ';
862
- const full = prefix + this.buffer;
863
-
864
- const rows = [];
865
- const starts = [];
866
- let at = 0;
867
-
868
- for (const para of full.split(NEWLINE)) {
869
- let i = 0;
870
- do {
871
- rows.push(para.slice(i, i + width));
872
- starts.push(at + i);
873
- i += width;
874
- } while (i < para.length);
875
- at += para.length + 1; // the newline itself
876
- }
877
-
878
- if (rows.length === 0) { rows.push(prefix); starts.push(0); }
879
- return { rows, prefix, width, starts };
880
- }
881
-
882
- /** Which row the caret sits on, and how far along it. */
883
- caretAt(width) {
884
- const { rows, prefix, starts } = this.inputLines(width);
885
- const index = prefix.length + this.cursor;
886
- let row = 0;
887
- while (row + 1 < starts.length && starts[row + 1] <= index) row++;
888
- return { row, col: Math.min(index - starts[row], rows[row].length), rows };
889
- }
890
-
891
- viewportHeight() {
892
- return Math.max(
893
- 3,
894
- this.rows - this.headerHeight() - CHROME_BELOW - this.inputLines().rows.length
895
- );
896
- }
897
-
898
- /**
899
- * The input box: what you are typing, and directly under it, inside the same
900
- * border, the three things worth knowing while you type.
901
- *
902
- * The status used to sit outside the box on the last row of the screen,
903
- * which made it a separate object floating under the input. Inside the
904
- * border it reads as part of the thing you are using — the box says "this is
905
- * where you work", and the row underneath says what you are working with.
906
- */
907
- inputBox(width = this.width()) {
908
- const { rows } = this.inputLines(width - 4);
909
- const border = this.borderPaint();
910
- const busy = this.busy();
911
- // Nothing typed yet: a quiet prompt where the text will go. The caret sits
912
- // on its first letter and typing replaces it.
913
- const empty = !this.buffer && !this.pendingPrompt;
914
- const hint = !busy ? PLACEHOLDER
915
- : (width >= WORKING_HINT_NEEDS ? WORKING_HINT : WORKING_HINT_SHORT);
916
- const painted = rows.map((row, i) =>
917
- i === 0
918
- ? boxRow(` ${border('›')}${empty ? ` ${dim(hint)}` : row.slice(1)}`, width, border)
919
- : boxRow(` ${row}`, width, border)
920
- );
921
- return [
922
- boxTop(width, border),
923
- ...painted,
924
- // A blank row between the two. Sitting directly under the caret, the
925
- // status read as a second line of the thing being typed; one row of air
926
- // separates what you are writing from what you are writing it with.
927
- boxRow('', width, border),
928
- boxRow(this.statusRow(width), width, border),
929
- boxBottom(width, border),
930
- ];
931
- }
932
-
933
- /** Is the agent holding the turn? */
934
- busy() {
935
- return this.status.busy || !!this.activity;
936
- }
937
-
938
- /**
939
- * The input box's edge, which says whose turn it is.
940
- *
941
- * Bold blue while the box is yours, quiet while the agent has it. The status
942
- * row inside the same box already carries the words; this is the half you
943
- * catch without reading, from the corner of your eye, in the one place on
944
- * screen you were already looking.
945
- */
946
- borderPaint() {
947
- return this.busy() ? deep : edge;
948
- }
949
-
950
- /**
951
- * Repaint after something that may have changed whose turn it is.
952
- *
953
- * The cheap path redraws one row, which is right twelve times a second for a
954
- * spinner and wrong at a turn boundary: the border above and below would
955
- * still be the old weight while the status row had the new one, and the box
956
- * would be drawn in two colours. A whole frame costs nothing twice a turn.
957
- */
958
- paintBusy() {
959
- if (this.busy() === this.paintedBusy) this.paintStatus();
960
- else this.render();
961
- }
962
-
963
- // -- status row ----------------------------------------------------------
964
-
965
- modeChip() {
966
- return modeChip(this.mode);
967
- }
968
-
969
- /**
970
- * How full the context window is, as a bare number.
971
- *
972
- * It turns amber at 75% because that is where turns start being folded away
973
- * into a summary — the one moment the number predicts something you would
974
- * want to know before it happens.
975
- */
976
- percentChip() {
977
- const percent = Math.round(this.facts.percent ?? 0);
978
- return percent >= 75 ? theme.warn(`${percent}%`) : dim(`${percent}%`);
979
- }
980
-
981
- /**
982
- * Which mode is live, which model is answering, and how full the window is.
983
- *
984
- * Nothing else earns a place. The provider name was there and was cut: it is
985
- * the same on every line of every session, so it was decoration that had to
986
- * be read past to reach the two things that do change.
987
- *
988
- * The spinner used to borrow the middle of this row while something ran. It
989
- * has a row of its own outside the box now, so the only thing that ever
990
- * appears between the model and the percentage is a flash — a reply to
991
- * something you just pressed, gone a moment later.
992
- */
993
- statusRow(width = this.width()) {
994
- const inner = width - 2; // the space between the two borders
995
- const chip = this.modeChip();
996
- const mic = micChip(this.listening);
997
- const buttons = ` ${chip} ${ADD_CHIP} ${mic}`;
998
-
999
- // How long the turn has taken, back in the box beside the other two facts
1000
- // about the session. It is not on the live line: that line says what is
1001
- // being done, and a clock ticking in the middle of it competes with the
1002
- // words for no reason. Under a second there is no number worth reading.
1003
- const now = Date.now();
1004
- const since = this.activity?.start ?? this.status.since ?? 0;
1005
- const running = this.busy() && since && now - since >= 1000;
1006
- const right = `${running ? `${dim(formatDuration(now - since))} ` : ''}${this.percentChip()} `;
1007
- // On a narrow terminal the model name goes before any button does.
1008
- const withModel = `${buttons} ${chalk.white(this.model || '—')}`;
1009
- const left = visLen(withModel) + visLen(right) < inner ? withModel : buttons;
1010
-
1011
- // Where a click on the bottom row still counts as hitting the mode chip.
1012
- this.chipTo = 2 + visLen(chip);
1013
- this.plusFrom = this.chipTo + 3; // after the two spaces
1014
- this.plusTo = this.plusFrom + visLen(ADD_CHIP) - 1;
1015
- this.micFrom = this.plusTo + 3;
1016
- this.micTo = this.micFrom + visLen(mic) - 1;
1017
-
1018
- const between = Math.max(1, inner - visLen(left) - visLen(right));
1019
- const files = this.attachments.map((f) => path.basename(f)).join(', ');
1020
- // Room for the three spaces after it and at least one before.
1021
- const room = between - 4;
1022
- const middle = this.listening === 'listening' ? sky(clip('speak now · enter sends · ctrl+t stops · esc cancels', room))
1023
- : this.flashText ? dim(clip(this.flashText, room))
1024
- : files ? sky(clip(`+ ${files}`, room)) : '';
1025
-
1026
- const tail = middle ? `${middle} ` : '';
1027
- const pad = Math.max(1, inner - visLen(left) - visLen(tail) - visLen(right));
1028
- return padVis(left + ' '.repeat(pad) + tail + right, inner);
1029
- }
1030
-
1031
- /**
1032
- * What is happening right now: the spinner, what it is doing, how long it has
1033
- * been doing it, and the way out.
1034
- *
1035
- * It used to live in whatever space the status row had spare between the
1036
- * model name and the percentage — inside the box you type into, which is the
1037
- * one place on screen that is about you rather than about the agent. Out
1038
- * here it sits directly under the steps it belongs to, in the same column,
1039
- * and has room for a bar instead of a shimmer.
1040
- */
1041
- activityLine(width = this.width()) {
1042
- if (!this.busy()) return '';
1043
- const now = Date.now();
1044
- return workingLine({
1045
- glyph: spinnerGlyph(this.tick, now),
1046
- label: this.status.busy ? this.status.text : 'working',
1047
- hint: 'esc to stop',
1048
- room: Math.max(4, width),
1049
- t: now,
1050
- });
1051
- }
1052
-
1053
- /**
1054
- * Which row the live line is painted on, 1-based, or 0 when it is not shown.
1055
- *
1056
- * It is the last line of the conversation, so its row moves as the
1057
- * conversation grows and stops moving once the viewport is full. Scrolled
1058
- * back, or with the picker open, it is not on screen at all and the cheap
1059
- * repaint has nothing to do.
1060
- */
1061
- activityRowAt() {
1062
- if (this.scroll > 0 || this.picker) return 0;
1063
- const index = Math.min(this.lines.length + 1, this.viewportHeight()) - 1;
1064
- return index < 0 ? 0 : this.headerHeight() + 2 + index;
1065
- }
1066
-
1067
- /**
1068
- * Repaint only the moving rows, leaving the caret where the user left it.
1069
- *
1070
- * It is the second row from the bottom now — the box's own border is below
1071
- * it — so the row is written with its borders rather than as a bare line.
1072
- */
1073
- paintStatus() {
1074
- if (this.closed) return;
1075
- // On the start screen the status row is mid-screen, not second from the
1076
- // bottom, so the cheap single-row repaint would draw it in the wrong place.
1077
- if (this.welcoming()) {
1078
- this.render();
1079
- return;
1080
- }
1081
- const [row, col] = this.caret();
1082
- const width = this.width();
1083
- const liveAt = this.activityRowAt();
1084
- this.output.write(
1085
- PAINT_BEGIN + HIDE +
1086
- (liveAt ? at(liveAt, 1) + CLEAR_LINE + padVis(this.activityLine(width), width) : '') +
1087
- at(this.rows - 1, 1) + CLEAR_LINE + boxRow(this.statusRow(width), width, this.borderPaint()) +
1088
- at(row, col) + SHOW + PAINT_END
1089
- );
1090
- }
1091
-
1092
- toggleMode() {
1093
- this.mode = this.mode === 'plan' ? 'build' : 'plan';
1094
- this.flash(this.mode === 'plan'
1095
- ? 'plan mode — reads and researches, changes nothing'
1096
- : 'build mode — free to edit files and run commands');
1097
- this.onModeChange?.(this.mode);
1098
- this.render();
1099
- }
1100
-
1101
- /** A message on the status line that fades on its own. */
1102
- flash(text) {
1103
- this.flashText = text;
1104
- clearTimeout(this.flashTimer);
1105
- this.flashTimer = setTimeout(() => {
1106
- this.flashText = null;
1107
- this.paintStatus();
1108
- }, 2500);
1109
- this.flashTimer.unref?.();
1110
- this.paintStatus();
1111
- }
1112
-
1113
- // -- spinner -------------------------------------------------------------
1114
-
1115
- startSpinner(text = 'thinking') {
1116
- // `since` is what makes a long think legible: the label may not change for
1117
- // a minute, so the seconds beside it are the proof it is still alive.
1118
- this.status = { busy: true, text: asLabel(text), frame: 0, since: Date.now() };
1119
- this.startTimer();
1120
- this.paintBusy();
1121
- }
1122
-
1123
- updateSpinner(text) {
1124
- if (!this.status.busy) return;
1125
- this.status.text = asLabel(text);
1126
- this.paintStatus();
1127
- }
1128
-
1129
- stopSpinner() {
1130
- if (!this.activity) this.stopTimer();
1131
- if (this.status.busy) {
1132
- this.status = { busy: false, text: '', frame: 0, since: 0 };
1133
- this.paintBusy();
1134
- }
1135
- }
1136
-
1137
- // -- the turn in flight ----------------------------------------------------
1138
-
1139
- /** A turn begins: the timer and step count run until turnEnd(). */
1140
- turnStart() {
1141
- this.newSegment();
1142
- this.activity = { start: Date.now(), steps: 0, movedAt: 0 };
1143
- this.startTimer();
1144
- this.paintBusy();
1145
- }
1146
-
1147
- /** One more model step in this turn. */
1148
- step() {
1149
- if (!this.activity) return;
1150
- this.activity.steps++;
1151
- this.activity.movedAt = Date.now();
1152
- }
1153
-
1154
- /** The turn is over: leave "✓ Done in 6m 12s · 25 steps" under the answer. */
1155
- turnEnd({ ok = true } = {}) {
1156
- const a = this.activity;
1157
- this.activity = null;
1158
- if (!this.status.busy) this.stopTimer();
1159
- // Nothing is written when a turn finishes. The reply is the end of the
1160
- // turn, and a timing line under it is bookkeeping the reader did not ask
1161
- // for. A turn that stopped *without* finishing still says so, because
1162
- // silence there is indistinguishable from a crash.
1163
- if (a && !ok && Date.now() - a.start >= 2000) {
1164
- this.push(` ${doneLine(Date.now() - a.start, a.steps, { ok })}`);
1165
- }
1166
- this.paintBusy();
1167
- }
1168
-
1169
- /** The animation clock: only the status row repaints, about twelve times a second. */
1170
- startTimer() {
1171
- if (this.spinTimer) return;
1172
- this.spinTimer = setInterval(() => {
1173
- // Only the status row repaints on a tick. Animating a transcript line
1174
- // meant redrawing the whole frame twelve times a second, and the input
1175
- // box was being rebuilt under the user's cursor as they typed.
1176
- this.tick++;
1177
- this.paintStatus();
1178
- }, FRAME_MS);
1179
- this.spinTimer.unref?.();
1180
- }
1181
-
1182
- stopTimer() {
1183
- if (!this.spinTimer) return;
1184
- clearInterval(this.spinTimer);
1185
- this.spinTimer = null;
1186
- }
1187
-
1188
- // -- input ---------------------------------------------------------------
1189
-
1190
- nextLine() {
1191
- if (this.queue.length) return Promise.resolve(this.queue.shift());
1192
- if (this.closed) return Promise.resolve(null);
1193
- return new Promise((resolve) => this.waiters.push(resolve));
1194
- }
1195
-
1196
- ask() {
1197
- return this.nextLine();
1198
- }
1199
-
1200
- submit(text) {
1201
- const waiter = this.waiters.shift();
1202
- if (waiter) waiter(text);
1203
- else this.queue.push(text);
1204
- }
1205
-
1206
- /** y/n, answered on the input line. */
1207
- confirm({ action, detail, risk, always }) {
1208
- this.push('');
1209
- this.push(`${chalk.inverse(theme.warn(badge(risk)))} ${chalk.white(action)}`);
1210
- for (const line of String(detail ?? '').split('\n')) {
1211
- if (line) this.push(dim(` ${line}`));
1212
- }
1213
-
1214
- this.pendingPrompt = always ? `go ahead? [y/N, a = always allow ${always}]` : 'go ahead? [y/N]';
1215
- this.scroll = 0; // the question has to be on screen to be answered
1216
- this.render();
1217
-
1218
- return this.nextLine().then((answer) => {
1219
- this.pendingPrompt = null;
1220
- // End of input counts as no. Never run something nobody approved.
1221
- const said = String(answer ?? '').trim();
1222
- const yes = always && /^(a|always)$/i.test(said) ? 'always' : /^(y|yes)$/i.test(said);
1223
- this.push(dim(yes === 'always' ? ` approved — ${always} is always allowed here now` : yes ? ' approved' : ' declined'));
1224
- this.push('');
1225
- return yes;
1226
- });
1227
- }
1228
-
1229
- /**
1230
- * A modal list: arrows move, Enter picks, Esc cancels.
1231
- *
1232
- * Only while this is open do the arrows stop scrolling the transcript. They
1233
- * cannot be given up permanently, because under alternate scroll the mouse
1234
- * wheel arrives as arrow keys.
1235
- */
1236
- pick(items, { active = 0, hint = 'enter to choose · esc to cancel', deletable = false } = {}) {
1237
- this.picker = {
1238
- items,
1239
- index: Math.min(Math.max(0, active), Math.max(0, items.length - 1)),
1240
- hint,
1241
- // With deletable, `d` twice on a row resolves { delete: index }. Twice,
1242
- // because a single stray keypress should never cost a conversation.
1243
- deletable,
1244
- armed: null,
1245
- };
1246
- this.render();
1247
- return new Promise((resolve) => { this.pickerResolve = resolve; });
1248
- }
1249
-
1250
- closePicker(value) {
1251
- const resolve = this.pickerResolve;
1252
- this.picker = null;
1253
- this.pickerResolve = null;
1254
- this.render();
1255
- resolve?.(value);
1256
- }
1257
-
1258
- /**
1259
- * Rows for an open picker, windowed so a long list still fits.
1260
- *
1261
- * An item may carry a `sub` line — a second, dimmer row underneath it. That
1262
- * is what lets a list of saved conversations show what each one was actually
1263
- * about instead of a column of near-identical titles.
1264
- */
1265
- pickerLines(height) {
1266
- const { items, index, hint, armed } = this.picker;
1267
- const room = Math.max(1, height - 2);
1268
-
1269
- // Rows per item, so the window can be sized in rows rather than in items.
1270
- const rowsFor = (item) => (typeof item !== 'string' && item.sub ? 2 : 1);
1271
- const perItem = items.map(rowsFor);
1272
-
1273
- // Walk outward from the selection until the window is full. Starting from
1274
- // the selection guarantees it is on screen however long the list is.
1275
- let first = index;
1276
- let last = index;
1277
- let used = perItem[index] ?? 1;
1278
- while (used < room && (first > 0 || last < items.length - 1)) {
1279
- if (first > 0 && used + perItem[first - 1] <= room) { first--; used += perItem[first]; }
1280
- else if (last < items.length - 1 && used + perItem[last + 1] <= room) { last++; used += perItem[last]; }
1281
- else break;
1282
- }
1283
-
1284
- const out = [];
1285
- for (let i = first; i <= last; i++) {
1286
- const item = items[i];
1287
- const body = typeof item === 'string' ? item : item.label;
1288
- if (i === armed) out.push(`${theme.warn('✗')} ${theme.warn(bare(body))}`);
1289
- else out.push(i === index ? `${blue('❯')} ${chalk.bold.white(body)}` : ` ${dim(body)}`);
1290
- if (typeof item !== 'string' && item.sub) out.push(` ${item.sub}`);
1291
- }
1292
-
1293
- out.push('');
1294
- out.push(armed !== null && armed !== undefined
1295
- ? theme.warn(' press d again to delete this conversation · any other key keeps it')
1296
- : dim(` ${hint}`));
1297
- return out;
1298
- }
1299
-
1300
- /** A numbered list, answered on the input line. */
1301
- async choose(prompt, items, { allowNone = true } = {}) {
1302
- items.forEach((item, i) => this.push(` ${blue(String(i + 1).padStart(2))}. ${item}`));
1303
- if (allowNone) this.push(dim(' 0. none — start fresh'));
1304
- this.push('');
1305
-
1306
- this.pendingPrompt = prompt;
1307
- this.render();
1308
-
1309
- const answer = await this.nextLine();
1310
- this.pendingPrompt = null;
1311
-
1312
- const trimmed = String(answer ?? '').trim();
1313
- if (trimmed === '' || trimmed === '0') return null;
1314
-
1315
- const index = Number(trimmed);
1316
- if (!Number.isInteger(index) || index < 1 || index > items.length) {
1317
- this.push(theme.warn(` "${trimmed}" is not one of 1-${items.length}.`));
1318
- return null;
1319
- }
1320
- return index - 1;
1321
- }
1322
-
1323
- // -- keyboard and mouse --------------------------------------------------
1324
-
1325
- /**
1326
- * Scroll the transcript, clamped at both ends.
1327
- *
1328
- * When there is nothing above the fold, say so. Silence is indistinguishable
1329
- * from broken input, and the difference matters: one means the conversation
1330
- * simply fits, the other means the terminal is not forwarding keys at all.
1331
- */
1332
- scrollBy(delta) {
1333
- const max = Math.max(0, this.lines.length - this.viewportHeight());
1334
- if (max === 0) {
1335
- this.flash('nothing above — it all fits on screen');
1336
- return;
1337
- }
1338
- const before = this.scroll;
1339
- this.scroll = Math.min(Math.max(0, this.scroll + delta), max);
1340
- if (this.scroll === before && delta > 0) this.flash('already at the top');
1341
- // One wheel notch arrives as three arrow keys in one chunk; one frame, not three.
1342
- this.soon();
1343
- }
1344
-
1345
- /**
1346
- * Text arriving as a paste rather than as typing.
1347
- *
1348
- * A terminal in bracketed-paste mode wraps pasted text in markers, which is
1349
- * the only way to tell forty lines pasted at once from forty lines typed
1350
- * very fast. Without it every newline in the paste reads as Enter, so a
1351
- * pasted block submits itself a line at a time and arrives as forty
1352
- * messages. Inside the markers a newline is just a character.
1353
- */
1354
- onPaste(text) {
1355
- const clean = String(text).replace(/\r\n?/g, '\n');
1356
- this.buffer = this.buffer.slice(0, this.cursor) + clean + this.buffer.slice(this.cursor);
1357
- this.cursor += clean.length;
1358
- this.render();
1359
- }
1360
-
1361
- onData(chunk) {
1362
- // Pasted text first: it is wrapped in markers and must not be read as
1363
- // keys, or its newlines submit it in pieces.
1364
- const paste = /\[200~([\s\S]*?)\[201~/g;
1365
- if (paste.test(chunk)) {
1366
- paste.lastIndex = 0;
1367
- let at = 0;
1368
- let m;
1369
- while ((m = paste.exec(chunk))) {
1370
- if (m.index > at) this.onData(chunk.slice(at, m.index));
1371
- this.onPaste(m[1]);
1372
- at = m.index + m[0].length;
1373
- }
1374
- if (at < chunk.length) this.onData(chunk.slice(at));
1375
- return;
1376
- }
1377
- // An unterminated paste: hold what has arrived and wait for the rest.
1378
- const open = chunk.indexOf('[200~');
1379
- if (open !== -1) {
1380
- if (open > 0) this.onData(chunk.slice(0, open));
1381
- this.pasting = chunk.slice(open + 6);
1382
- return;
1383
- }
1384
- if (this.pasting !== undefined && this.pasting !== null) {
1385
- const close = chunk.indexOf('[201~');
1386
- if (close === -1) { this.pasting += chunk; return; }
1387
- this.onPaste(this.pasting + chunk.slice(0, close));
1388
- this.pasting = null;
1389
- const after = chunk.slice(close + 6);
1390
- if (after) this.onData(after);
1391
- return;
1392
- }
1393
-
1394
- // UCODE_DEBUG_KEYS=1 logs every byte the terminal sends to
1395
- // ~/.ucode/keys.log. Whether mouse reporting works at all depends on the
1396
- // terminal forwarding it; this is how to find out.
1397
- if (process.env.UCODE_DEBUG_KEYS) {
1398
- appendFile(path.join(homedir(), '.ucode', 'keys.log'), `${JSON.stringify(chunk)}\n`).catch(() => {});
1399
- }
1400
-
1401
- // Pull mouse reports out of the chunk wherever they sit. Anchoring the
1402
- // match to the whole chunk meant a wheel event arriving alongside any
1403
- // other byte was silently treated as typing.
1404
- let rest = '';
1405
- let index = 0;
1406
- // Two encodings: SGR (ESC [ < b ; x ; y M|m), and the legacy form
1407
- // (ESC [ M then three bytes offset by 32) for terminals that ignore 1006.
1408
- const mouse = /\x1b\[<(\d+);(\d+);(\d+)([Mm])|\x1b\[M([\s\S])([\s\S])([\s\S])/g;
1409
- let match;
1410
-
1411
- while ((match = mouse.exec(chunk)) !== null) {
1412
- rest += chunk.slice(index, match.index);
1413
- index = match.index + match[0].length;
1414
- if (match[1] !== undefined) {
1415
- this.onMouse(Number(match[1]), Number(match[2]), Number(match[3]), match[4]);
1416
- } else {
1417
- this.onMouse(
1418
- match[5].charCodeAt(0) - 32,
1419
- match[6].charCodeAt(0) - 32,
1420
- match[7].charCodeAt(0) - 32,
1421
- 'M'
1422
- );
1423
- }
1424
- }
1425
- rest += chunk.slice(index);
1426
-
1427
- // A chunk carrying a line break *and* other text did not come from a
1428
- // keyboard: nobody types a newline in the middle of a burst. Many
1429
- // terminals, Windows ones especially, send a paste with no markers at
1430
- // all, so without this every newline in it reads as Enter and the paste
1431
- // submits itself a line at a time.
1432
- if (looksPasted(rest)) { this.onPaste(rest); return; }
1433
-
1434
- for (const key of splitKeys(rest)) this.onKey(key);
1435
- }
1436
-
1437
- onMouse(button, col, row, press) {
1438
- // Wheel reports set bit 6; bit 0 says which way.
1439
- if (button >= 64) {
1440
- this.scrollBy(button % 2 === 0 ? 3 : -3);
1441
- return;
1442
- }
1443
- if (press !== 'M' || button !== 0) return;
1444
- if (this.welcoming()) {
1445
- const g = this.welcomeGeometry();
1446
- const statusRow = g.boxTop + g.inputRows + 3; // 1-based
1447
- if (row !== statusRow) return;
1448
- if (col > g.left + 1 && col <= g.left + this.chipTo) this.toggleMode();
1449
- else if (col >= g.left + this.plusFrom && col <= g.left + this.plusTo) this.onAttach?.();
1450
- else if (col >= g.left + this.micFrom && col <= g.left + this.micTo) this.onMic?.('toggle');
1451
- return;
1452
- }
1453
- // The mode chip, "+ file" and mic buttons, at the left of the bottom row.
1454
- if (row !== this.rows - 1) return;
1455
- if (col >= 2 && col <= this.chipTo) this.toggleMode();
1456
- else if (col >= this.plusFrom && col <= this.plusTo) this.onAttach?.();
1457
- else if (col >= this.micFrom && col <= this.micTo) this.onMic?.('toggle');
1458
- }
1459
-
1460
- /** The mic's state, for the chip: null, 'starting', 'listening' or 'writing'. */
1461
- setListening(state) {
1462
- this.listening = state;
1463
- this.render();
1464
- }
1465
-
1466
- /** Words from the mic, into the box at the cursor; `send` presses enter for them. */
1467
- insertText(text, { send = false } = {}) {
1468
- const before = this.buffer.slice(0, this.cursor);
1469
- const joined = before && !before.endsWith(' ') ? `${before} ${text}` : before + text;
1470
- this.buffer = joined + this.buffer.slice(this.cursor);
1471
- this.cursor = joined.length;
1472
- if (send) this.onKey('\r');
1473
- else this.render();
1474
- }
1475
-
1476
- /** A file chosen with "+ file", to go with the next message. */
1477
- addAttachment(file) {
1478
- if (!this.attachments.includes(file)) this.attachments.push(file);
1479
- this.render();
1480
- }
1481
-
1482
- /** The files for the message being sent; the button starts empty again. */
1483
- takeAttachments() {
1484
- const files = this.attachments;
1485
- this.attachments = [];
1486
- this.render();
1487
- return files;
1488
- }
1489
-
1490
- onKey(key) {
1491
- // An open picker owns the keyboard until it closes.
1492
- if (this.picker) {
1493
- const last = this.picker.items.length - 1;
1494
- if (this.picker.deletable && (key === 'd' || key === 'D' || key === `${ESC}[3~`)) {
1495
- if (this.picker.armed === this.picker.index) { this.closePicker({ delete: this.picker.index }); return; }
1496
- this.picker.armed = this.picker.index;
1497
- this.render();
1498
- return;
1499
- }
1500
- this.picker.armed = null; // any other key takes the delete back
1501
- if (key === `${ESC}[A`) { this.picker.index = Math.max(0, this.picker.index - 1); this.render(); return; }
1502
- if (key === `${ESC}[B`) { this.picker.index = Math.min(last, this.picker.index + 1); this.render(); return; }
1503
- if (key === '\r' || key === '\n') { this.closePicker(this.picker.index); return; }
1504
- if (key === ESC || key === '\x03') { this.closePicker(null); return; }
1505
- return;
1506
- }
1507
-
1508
- // While the mic listens, enter means "done, send it" and esc means "never mind".
1509
- if (this.listening === 'listening') {
1510
- if (key === '\r' || key === '\n') { this.onMic?.('send'); return; }
1511
- if (key === ESC || key === '\x03') { this.onMic?.('cancel'); return; }
1512
- }
1513
-
1514
- switch (key) {
1515
- case '\r':
1516
- case '\n': {
1517
- const text = this.buffer;
1518
- this.buffer = '';
1519
- this.cursor = 0;
1520
- this.historyIndex = -1;
1521
- if (text.trim()) {
1522
- this.history.unshift(text);
1523
- // Answers to a y/N or a numbered pick are not messages, so they are
1524
- // not echoed: the prompt reports its own outcome.
1525
- if (!this.pendingPrompt) {
1526
- this.userMessage(text);
1527
- for (const f of this.attachments) this.push(dim(` + ${path.basename(f)}`));
1528
- }
1529
- }
1530
- this.render();
1531
- this.submit(text);
1532
- return;
1533
- }
1534
-
1535
- case '\x7f': // backspace
1536
- case '\b':
1537
- if (this.cursor > 0) {
1538
- this.buffer = this.buffer.slice(0, this.cursor - 1) + this.buffer.slice(this.cursor);
1539
- this.cursor--;
1540
- }
1541
- break;
1542
-
1543
- case '\x03': // ctrl+c
1544
- if (this.status.busy && this.onInterrupt) this.onInterrupt();
1545
- else { this.buffer = ''; this.cursor = 0; }
1546
- break;
1547
-
1548
- case '\x04': // ctrl+d
1549
- this.close();
1550
- return;
1551
-
1552
- case '\x02': // ctrl+b — swap plan and build
1553
- this.toggleMode();
1554
- return;
1555
-
1556
- case '\x0f': // ctrl+o — the "+ file" button, for terminals that send no clicks
1557
- this.onAttach?.();
1558
- return;
1559
-
1560
- case '\x14': // ctrl+t — the mic button: start listening, or stop and put the words in the box
1561
- this.onMic?.('toggle');
1562
- return;
1563
-
1564
- case '\x15': // ctrl+u — clear the line; on an empty line, the attached files
1565
- if (!this.buffer) this.attachments = [];
1566
- this.buffer = this.buffer.slice(this.cursor);
1567
- this.cursor = 0;
1568
- break;
1569
-
1570
- case ESC: // esc — stop the turn in flight
1571
- if (this.onInterrupt) this.onInterrupt();
1572
- return;
1573
-
1574
- case '\t': {
1575
- const hit = COMMANDS.find((c) => c.startsWith(this.buffer));
1576
- if (hit) { this.buffer = hit; this.cursor = hit.length; }
1577
- break;
1578
- }
1579
-
1580
- // With an empty line the arrows scroll the conversation; once there is
1581
- // something typed they walk history. Terminals often swallow PgUp and
1582
- // PgDn for their own scrollback, so this is the path that always works.
1583
- case `${ESC}[A`:
1584
- if (!this.buffer) { this.scrollBy(2); return; }
1585
- if (this.history.length) {
1586
- this.historyIndex = Math.min(this.historyIndex + 1, this.history.length - 1);
1587
- this.buffer = this.history[this.historyIndex] ?? '';
1588
- this.cursor = this.buffer.length;
1589
- }
1590
- break;
1591
-
1592
- case `${ESC}[B`:
1593
- if (!this.buffer) { this.scrollBy(-2); return; }
1594
- this.historyIndex = Math.max(this.historyIndex - 1, -1);
1595
- this.buffer = this.historyIndex === -1 ? '' : (this.history[this.historyIndex] ?? '');
1596
- this.cursor = this.buffer.length;
1597
- break;
1598
-
1599
- case `${ESC}[1;5A`: this.scrollBy(2); return; // ctrl+up
1600
- case `${ESC}[1;5B`: this.scrollBy(-2); return; // ctrl+down
1601
- case `${ESC}[5~`: this.scrollBy(this.viewportHeight()); return;
1602
- case `${ESC}[6~`: this.scrollBy(-this.viewportHeight()); return;
1603
-
1604
- case `${ESC}[H`: this.scrollBy(this.lines.length); return;
1605
- case `${ESC}[F`: this.scroll = 0; this.render(); return;
1606
-
1607
- case `${ESC}[C`: this.cursor = Math.min(this.cursor + 1, this.buffer.length); break;
1608
- case `${ESC}[D`: this.cursor = Math.max(this.cursor - 1, 0); break;
1609
-
1610
- default:
1611
- if (key >= ' ' && !key.startsWith(ESC)) {
1612
- this.buffer = this.buffer.slice(0, this.cursor) + key + this.buffer.slice(this.cursor);
1613
- this.cursor += key.length;
1614
- } else {
1615
- return;
1616
- }
1617
- }
1618
-
1619
- this.render();
1620
- }
1621
-
1622
- // -- painting ------------------------------------------------------------
1623
-
1624
- render() {
1625
- if (this.closed) return;
1626
- if (this.welcoming()) {
1627
- this.renderWelcome();
1628
- return;
1629
- }
1630
-
1631
- const width = this.width();
1632
- const height = this.viewportHeight();
1633
-
1634
- // Scrolled back, the view stays on what is being read while output grows
1635
- // underneath: every line added since the last paint extends the scroll by
1636
- // the same amount, so the same rows stay on screen. Snapping to the
1637
- // bottom on every streamed line fought the wheel sixteen times a second.
1638
- // A streamed reply truncates its tail and rebuilds it on every delta, so
1639
- // the growth since the last paint is net — but the tail sits below a
1640
- // scrolled-back viewport, and net growth still moves the scroll by exactly
1641
- // the amount that keeps the visible rows put.
1642
- if (this.scroll > 0 && this.paintedLines != null) {
1643
- const grown = this.lines.length - this.paintedLines;
1644
- this.scroll = Math.min(Math.max(0, this.scroll + grown), Math.max(0, this.lines.length - height));
1645
- }
1646
- this.paintedLines = this.lines.length;
1647
-
1648
- // The live line is the last line of the conversation, not a fixture above
1649
- // the input box. Pinned down there it sat at the bottom of the screen while
1650
- // the message that started it was at the top, with the empty middle of the
1651
- // viewport between them — so the thing being done looked unrelated to the
1652
- // thing that had been asked. On the end of the transcript it arrives
1653
- // directly under the prompt, which is where the eye already is.
1654
- const live = this.activityLine(width);
1655
- const said = live ? [...this.lines, live] : this.lines;
1656
-
1657
- const end = Math.max(0, said.length - this.scroll);
1658
- const start = Math.max(0, end - height);
1659
- const window = this.picker ? this.pickerLines(height) : said.slice(start, end);
1660
- while (window.length < height) window.push('');
1661
-
1662
- const frame = [
1663
- ...this.headerLines(),
1664
- '',
1665
- ...window,
1666
- // Always one clear row between the last thing said and the box you type
1667
- // in. Without it the newest line of output sits against the border and
1668
- // reads as part of the input rather than as the answer above it.
1669
- '',
1670
- ...this.inputBox(),
1671
- ];
1672
-
1673
- // The cursor is hidden for the duration of the paint. Without this it is
1674
- // dragged through every line as the frame is written, which shows up as a
1675
- // dot flickering above the input box on every keystroke.
1676
- const out = [PAINT_BEGIN, HIDE];
1677
- for (let i = 0; i < this.rows; i++) {
1678
- out.push(at(i + 1, 1) + CLEAR_LINE + padVis(frame[i] ?? '', width));
1679
- }
1680
-
1681
- const [row, col] = this.caret();
1682
- out.push(at(row, col) + SHOW + PAINT_END);
1683
- this.paintedBusy = this.busy();
1684
- this.output.write(out.join(''));
1685
- }
1686
-
1687
- /**
1688
- * Where the typing caret belongs, 1-based.
1689
- *
1690
- * Column three is the first character inside the box: border, a space of
1691
- * padding, then the text.
1692
- */
1693
- caret() {
1694
- if (this.welcoming()) {
1695
- const g = this.welcomeGeometry();
1696
- const { row, col } = this.caretAt(g.boxWidth - 4);
1697
- // g.boxTop is 0-based and the typed lines start one below the border.
1698
- return [g.boxTop + 2 + row, g.left + 3 + col];
1699
- }
1700
-
1701
- const { row, col: at, rows } = this.caretAt();
1702
- const col = 3 + at;
1703
- // Counting up from the bottom: the box border is the last row, the status
1704
- // row is above it, then the blank row, then the typed lines.
1705
- const firstRow = this.rows - 2 - rows.length;
1706
- return [firstRow + row, col];
1707
- }
1708
-
1709
- // -- start screen ----------------------------------------------------------
1710
-
1711
- /**
1712
- * Nothing has been said yet, so there is nothing to scroll: the screen is the
1713
- * wordmark and the place to type, centred, and nothing else.
1714
- *
1715
- * It comes back after /clear and /new too, since those empty the transcript —
1716
- * a fresh conversation starts from the same quiet screen as a fresh launch.
1717
- */
1718
- welcoming() {
1719
- return this.lines.length === 0 && !this.picker;
1720
- }
1721
-
1722
- /** Where everything on the start screen goes, 0-based rows. */
1723
- welcomeGeometry() {
1724
- const cols = this.width();
1725
- const boxWidth = Math.max(30, Math.min(cols - 4, 84));
1726
- const left = Math.max(0, Math.floor((cols - boxWidth) / 2));
1727
- const big = cols >= BANNER_WIDTH + 4 && this.rows >= 18;
1728
- const art = big ? BANNER : ['u c o d e'];
1729
- const inputRows = this.inputLines(boxWidth - 4).rows.length;
1730
- const boxRows = inputRows + 4; // borders, typed rows, gap, status
1731
- const suggest = this.rows >= boxRows + art.length + SUGGEST_ROWS + 6;
1732
- const block = art.length + 2 + boxRows + (suggest ? SUGGEST_ROWS : 0);
1733
- // A touch above true centre reads as centred; exact centre looks low.
1734
- const top = Math.max(0, Math.floor((this.rows - block) / 2) - 1);
1735
- return { cols, boxWidth, left, big, art, inputRows, boxRows, suggest, top, boxTop: top + art.length + 2 };
1736
- }
1737
-
1738
- renderWelcome() {
1739
- const g = this.welcomeGeometry();
1740
- const frame = new Array(this.rows).fill('');
1741
-
1742
- // The wordmark lit from the top: sky at the crown, deep in the shadow
1743
- // rows. Across the rows rather than along them — a name split down its
1744
- // middle reads as two words, where a name that fades downward reads as
1745
- // one object with a light on it.
1746
- const elapsed = this.intro ? Date.now() - this.intro : Infinity;
1747
- g.art.forEach((line, i) => {
1748
- const pad = ' '.repeat(Math.max(0, Math.floor((g.cols - line.length) / 2)));
1749
- frame[g.top + i] = pad + (g.big
1750
- ? bannerSweep(line, i, g.art.length, elapsed)
1751
- : blue.bold(line));
1752
- });
1753
-
1754
- const indent = ' '.repeat(g.left);
1755
- this.inputBox(g.boxWidth).forEach((row, i) => {
1756
- frame[g.boxTop + i] = indent + row;
1757
- });
1758
-
1759
- // Three things to try, aligned with the text inside the box above them.
1760
- if (g.suggest) {
1761
- const at = g.boxTop + g.boxRows + 1;
1762
- SUGGESTIONS.forEach((text, i) => {
1763
- const label = i === 0 ? 'try'.padEnd(SUGGEST_LABEL) : ' '.repeat(SUGGEST_LABEL);
1764
- frame[at + i] = `${indent} ${dim(sky(label))}${dim(clip(text, Math.max(8, g.boxWidth - SUGGEST_LABEL - 2)))}`;
1765
- });
1766
- }
1767
-
1768
- // The version, in the corner, and nothing else on the screen.
1769
- if (VERSION) {
1770
- const tag = dim(this.facts.update ? `v${VERSION} · v${this.facts.update} installed, starts next time` : `v${VERSION}`);
1771
- frame[this.rows - 1] = ' '.repeat(Math.max(0, g.cols - visLen(tag) - 2)) + tag;
1772
- }
1773
-
1774
- const out = [PAINT_BEGIN, HIDE];
1775
- for (let i = 0; i < this.rows; i++) {
1776
- out.push(at(i + 1, 1) + CLEAR_LINE + padVis(frame[i], g.cols));
1777
- }
1778
- const [row, col] = this.caret();
1779
- out.push(at(row, col) + SHOW + PAINT_END);
1780
- this.paintedBusy = this.busy();
1781
- this.output.write(out.join(''));
1782
- }
1783
- }
1784
-
1785
- /**
1786
- * Split a raw stdin chunk into keys, keeping escape sequences whole.
1787
- *
1788
- * Application cursor key mode (DECCKM) makes a terminal send ESC O A for the
1789
- * up arrow rather than ESC [ A. Both are normalised to the bracket form here
1790
- * so the key handler only ever sees one of them.
1791
- */
1792
- /**
1793
- * Did this arrive as a paste, judged by shape rather than by markers?
1794
- *
1795
- * Someone pressing Enter sends one carriage return on its own. A paste sends
1796
- * a line break with text around it, in a single read. That difference is all
1797
- * there is to go on when a terminal does not implement bracketed paste, and
1798
- * it is enough.
1799
- *
1800
- * Anything carrying an escape sequence is left alone: that is a key or a
1801
- * mouse report, and reading one as text would put gibberish in the input.
1802
- */
1803
- export function looksPasted(chunk) {
1804
- const text = String(chunk ?? '');
1805
- if (text.length < 2 || text.includes(ESC)) return false;
1806
- const breaks = (text.match(/[\r\n]/g) ?? []).length;
1807
- if (breaks === 0) return false;
1808
- // One trailing break is someone finishing a line, not pasting one.
1809
- if (breaks === 1 && /[\r\n]$/.test(text)) return false;
1810
- return true;
1811
- }
1812
-
1813
- export function splitKeys(chunk) {
1814
- const keys = [];
1815
- let i = 0;
1816
-
1817
- while (i < chunk.length) {
1818
- const c = chunk[i];
1819
- if (c !== ESC) { keys.push(c); i++; continue; }
1820
-
1821
- const rest = chunk.slice(i);
1822
- const csi = /^\x1b\[[0-9;?]*[A-Za-z~]/.exec(rest);
1823
- if (csi) { keys.push(csi[0]); i += csi[0].length; continue; }
1824
-
1825
- const ss3 = /^\x1bO([A-Za-z])/.exec(rest);
1826
- if (ss3) { keys.push(`${ESC}[${ss3[1]}`); i += ss3[0].length; continue; }
1827
-
1828
- keys.push(ESC);
1829
- i++;
1830
- }
1831
-
1832
- return keys;
1833
- }
1
+ // SPDX-License-Identifier: AGPL-3.0-only - ucode, made and tested by om dixit. Additional terms: see NOTICE.
2
+ /**
3
+ * screen.js — the full-screen interface.
4
+ *
5
+ * Used whenever stdout is a real terminal. Everything else — piped input, CI,
6
+ * `echo ... | ucode` — falls back to plain.js, which is why both exist.
7
+ *
8
+ * The layout, top to bottom:
9
+ *
10
+ * ╭──────────────────────────────────────────────────╮
11
+ * │ UCODE wordmark dir / keys │
12
+ * ╰──────────────────────────────────────────────────╯
13
+ *
14
+ * the conversation, scrolling with the wheel or PgUp
15
+ *
16
+ * ╭──────────────────────────────────────────────────╮
17
+ * │ › what you are typing, growing downward as it │
18
+ * │ │
19
+ * │ ◆ Build · Nemotron 3 Ultra 4% │
20
+ * ╰──────────────────────────────────────────────────╯
21
+ *
22
+ * Both boxes are drawn rather than ruled off, because a box says "this is a
23
+ * thing you use" where a horizontal rule only says "something changes here".
24
+ *
25
+ * The status sits inside the input box rather than under it: it describes the
26
+ * thing you are typing into, so it belongs within the same border. It carries
27
+ * three facts and no more — which mode is live, which model is answering, and
28
+ * how full the window is. Anything else down there competes with what the user
29
+ * is actually looking at, which is what they just typed.
30
+ *
31
+ * The transcript is a buffer of pre-rendered lines and the whole frame is
32
+ * repainted whenever anything changes. At terminal sizes that is cheap, and
33
+ * it rules out every partial-update bug at once.
34
+ */
35
+
36
+ import { appendFile } from 'node:fs/promises';
37
+ import { homedir } from 'node:os';
38
+ import path from 'node:path';
39
+ import chalk from 'chalk';
40
+ import {
41
+ theme, blue, sky, deep, dim, edge, ADDED, REMOVED, BANNER, BANNER_WIDTH, SPINNER, SELECTED, BOX, CREDIT,
42
+ boxTop, boxBottom, boxRow, visLen, padVis, clip, wrapAnsi,
43
+ shortenPath, asLabel, ensureColour, planLine, bare, narration, narrationMark, groupKind, groupLabel, groupTarget, runLine, planRows, tidyReply, trimAnswer,
44
+ bannerPaint, RAIL, modeChip, ADD_CHIP, micChip, asNarrationLine } from './theme.js';
45
+ import { FRAME_MS, spinnerGlyph, formatDuration, doneLine, workingLine, bannerSweep, SWEEP_MS } from './activity.js';
46
+ import { gitBranch } from '../core/git.js';
47
+ import { renderer, render } from './markdown.js';
48
+ import { badge } from './plain.js';
49
+ import { VERSION } from '../core/version.js';
50
+
51
+ /**
52
+ * One line of narration, in the model's own words: "Reading screen.js".
53
+ * Anything longer than this is prose, and prose belongs in the answer.
54
+ */
55
+ export const MAX_LABEL = 120;
56
+
57
+ export function isLabel(text) {
58
+ const t = String(text ?? '').trim();
59
+ return t.length > 0 && t.length <= MAX_LABEL && !t.includes('\n');
60
+ }
61
+
62
+ export const COMMANDS = [
63
+ '/help', '/model', '/models', '/session', '/sessions', '/resume',
64
+ '/new', '/remember', '/skills', '/clear', '/search', '/copy', '/exit',
65
+ '/stats', '/doctor', '/deploy', '/look', '/undo', '/mic',
66
+ '/init', '/diff', '/commit', '/review', '/mcp', '/permissions', '/theme',
67
+ ];
68
+
69
+ // ANSI ----------------------------------------------------------------------
70
+ const ESC = '\x1b';
71
+ const ALT_ON = `${ESC}[?1049h`;
72
+ const ALT_OFF = `${ESC}[?1049l`;
73
+
74
+ /**
75
+ * Mouse setup, decided by measurement rather than by documentation.
76
+ *
77
+ * 1007 is alternate scroll: inside the alternate screen the terminal turns
78
+ * wheel events into arrow keys. On Windows that is the only way a wheel ever
79
+ * reaches the program, because ConPTY forwards no mouse input at all — a probe
80
+ * that enabled every tracking mode received nothing from a scroll.
81
+ *
82
+ * And mouse tracking suppresses alternate scroll. So on Windows tracking is
83
+ * deliberately not requested: it delivers nothing there, and asking for it
84
+ * would cost the wheel. Elsewhere tracking works, so the mode chip is
85
+ * clickable on those platforms.
86
+ */
87
+ const TRACK = process.platform === 'win32'
88
+ ? '' : `${ESC}[?1000h${ESC}[?1002h${ESC}[?1015h${ESC}[?1006h`;
89
+ const UNTRACK = process.platform === 'win32'
90
+ ? '' : `${ESC}[?1006l${ESC}[?1015l${ESC}[?1002l${ESC}[?1000l`;
91
+
92
+ const PASTE_ON = `${ESC}[?2004h`;
93
+ const PASTE_OFF = `${ESC}[?2004l`;
94
+ const MOUSE_ON = `${ESC}[?1007h${TRACK}`;
95
+ const MOUSE_OFF = `${UNTRACK}${ESC}[?1007l`;
96
+ const HIDE = `${ESC}[?25l`;
97
+ const SHOW = `${ESC}[?25h`;
98
+ const HOME = `${ESC}[H`;
99
+ const CLEAR_LINE = `${ESC}[K`;
100
+ /** Written out rather than inline, so no edit can turn it into a real break. */
101
+ const NEWLINE = String.fromCharCode(10);
102
+ const at = (row, col) => `${ESC}[${row};${col}H`;
103
+ const title = (t) => `${ESC}]0;${t}\x07`;
104
+
105
+ /**
106
+ * Every paint is wrapped in these. Autowrap off means a row that is one cell
107
+ * wider than we counted loses its last cell instead of wrapping onto the next
108
+ * row and scrolling the whole frame up — that scroll was the glitch. The
109
+ * synchronized-update pair makes terminals that support it show the frame in
110
+ * one go; the rest ignore it.
111
+ */
112
+ const PAINT_BEGIN = `${ESC}[?2026h${ESC}[?7l`;
113
+ const PAINT_END = `${ESC}[?7h${ESC}[?2026l`;
114
+
115
+ /**
116
+ * Text as it may be drawn: colour codes kept, everything that moves the cursor
117
+ * gone. A carriage return from a CRLF file sent the padding back over the
118
+ * line, a tab took eight cells while it was counted as one, and a clear-screen
119
+ * from a tool's output wiped the frame mid-paint.
120
+ *
121
+ * The SGR colour codes (\x1b[..m) are matched first and kept. An earlier
122
+ * version only excluded them from the CSI branch, so the bare-\x1b fallback
123
+ * stripped the ESC off every colour code and left "[36m" littered across
124
+ * coloured lines — visible only in a real terminal, never in tests, which is
125
+ * why the glitch survived the suite.
126
+ */
127
+ function printable(text) {
128
+ return String(text)
129
+ .replace(/\t/g, ' ')
130
+ .replace(/\x1b\[[0-9;]*m|(\x1b(?:\[[0-?]*[ -\/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)?|[()#][0-9A-Za-z]|[\x30-\x7e])?|[\x00-\x09\x0b-\x1a\x1c-\x1f\x7f])/g, (m, bad) => (bad ? '' : m));
131
+ }
132
+
133
+ /**
134
+ * Fixed rows below the header: the gap under it, the gap above the input box,
135
+ * the input box's two borders, the blank row inside it, and the status row.
136
+ */
137
+ const CHROME_BELOW = 6;
138
+
139
+ /** How long one sentence of reasoning holds the line before the next takes it. */
140
+ const THOUGHT_HOLD_MS = 1100;
141
+
142
+ /** Reasoning that is about the request rather than about the work. */
143
+ const RESTATEMENT = /^(?:the user|they|so the user|user)|^(?:i (?:need|should|will need) to (?:understand|figure|work out|check what))|^(?:let me (?:understand|re-?read|look at the (?:request|prompt)))|^(?:the (?:request|prompt|task) (?:is|asks|says))/i;
144
+
145
+ /** The window size asked of macOS Terminal when it opens smaller than this. */
146
+ const MAC_COLS = 120;
147
+ const MAC_ROWS = 34;
148
+
149
+ /** The wordmark only earns its place with room for the facts column beside it. */
150
+ const WORDMARK_NEEDS = BANNER_WIDTH + 30;
151
+
152
+ /** What the empty input box says before anything is typed. */
153
+ const PLACEHOLDER = 'Ask anything…';
154
+
155
+ /**
156
+ * What it says instead while the agent has the turn.
157
+ *
158
+ * The status row beside it already says "esc to stop", so this carries the
159
+ * half nothing else on screen does: that the box is still live, and a line
160
+ * typed into it now is kept and sent when the turn ends rather than lost. The
161
+ * short form is for a terminal too narrow to hold the sentence, where a cut
162
+ * one would read as a glitch.
163
+ */
164
+ const WORKING_HINT = 'Working… type to queue your next message';
165
+ const WORKING_HINT_SHORT = 'Working…';
166
+ const WORKING_HINT_NEEDS = WORKING_HINT.length + 8;
167
+
168
+ /**
169
+ * Three things to try, under the box, on a screen with nothing on it yet.
170
+ *
171
+ * A wordmark over an empty field is handsome and tells you nothing you can act
172
+ * on — the first thing a new user has to do is guess what this accepts. Three
173
+ * greyed lines answer that in one glance, and they say something about the
174
+ * range of it too: build something new, understand something that exists,
175
+ * change something small. They are dim, and they are gone the moment anything
176
+ * is on the screen.
177
+ */
178
+ const SUGGESTIONS = [
179
+ 'build me a landing page for a coffee shop',
180
+ 'explain what this project does and how it fits together',
181
+ 'add a dark mode toggle that remembers the choice',
182
+ ];
183
+
184
+ /** The width of the `try` label, so the three lines share one left edge. */
185
+ const SUGGEST_LABEL = 6;
186
+
187
+ /** Rows the suggestions occupy under the box: one of air, then the three. */
188
+ const SUGGEST_ROWS = SUGGESTIONS.length + 1;
189
+ export class Screen {
190
+ constructor({ cwd, input = process.stdin, output = process.stdout } = {}) {
191
+ this.cwd = cwd;
192
+ this.input = input;
193
+ this.output = output;
194
+
195
+ this.lines = []; // the rendered transcript
196
+ this.scroll = 0; // rows scrolled up from the bottom
197
+ this.buffer = ''; // what is being typed
198
+ this.cursor = 0;
199
+ this.history = [];
200
+ this.historyIndex = -1;
201
+
202
+ this.status = { busy: false, text: '', frame: 0, since: 0 };
203
+ this.facts = {};
204
+ this.model = '';
205
+
206
+ this.waiters = [];
207
+ this.queue = [];
208
+ this.closed = false;
209
+
210
+ // 'build' may edit and run; 'plan' is read-only. Ctrl+B swaps them, and
211
+ // the chip is clickable wherever the terminal forwards clicks.
212
+ this.mode = 'build';
213
+ this.chipTo = 0;
214
+ // The "+ file" button: its columns on the status row, and the files it has
215
+ // added so far, which go with the next message. Ctrl+O does the same.
216
+ this.plusFrom = 0;
217
+ this.plusTo = 0;
218
+ this.attachments = [];
219
+ this.onAttach = null;
220
+ // The mic button: null when idle, else 'starting' | 'listening' | 'writing'.
221
+ // Ctrl+T starts and stops it; while it listens, enter stops and sends.
222
+ this.micFrom = 0;
223
+ this.micTo = 0;
224
+ this.listening = null;
225
+ this.onMic = null;
226
+ this.onInterrupt = null;
227
+ this.onModeChange = null;
228
+ this.spinTimer = null;
229
+ this.paintedBusy = false; // whose turn the frame on screen was drawn for
230
+ this.paintedLines = null; // transcript length at the last paint; growth since then belongs underneath a scrolled-back view
231
+ this.activity = null; // the turn in flight: when it began, how many steps
232
+ this.tick = 0; // animation frames painted, for the spinner
233
+ this.intro = 0; // when the launch sweep began, 0 once it is over
234
+ this.introTimer = null;
235
+ this.pendingPrompt = null;
236
+ this.lastPrompt = ''; // the last thing the user said, for the echo check
237
+ this.facts.branch = gitBranch(cwd);
238
+
239
+ this.cols = output.columns || 80;
240
+ this.rows = output.rows || 24;
241
+ this.md = renderer(this.width());
242
+ }
243
+
244
+ // -- lifecycle -----------------------------------------------------------
245
+
246
+ async start() {
247
+ ensureColour(this.output);
248
+ this.output.write(ALT_ON + MOUSE_ON + PASTE_ON + HIDE + title(`ucode — ${path.basename(this.cwd)}`));
249
+ this.input.setRawMode?.(true);
250
+ this.input.resume();
251
+ this.input.setEncoding('utf8');
252
+ this.input.on('data', (chunk) => this.onData(chunk));
253
+
254
+ this.onResize = () => {
255
+ this.cols = this.output.columns || 80;
256
+ this.rows = this.output.rows || 24;
257
+ this.md = renderer(this.width());
258
+ this.render();
259
+ };
260
+ this.output.on('resize', this.onResize);
261
+
262
+ // macOS Terminal opens at 80×24, and with the header and the input box
263
+ // that leaves a letterbox for the conversation. Ask for more room — never
264
+ // less — and give the window back its own size on the way out.
265
+ if (process.env.TERM_PROGRAM === 'Apple_Terminal' && !this.grownFrom
266
+ && (this.cols < MAC_COLS || this.rows < MAC_ROWS)) {
267
+ this.grownFrom = [this.rows, this.cols];
268
+ this.output.write(`${ESC}[8;${Math.max(this.rows, MAC_ROWS)};${Math.max(this.cols, MAC_COLS)}t`);
269
+ }
270
+
271
+ this.render();
272
+ this.startIntro();
273
+ }
274
+
275
+ /**
276
+ * The light that crosses the wordmark once, at launch.
277
+ *
278
+ * Half a second, on the start screen only, and abandoned the instant there is
279
+ * anything else to look at. Below 256 colours there are no shades to fade
280
+ * through, so it is skipped rather than flickered.
281
+ */
282
+ startIntro() {
283
+ if (!this.output.isTTY || chalk.level < 2 || !this.welcoming()) return;
284
+ this.intro = Date.now();
285
+ this.introTimer = setInterval(() => {
286
+ if (this.closed || !this.welcoming() || Date.now() - this.intro >= SWEEP_MS) this.stopIntro();
287
+ else this.render();
288
+ }, FRAME_MS);
289
+ this.introTimer.unref?.();
290
+ }
291
+
292
+ stopIntro() {
293
+ if (this.introTimer) clearInterval(this.introTimer);
294
+ this.introTimer = null;
295
+ if (!this.intro) return;
296
+ this.intro = 0;
297
+ if (!this.closed) this.render();
298
+ }
299
+
300
+ stop() {
301
+ this.activity = null;
302
+ if (this.introTimer) clearInterval(this.introTimer);
303
+ this.introTimer = null;
304
+ this.intro = 0;
305
+ this.stopSpinner();
306
+ this.stopTimer();
307
+ this.output.off?.('resize', this.onResize);
308
+ this.input.setRawMode?.(false);
309
+ this.input.pause();
310
+ this.output.write(PASTE_OFF + MOUSE_OFF + ALT_OFF + SHOW);
311
+ if (this.grownFrom) {
312
+ this.output.write(`${ESC}[8;${this.grownFrom[0]};${this.grownFrom[1]}t`);
313
+ this.grownFrom = null;
314
+ }
315
+ }
316
+
317
+ close() {
318
+ if (this.closed) return;
319
+ this.closed = true;
320
+ this.stop();
321
+ while (this.waiters.length) this.waiters.shift()(null);
322
+ }
323
+
324
+ /**
325
+ * Every column the terminal has.
326
+ *
327
+ * Capped at 100 for a release, and it was wrong: on a wide monitor the frame
328
+ * sat in the left half of the screen with the rest of it empty, which reads
329
+ * as the window having failed to open rather than as a measured column. The
330
+ * interface fills what it is given. Prose inside it is still held to 100 by
331
+ * the markdown renderer, which is where that limit belongs — the boxes are
332
+ * the shape of the window, not of a paragraph.
333
+ */
334
+ width() {
335
+ return Math.max(30, this.cols);
336
+ }
337
+
338
+ /** Usable width inside a box: two borders and a space of padding each side. */
339
+ inner() {
340
+ return Math.max(8, this.width() - 4);
341
+ }
342
+
343
+ // -- transcript ----------------------------------------------------------
344
+
345
+ /**
346
+ * Append without painting.
347
+ *
348
+ * Anything replacing a region of the transcript has to build the whole
349
+ * region and then render once. Painting between the delete and the re-add
350
+ * puts a frame on screen with the text missing, and at streaming speed that
351
+ * reads as flicker.
352
+ */
353
+ add(text = '') {
354
+ const width = this.width();
355
+ for (const raw of printable(text).split('\n')) {
356
+ if (visLen(raw) <= width) this.lines.push(raw);
357
+ else for (const wrapped of wrapAnsi(raw, width)) this.lines.push(wrapped);
358
+ }
359
+ }
360
+
361
+ push(text = '') {
362
+ this.add(text);
363
+ this.soon();
364
+ }
365
+
366
+ /**
367
+ * Collapse a burst of pushes into one frame.
368
+ *
369
+ * Printing a list one line at a time repaints the screen per line — a model
370
+ * list of fifty entries drew a hundred frames back to back, which is visible
371
+ * as a cascade. A microtask runs before any I/O, so everything pushed in one
372
+ * synchronous stretch becomes a single render, while a push after an await
373
+ * still paints immediately.
374
+ */
375
+ soon() {
376
+ if (this.queued) return;
377
+ this.queued = true;
378
+ queueMicrotask(() => {
379
+ this.queued = false;
380
+ this.render();
381
+ });
382
+ }
383
+
384
+ write(text = '') { this.push(text); }
385
+ blank() { this.push(''); }
386
+ note(text) { this.push(dim(` ${text}`)); }
387
+
388
+ clearScreen() {
389
+ this.lines = [];
390
+ this.scroll = 0;
391
+ this.paintedLines = null;
392
+ this.render();
393
+ }
394
+
395
+ /**
396
+ * The reply, at full strength, with room either side.
397
+ *
398
+ * `closing` says this is the last thing the turn will say. It is then also
399
+ * the last thing left on screen, and what the whole session reads like
400
+ * afterwards, so it is cut to eight lines — see trimAnswer.
401
+ */
402
+ assistant(text, { closing = false, replay = false, silent = false } = {}) {
403
+ if (!text?.trim()) return;
404
+ // A replayed answer goes back exactly as it was first shown: no tidying,
405
+ // no trimming, no re-read of the request. Tidy-up exists to keep a live
406
+ // answer short; on a resumed one it rewrites history, which is why a
407
+ // resumed session read like only fragments had survived.
408
+ const tidy = tidyReply(text, 4, this.lastPrompt);
409
+ const body = replay ? String(text) : (closing ? trimAnswer(tidy) : tidy);
410
+ if (!body.trim()) return;
411
+ this.endRun();
412
+ this.add('');
413
+
414
+ // No bullet, and no indent. A mark on every reply made the answer read as
415
+ // one more step in the list above it, and on "Hey! How can I help you
416
+ // today?" it was a bullet on a greeting. The answer already wins the page
417
+ // by being the only thing on it at full strength; it does not also need to
418
+ // be labelled.
419
+ this.add(render(this.md, body));
420
+
421
+ this.add('');
422
+ if (!silent) this.render();
423
+ }
424
+
425
+ /**
426
+ * Something the user said, marked down its left edge in the same blue as the
427
+ * box it was typed into.
428
+ *
429
+ * A long session is mostly the agent's output — tool calls, diffs, answers.
430
+ * Your own messages are the landmarks you scroll back looking for, so they
431
+ * get a mark of their own. It was a full box, and forty turns of that is a
432
+ * ladder of rules across the page: two horizontal lines per message, each as
433
+ * loud as the input box, none of them saying anything the rail does not.
434
+ */
435
+ userMessage(text, { silent = false } = {}) {
436
+ // Kept so the reply can be checked against it: an answer that opens by
437
+ // saying the request back is repeating the line directly above it.
438
+ this.lastPrompt = String(text ?? '');
439
+ this.scroll = 0; // sending something is the one thing that jumps to the bottom
440
+ const room = Math.max(8, this.width() - 2); // the rail and the space after it
441
+
442
+ const rows = [];
443
+ for (const paragraph of String(text).replace(/\r/g, '').split('\n')) {
444
+ for (const line of wrapAnsi(paragraph, room)) rows.push(line);
445
+ }
446
+
447
+ // Room between what you asked for and what came back: without it the reply
448
+ // starts against your own message and the two read as one block of text.
449
+ this.add('');
450
+ for (const row of rows) this.add(`${blue(RAIL)} ${chalk.white(row)}`);
451
+ this.add('');
452
+ this.add('');
453
+ if (!silent) this.render();
454
+ }
455
+
456
+ /**
457
+ * A tool call, as it happens: "● Listing src".
458
+ *
459
+ * This lives in the transcript rather than only on the status line. The
460
+ * status line overwrites itself and is empty by the end of the turn, so work
461
+ * announced only there scrolls past unseen — and the diff underneath ends up
462
+ * with nothing above it explaining where it came from.
463
+ */
464
+ toolCall(label) {
465
+ // U+25CF, not U+23FA: the latter carries emoji presentation, which Windows
466
+ // Terminal draws as a white circle on a blue tile.
467
+ // Trimmed here rather than at paint time: runLine hands back a coloured
468
+ // string, and asLabel's regexes run off the end of one of those into the
469
+ // escape sequence instead of the last word.
470
+ const clean = asLabel(label);
471
+ const kind = groupKind(clean);
472
+ // One line per kind of work for as long as the model is working on one
473
+ // thing. Reading, writing and reading again used to draw six lines that
474
+ // said three things; now the "Reading files" line it already has is the
475
+ // one that counts up, wherever it sits.
476
+ const run = (this.segment ??= new Map()).get(kind);
477
+
478
+ if (run && this.lines[run.at] !== undefined) {
479
+ run.count++;
480
+ run.label = clean;
481
+ run.targets.push(groupTarget(clean));
482
+ this.run = run;
483
+ this.paintRun();
484
+ } else {
485
+ const fresh = {
486
+ kind, count: 1, at: 0, label: clean,
487
+ targets: [groupTarget(clean)], added: 0, removed: 0,
488
+ };
489
+ this.push(`${narrationMark(kind)} ${runLine(fresh)}`);
490
+ fresh.at = this.lines.length - 1;
491
+ this.run = fresh;
492
+ this.segment.set(kind, fresh);
493
+ }
494
+ this.updateSpinner(label);
495
+ }
496
+
497
+ /**
498
+ * The model speaking — or a plan, or a failure — ends the segment.
499
+ *
500
+ * Up to that point a kind of work keeps one line and counts up on it. After
501
+ * it, the next read is a new piece of work and deserves its own line, which
502
+ * is what makes the transcript read as a sequence of things done rather
503
+ * than a set of running totals.
504
+ */
505
+ /**
506
+ * Stop adding to the current run, but keep the lines already on screen.
507
+ *
508
+ * A kind of work gets one line for the whole turn. Starting a fresh set
509
+ * whenever the model spoke meant "Creating Tide from the HTML starter" five
510
+ * times down the page and "Reading files" four, each saying the same thing
511
+ * about a different moment. One line that counts up says all of it and
512
+ * costs one row.
513
+ */
514
+ endRun() { this.run = null; }
515
+
516
+ /** A new turn starts with a clean page's worth of lines. */
517
+ newSegment() { this.run = null; this.segment = new Map(); }
518
+
519
+ /** Redraw the run's single line from what it has accumulated. */
520
+ /**
521
+ * Redraw the run's single line from what it has accumulated.
522
+ *
523
+ * While its step is still running the text shimmers, which is the only
524
+ * thing on screen saying "this is happening now" once the per-step result
525
+ * lines are gone. It settles to plain dim the moment the step finishes, so
526
+ * the finished ones above stay quiet.
527
+ */
528
+ paintRun() {
529
+ if (!this.run) return;
530
+ this.lines[this.run.at] = `${narrationMark(this.run.kind)} ${runLine(this.run)}`;
531
+ this.render();
532
+ }
533
+
534
+ /**
535
+ * A change, as its two numbers.
536
+ *
537
+ * The diff itself used to go into the transcript. A 539-line file printed
538
+ * there buries the answer under a copy of something already on disk, so
539
+ * what is kept is the shape of the change: how much arrived, how much left.
540
+ */
541
+ /**
542
+ * A one-word verdict on the step that just ran — "clean", "3 to fix".
543
+ *
544
+ * Same rule as diffStat: it goes on the line that named the step. Nothing
545
+ * goes underneath a bullet, and a result line per tool doubles the height of
546
+ * the transcript to say "ok".
547
+ */
548
+ runStat(text) {
549
+ if (!this.run || !text) return;
550
+ this.run.stat = text;
551
+ this.paintRun();
552
+ }
553
+
554
+ diffStat({ added = 0, removed = 0 } = {}) {
555
+ if (!this.run) return;
556
+ this.run.added += added;
557
+ this.run.removed += removed;
558
+ this.paintRun();
559
+ }
560
+
561
+ /** The checklist, when the model updates it. One line, wrapped if it must. */
562
+ plan(items) {
563
+ const rows = planRows(items);
564
+ if (!rows.length) return;
565
+ this.endRun(); // a plan is not another step of whatever came before
566
+ for (const row of rows) this.push(row);
567
+ }
568
+
569
+ /**
570
+ * What came of a step.
571
+ *
572
+ * Nothing goes underneath the bullet any more: a line of its own for every
573
+ * result doubles the height of the transcript to say "ok". The bullet
574
+ * already names the step, and a change adds its numbers to that same line.
575
+ * Only a failure earns a line of its own.
576
+ */
577
+ toolResult() {}
578
+
579
+ /**
580
+ * Something went wrong, and the model is the one who can do anything about it.
581
+ *
582
+ * A red line of machinery — a failed edit, a command that exited non-zero —
583
+ * reads as the tool being broken, when almost always it is a step the model
584
+ * corrects on its own a second later. It goes to the model; the screen stays
585
+ * for what is being built. Whatever is genuinely unrecoverable surfaces as
586
+ * the model saying so in words, which is the form worth reading.
587
+ */
588
+ toolFailed() {
589
+ this.endRun();
590
+ }
591
+
592
+ /**
593
+ * The change itself, under the result.
594
+ *
595
+ * A line-number gutter, then the sign and the code tinted right across the
596
+ * row. The numbers are the point: a diff you cannot navigate from is a
597
+ * picture of a change rather than a record of one.
598
+ */
599
+ diff(lines) {
600
+ const gutter = 6;
601
+ // Two spaces of indent, the gutter, one space, then the tint fills the
602
+ // rest. One column over and every row wraps, splitting the whole diff.
603
+ const room = Math.max(12, this.width() - gutter - 3);
604
+
605
+ for (const line of lines) {
606
+ // A file heading in a multi-file write.
607
+ if (line.startsWith('~')) {
608
+ this.add(` ${dim(' '.repeat(gutter))} ${sky(line.slice(1))}`);
609
+ continue;
610
+ }
611
+
612
+ const added = line.startsWith('+');
613
+ const rest = line.slice(1);
614
+ // Tools emit "<line>| <text>". A row with no number is the "12 more
615
+ // lines" note, which is not part of the change, so it stays dim.
616
+ const parsed = /^(\d+)\|\s?([\s\S]*)$/.exec(rest);
617
+ if (!parsed) {
618
+ this.add(` ${dim(' '.repeat(gutter))} ${dim(rest)}`);
619
+ continue;
620
+ }
621
+
622
+ const [, number, body] = parsed;
623
+ const tint = added ? ADDED : REMOVED;
624
+ this.add(
625
+ ` ${dim(number.padStart(gutter))} ` +
626
+ // Tabs would leave the tint ending short of the row, so they widen.
627
+ tint(padVis(clip(`${added ? '+' : '-'} ${body.replace(/\t/g, ' ')}`, room), room))
628
+ );
629
+ }
630
+ this.render(); // a sixteen-line diff is one frame, not sixteen
631
+ }
632
+
633
+ /** Captured output under a command, dimmed so it reads as evidence. */
634
+ commandOutput(lines) {
635
+ for (const line of lines) this.add(` ${dim(line)}`);
636
+ this.render();
637
+ }
638
+
639
+ /**
640
+ * A running command's output, live — on the status line and nowhere else.
641
+ *
642
+ * Only the newest line, gone as soon as the next arrives. Appending each one
643
+ * instead would mean a test run leaving sixty lines of "ok" in the
644
+ * conversation permanently, which is noise the moment it scrolls. What
645
+ * survives a command is decided when it ends: nothing if it worked, the tail
646
+ * if it did not.
647
+ */
648
+ progress(lines) {
649
+ const last = lines[lines.length - 1]?.trim();
650
+ if (last) this.updateSpinner(last);
651
+ }
652
+
653
+ /**
654
+ * The model's own account of the step it is taking, before it takes it.
655
+ *
656
+ * Not called status(): `this.status` holds the spinner state, and a method
657
+ * of the same name would be shadowed by it on every instance.
658
+ */
659
+ /**
660
+ * What the model says beside a tool call — "I will build the app now" — is
661
+ * not an answer, and printing it made a build read like a running commentary
662
+ * nobody asked for. It moves the spinner, and nothing stays on screen: the
663
+ * only prose in the transcript is the answer at the end.
664
+ */
665
+ narrate(text) {
666
+ const line = asLabel(text);
667
+ if (line) this.updateSpinner(line);
668
+ }
669
+
670
+ // -- streaming -----------------------------------------------------------
671
+ // A reply is collected while it arrives and shown once it is complete — and
672
+ // only if it turns out to be the answer. Painted as it streamed, every
673
+ // "Let me build this" flashed up and vanished again when a tool call
674
+ // followed it. The spinner keeps running meanwhile.
675
+
676
+ streamBegin() {
677
+ this.streamAt = this.lines.length;
678
+ this.streamBuf = '';
679
+ }
680
+
681
+ streamDelta(delta) {
682
+ if (this.streamAt === undefined) this.streamBegin();
683
+ this.streamBuf += delta;
684
+ }
685
+
686
+ /**
687
+ * Finish a streamed reply.
688
+ *
689
+ * `asLabel` says the text turned out to be narration ahead of a tool call
690
+ * rather than an answer, in which case one short line folds down into the
691
+ * status line it was always meant to be.
692
+ */
693
+ streamEnd({ asNarration = false, closing = false } = {}) {
694
+ if (this.streamAt === undefined) return '';
695
+ const text = this.streamBuf;
696
+ this.lines.length = this.streamAt;
697
+ this.streamAt = undefined;
698
+ this.streamBuf = '';
699
+
700
+ // Mid-build the model's prose is commentary on work that has not happened
701
+ // yet, so it is condensed to one line rather than printed whole — long or
702
+ // short, it never becomes a block of text above the file being written.
703
+ if (asNarration) {
704
+ const line = asNarrationLine(text);
705
+ if (line) this.narrate(line);
706
+ else this.render();
707
+ }
708
+ else if (text.trim()) { this.stopSpinner(); this.assistant(text, { closing }); }
709
+ else this.render();
710
+ return text;
711
+ }
712
+
713
+ // -- thinking ------------------------------------------------------------
714
+ // A reasoning model does all its working before it says anything. None of it
715
+ // is printed: it is long, repetitive, and guesses drawn from it read worse
716
+ // than silence. The spinner counts the seconds so the wait is visibly alive,
717
+ // and the transcript gets one line afterwards saying how long it took.
718
+
719
+ /**
720
+ * The model's reasoning does not go on screen.
721
+ *
722
+ * It was surfaced here to fill the wait before the first tool call, and what
723
+ * it actually filled it with was the model talking to itself: "I need to
724
+ * build this", "The user wants a tasks app". Nobody needs their own request
725
+ * read back to them, and half-formed working-out is not something to publish.
726
+ * What the model *says* is its reply, and that is the only thing shown.
727
+ */
728
+ thinkingDelta() {}
729
+
730
+ thinkingEnd() {}
731
+
732
+ error(err, { debug = false } = {}) {
733
+ const known = err && typeof err === 'object' && err.attempted;
734
+ this.push('');
735
+ if (known) {
736
+ this.push(`${theme.error('✗')} ${chalk.white(`Failed while ${err.attempted}.`)}`);
737
+ this.push(` ${err.failed}`);
738
+ if (err.fix) this.push(` ${blue('→')} ${err.fix}`);
739
+ if (err.kind) this.push(dim(` (${err.kind})`));
740
+ } else {
741
+ this.push(`${theme.error('✗')} ${chalk.white('Something broke inside ucode.')}`);
742
+ this.push(` ${err?.message ?? String(err)}`);
743
+ this.push(` ${blue('→')} That is a bug in ucode rather than in your project. Re-run with --debug.`);
744
+ }
745
+ if (debug) {
746
+ const stack = (known && err.cause?.stack) || err?.stack;
747
+ if (stack) this.push(dim(stack));
748
+ }
749
+ this.push('');
750
+ }
751
+
752
+ // -- header --------------------------------------------------------------
753
+
754
+ setFacts(facts) {
755
+ this.facts = { ...this.facts, ...facts };
756
+ if (facts.model) this.model = facts.model;
757
+ this.render();
758
+ }
759
+
760
+ /** Same shape as the plain UI's header(), so the loop needs no branch. */
761
+ header({ cwd, model, used, limit, title: sessionTitle }) {
762
+ if (cwd && cwd !== this.facts.cwd) this.facts.branch = gitBranch(cwd);
763
+ this.setFacts({
764
+ cwd,
765
+ model,
766
+ title: sessionTitle,
767
+ percent: limit > 0 ? Math.min(100, Math.round((used / limit) * 100)) : 0,
768
+ });
769
+ }
770
+
771
+ /**
772
+ * How many rows the header box occupies.
773
+ *
774
+ * The frame has to be exactly as tall as the terminal or every row below the
775
+ * shortfall is off by that much — including the one the caret is parked on.
776
+ * So this is derived, never assumed.
777
+ */
778
+ headerHeight() {
779
+ return this.width() >= WORDMARK_NEEDS ? BANNER.length + 2 : 5;
780
+ }
781
+
782
+ headerLines() {
783
+ const width = this.width();
784
+ const inner = width - 2; // between the borders
785
+
786
+ if (width < WORDMARK_NEEDS) {
787
+ // Too narrow for the wordmark: stack it rather than wrap it into noise.
788
+ const rows = [
789
+ ` ${blue.bold('U C O D E')} ${dim(CREDIT)}`,
790
+ ` ${dim('dir'.padEnd(8))}${chalk.white(clip(shortenPath(this.facts.cwd ?? this.cwd, inner - 12), inner - 12))}`,
791
+ ];
792
+ return [boxTop(width), ...rows.map((r) => boxRow(r, width)), boxBottom(width)];
793
+ }
794
+
795
+ // Two spaces of padding, the wordmark, a gap, then the facts column.
796
+ //
797
+ // Only what you cannot work out by looking, and never what the status row
798
+ // already carries: the model and how full the window is live down there,
799
+ // next to each other, and a second copy up here would be a second place to
800
+ // keep in sync for no reader who needed it. What is left is where you are,
801
+ // which branch that is on, which build you are running, and the two keys
802
+ // worth knowing before you have typed anything.
803
+ //
804
+ // Every row has something on it. Three blank rows and a credit floating
805
+ // under them read as a column that was meant to be filled and was not.
806
+ const room = Math.max(8, inner - BANNER_WIDTH - 6);
807
+ const value = (v) => clip(String(v), Math.max(4, room - 10));
808
+ const branch = this.facts.branch;
809
+
810
+ // The facts run together from the top with nothing between them, the credit
811
+ // sits on the last row, and whatever is left over is the gap between the
812
+ // two. Holding a row empty in the middle of the list — which is what a
813
+ // fixed six-row layout did when a fact was missing — reads as a line that
814
+ // failed to draw rather than as spacing.
815
+ const facts = [
816
+ ['dir', value(shortenPath(this.facts.cwd ?? this.cwd, room - 10))],
817
+ branch && ['branch', value(branch)],
818
+ VERSION && ['version', value(this.facts.update ? `${VERSION} → ${this.facts.update} next start` : VERSION)],
819
+ ['keys', value('/help · ctrl+t mic · ctrl+o file · ctrl+b plan · esc stops')],
820
+ ].filter(Boolean).slice(0, BANNER.length - 1);
821
+
822
+ while (facts.length < BANNER.length - 1) facts.push(['', '']);
823
+ facts.push(['', CREDIT]);
824
+
825
+ const rows = BANNER.map((art, i) => {
826
+ const [label, text] = facts[i] ?? ['', ''];
827
+ const right = label
828
+ ? `${dim(label.padEnd(10))}${chalk.white(text)}`
829
+ : (text ? dim(text) : '');
830
+ return ` ${bannerPaint(i)(art)} ${right}`;
831
+ });
832
+
833
+ // The frame is lit the way the wordmark inside it is: brightest along the
834
+ // top rule, settling to deep at the bottom. Eight rows of box for six of
835
+ // banner, so the borders take the two ends of the same ramp and the box
836
+ // reads as one object with a light above it rather than as a rule someone
837
+ // drew around a picture.
838
+ const depth = BANNER.length + 2;
839
+ return [
840
+ boxTop(width, bannerPaint(0, depth)),
841
+ ...rows.map((r, i) => boxRow(r, width, bannerPaint(i + 1, depth))),
842
+ boxBottom(width, bannerPaint(depth - 1, depth)),
843
+ ];
844
+ }
845
+
846
+ // -- input box -----------------------------------------------------------
847
+
848
+ /** The typed line, wrapped to the inside of a box `width` characters across. */
849
+ /**
850
+ * The typed text, laid out as rows inside the box.
851
+ *
852
+ * A line break in the buffer is a row of its own before any wrapping is
853
+ * considered. Slicing the text into fixed widths without looking for one
854
+ * put the newline into the frame instead, and the terminal obeyed it — the
855
+ * pasted text walked out of the box and over the transcript beside it.
856
+ *
857
+ * `starts` records where each row begins in the text, so the caret can be
858
+ * placed by looking up rather than by counting characters a second way and
859
+ * hoping the two agree.
860
+ */
861
+ inputLines(width = this.inner()) {
862
+ // No arrow in front: it was drawn over the first character of the row,
863
+ // which is the arrow itself only when nothing else is there - a question
864
+ // waiting for an answer lost its first letter ("o ahead? [y/N]").
865
+ const prefix = this.pendingPrompt ? `${this.pendingPrompt} ` : '';
866
+ const full = prefix + this.buffer;
867
+
868
+ const rows = [];
869
+ const starts = [];
870
+ let at = 0;
871
+
872
+ for (const para of full.split(NEWLINE)) {
873
+ let i = 0;
874
+ do {
875
+ rows.push(para.slice(i, i + width));
876
+ starts.push(at + i);
877
+ i += width;
878
+ } while (i < para.length);
879
+ at += para.length + 1; // the newline itself
880
+ }
881
+
882
+ if (rows.length === 0) { rows.push(prefix); starts.push(0); }
883
+ return { rows, prefix, width, starts };
884
+ }
885
+
886
+ /** Which row the caret sits on, and how far along it. */
887
+ caretAt(width) {
888
+ const { rows, prefix, starts } = this.inputLines(width);
889
+ const index = prefix.length + this.cursor;
890
+ let row = 0;
891
+ while (row + 1 < starts.length && starts[row + 1] <= index) row++;
892
+ return { row, col: Math.min(index - starts[row], rows[row].length), rows };
893
+ }
894
+
895
+ viewportHeight() {
896
+ return Math.max(
897
+ 3,
898
+ this.rows - this.headerHeight() - CHROME_BELOW - this.inputLines().rows.length
899
+ );
900
+ }
901
+
902
+ /**
903
+ * The input box: what you are typing, and directly under it, inside the same
904
+ * border, the three things worth knowing while you type.
905
+ *
906
+ * The status used to sit outside the box on the last row of the screen,
907
+ * which made it a separate object floating under the input. Inside the
908
+ * border it reads as part of the thing you are using — the box says "this is
909
+ * where you work", and the row underneath says what you are working with.
910
+ */
911
+ inputBox(width = this.width()) {
912
+ const { rows } = this.inputLines(width - 4);
913
+ const border = this.borderPaint();
914
+ const busy = this.busy();
915
+ // Nothing typed yet: a quiet prompt where the text will go. The caret sits
916
+ // on its first letter and typing replaces it.
917
+ const empty = !this.buffer && !this.pendingPrompt;
918
+ const hint = !busy ? PLACEHOLDER
919
+ : (width >= WORKING_HINT_NEEDS ? WORKING_HINT : WORKING_HINT_SHORT);
920
+ const painted = rows.map((row, i) =>
921
+ i === 0
922
+ ? boxRow(` ${empty ? dim(hint) : row}`, width, border)
923
+ : boxRow(` ${row}`, width, border)
924
+ );
925
+ return [
926
+ boxTop(width, border),
927
+ ...painted,
928
+ // A blank row between the two. Sitting directly under the caret, the
929
+ // status read as a second line of the thing being typed; one row of air
930
+ // separates what you are writing from what you are writing it with.
931
+ boxRow('', width, border),
932
+ boxRow(this.statusRow(width), width, border),
933
+ boxBottom(width, border),
934
+ ];
935
+ }
936
+
937
+ /** Is the agent holding the turn? */
938
+ busy() {
939
+ return this.status.busy || !!this.activity;
940
+ }
941
+
942
+ /**
943
+ * The input box's edge, which says whose turn it is.
944
+ *
945
+ * Bold blue while the box is yours, quiet while the agent has it. The status
946
+ * row inside the same box already carries the words; this is the half you
947
+ * catch without reading, from the corner of your eye, in the one place on
948
+ * screen you were already looking.
949
+ */
950
+ borderPaint() {
951
+ return this.busy() ? deep : edge;
952
+ }
953
+
954
+ /**
955
+ * Repaint after something that may have changed whose turn it is.
956
+ *
957
+ * The cheap path redraws one row, which is right twelve times a second for a
958
+ * spinner and wrong at a turn boundary: the border above and below would
959
+ * still be the old weight while the status row had the new one, and the box
960
+ * would be drawn in two colours. A whole frame costs nothing twice a turn.
961
+ */
962
+ paintBusy() {
963
+ if (this.busy() === this.paintedBusy) this.paintStatus();
964
+ else this.render();
965
+ }
966
+
967
+ // -- status row ----------------------------------------------------------
968
+
969
+ modeChip() {
970
+ return modeChip(this.mode);
971
+ }
972
+
973
+ /**
974
+ * How full the context window is, as a bare number.
975
+ *
976
+ * It turns amber at 75% because that is where turns start being folded away
977
+ * into a summary — the one moment the number predicts something you would
978
+ * want to know before it happens.
979
+ */
980
+ percentChip() {
981
+ const percent = Math.round(this.facts.percent ?? 0);
982
+ return percent >= 75 ? theme.warn(`${percent}%`) : dim(`${percent}%`);
983
+ }
984
+
985
+ /**
986
+ * Which mode is live, which model is answering, and how full the window is.
987
+ *
988
+ * Nothing else earns a place. The provider name was there and was cut: it is
989
+ * the same on every line of every session, so it was decoration that had to
990
+ * be read past to reach the two things that do change.
991
+ *
992
+ * The spinner used to borrow the middle of this row while something ran. It
993
+ * has a row of its own outside the box now, so the only thing that ever
994
+ * appears between the model and the percentage is a flash — a reply to
995
+ * something you just pressed, gone a moment later.
996
+ */
997
+ statusRow(width = this.width()) {
998
+ const inner = width - 2; // the space between the two borders
999
+ const chip = this.modeChip();
1000
+ const mic = micChip(this.listening);
1001
+ const buttons = ` ${chip} ${ADD_CHIP} ${mic}`;
1002
+
1003
+ // How long the turn has taken, back in the box beside the other two facts
1004
+ // about the session. It is not on the live line: that line says what is
1005
+ // being done, and a clock ticking in the middle of it competes with the
1006
+ // words for no reason. Under a second there is no number worth reading.
1007
+ const now = Date.now();
1008
+ const since = this.activity?.start ?? this.status.since ?? 0;
1009
+ const running = this.busy() && since && now - since >= 1000;
1010
+ const right = `${running ? `${dim(formatDuration(now - since))} ` : ''}${this.percentChip()} `;
1011
+ // On a narrow terminal the model name goes before any button does.
1012
+ const withModel = `${buttons} ${chalk.white(this.model || '—')}`;
1013
+ const left = visLen(withModel) + visLen(right) < inner ? withModel : buttons;
1014
+
1015
+ // Where a click on the bottom row still counts as hitting the mode chip.
1016
+ this.chipTo = 2 + visLen(chip);
1017
+ this.plusFrom = this.chipTo + 3; // after the two spaces
1018
+ this.plusTo = this.plusFrom + visLen(ADD_CHIP) - 1;
1019
+ this.micFrom = this.plusTo + 3;
1020
+ this.micTo = this.micFrom + visLen(mic) - 1;
1021
+
1022
+ const between = Math.max(1, inner - visLen(left) - visLen(right));
1023
+ const files = this.attachments.map((f) => path.basename(f)).join(', ');
1024
+ // Room for the three spaces after it and at least one before.
1025
+ const room = between - 4;
1026
+ const middle = this.listening === 'listening' ? sky(clip('speak now · enter sends · ctrl+t stops · esc cancels', room))
1027
+ : this.flashText ? dim(clip(this.flashText, room))
1028
+ : files ? sky(clip(`+ ${files}`, room)) : '';
1029
+
1030
+ const tail = middle ? `${middle} ` : '';
1031
+ const pad = Math.max(1, inner - visLen(left) - visLen(tail) - visLen(right));
1032
+ return padVis(left + ' '.repeat(pad) + tail + right, inner);
1033
+ }
1034
+
1035
+ /**
1036
+ * What is happening right now: the spinner, what it is doing, how long it has
1037
+ * been doing it, and the way out.
1038
+ *
1039
+ * It used to live in whatever space the status row had spare between the
1040
+ * model name and the percentage — inside the box you type into, which is the
1041
+ * one place on screen that is about you rather than about the agent. Out
1042
+ * here it sits directly under the steps it belongs to, in the same column,
1043
+ * and has room for a bar instead of a shimmer.
1044
+ */
1045
+ activityLine(width = this.width()) {
1046
+ if (!this.busy()) return '';
1047
+ const now = Date.now();
1048
+ return workingLine({
1049
+ glyph: spinnerGlyph(this.tick, now),
1050
+ label: this.status.busy ? this.status.text : 'working',
1051
+ hint: 'esc to stop',
1052
+ room: Math.max(4, width),
1053
+ t: now,
1054
+ });
1055
+ }
1056
+
1057
+ /**
1058
+ * Which row the live line is painted on, 1-based, or 0 when it is not shown.
1059
+ *
1060
+ * It is the last line of the conversation, so its row moves as the
1061
+ * conversation grows and stops moving once the viewport is full. Scrolled
1062
+ * back, or with the picker open, it is not on screen at all and the cheap
1063
+ * repaint has nothing to do.
1064
+ */
1065
+ activityRowAt() {
1066
+ if (this.scroll > 0 || this.picker) return 0;
1067
+ const index = Math.min(this.lines.length + 1, this.viewportHeight()) - 1;
1068
+ return index < 0 ? 0 : this.headerHeight() + 2 + index;
1069
+ }
1070
+
1071
+ /**
1072
+ * Repaint only the moving rows, leaving the caret where the user left it.
1073
+ *
1074
+ * It is the second row from the bottom now — the box's own border is below
1075
+ * it — so the row is written with its borders rather than as a bare line.
1076
+ */
1077
+ paintStatus() {
1078
+ if (this.closed) return;
1079
+ // On the start screen the status row is mid-screen, not second from the
1080
+ // bottom, so the cheap single-row repaint would draw it in the wrong place.
1081
+ if (this.welcoming()) {
1082
+ this.render();
1083
+ return;
1084
+ }
1085
+ const [row, col] = this.caret();
1086
+ const width = this.width();
1087
+ const liveAt = this.activityRowAt();
1088
+ this.output.write(
1089
+ PAINT_BEGIN + HIDE +
1090
+ (liveAt ? at(liveAt, 1) + CLEAR_LINE + padVis(this.activityLine(width), width) : '') +
1091
+ at(this.rows - 1, 1) + CLEAR_LINE + boxRow(this.statusRow(width), width, this.borderPaint()) +
1092
+ at(row, col) + SHOW + PAINT_END
1093
+ );
1094
+ }
1095
+
1096
+ toggleMode() {
1097
+ this.mode = this.mode === 'plan' ? 'build' : 'plan';
1098
+ this.flash(this.mode === 'plan'
1099
+ ? 'plan mode — reads and researches, changes nothing'
1100
+ : 'build mode — free to edit files and run commands');
1101
+ this.onModeChange?.(this.mode);
1102
+ this.render();
1103
+ }
1104
+
1105
+ /** A message on the status line that fades on its own. */
1106
+ flash(text) {
1107
+ this.flashText = text;
1108
+ clearTimeout(this.flashTimer);
1109
+ this.flashTimer = setTimeout(() => {
1110
+ this.flashText = null;
1111
+ this.paintStatus();
1112
+ }, 2500);
1113
+ this.flashTimer.unref?.();
1114
+ this.paintStatus();
1115
+ }
1116
+
1117
+ // -- spinner -------------------------------------------------------------
1118
+
1119
+ startSpinner(text = 'thinking') {
1120
+ // `since` is what makes a long think legible: the label may not change for
1121
+ // a minute, so the seconds beside it are the proof it is still alive.
1122
+ this.status = { busy: true, text: asLabel(text), frame: 0, since: Date.now() };
1123
+ this.startTimer();
1124
+ this.paintBusy();
1125
+ }
1126
+
1127
+ updateSpinner(text) {
1128
+ if (!this.status.busy) return;
1129
+ this.status.text = asLabel(text);
1130
+ this.paintStatus();
1131
+ }
1132
+
1133
+ stopSpinner() {
1134
+ if (!this.activity) this.stopTimer();
1135
+ if (this.status.busy) {
1136
+ this.status = { busy: false, text: '', frame: 0, since: 0 };
1137
+ this.paintBusy();
1138
+ }
1139
+ }
1140
+
1141
+ // -- the turn in flight ----------------------------------------------------
1142
+
1143
+ /** A turn begins: the timer and step count run until turnEnd(). */
1144
+ turnStart() {
1145
+ this.newSegment();
1146
+ this.activity = { start: Date.now(), steps: 0, movedAt: 0 };
1147
+ this.startTimer();
1148
+ this.paintBusy();
1149
+ }
1150
+
1151
+ /** One more model step in this turn. */
1152
+ step() {
1153
+ if (!this.activity) return;
1154
+ this.activity.steps++;
1155
+ this.activity.movedAt = Date.now();
1156
+ }
1157
+
1158
+ /** The turn is over: leave "✓ Done in 6m 12s · 25 steps" under the answer. */
1159
+ turnEnd({ ok = true } = {}) {
1160
+ const a = this.activity;
1161
+ this.activity = null;
1162
+ if (!this.status.busy) this.stopTimer();
1163
+ // Nothing is written when a turn finishes. The reply is the end of the
1164
+ // turn, and a timing line under it is bookkeeping the reader did not ask
1165
+ // for. A turn that stopped *without* finishing still says so, because
1166
+ // silence there is indistinguishable from a crash.
1167
+ if (a && !ok && Date.now() - a.start >= 2000) {
1168
+ this.push(` ${doneLine(Date.now() - a.start, a.steps, { ok })}`);
1169
+ }
1170
+ this.paintBusy();
1171
+ }
1172
+
1173
+ /** The animation clock: only the status row repaints, about twelve times a second. */
1174
+ startTimer() {
1175
+ if (this.spinTimer) return;
1176
+ this.spinTimer = setInterval(() => {
1177
+ // Only the status row repaints on a tick. Animating a transcript line
1178
+ // meant redrawing the whole frame twelve times a second, and the input
1179
+ // box was being rebuilt under the user's cursor as they typed.
1180
+ this.tick++;
1181
+ this.paintStatus();
1182
+ }, FRAME_MS);
1183
+ this.spinTimer.unref?.();
1184
+ }
1185
+
1186
+ stopTimer() {
1187
+ if (!this.spinTimer) return;
1188
+ clearInterval(this.spinTimer);
1189
+ this.spinTimer = null;
1190
+ }
1191
+
1192
+ // -- input ---------------------------------------------------------------
1193
+
1194
+ nextLine() {
1195
+ if (this.queue.length) return Promise.resolve(this.queue.shift());
1196
+ if (this.closed) return Promise.resolve(null);
1197
+ return new Promise((resolve) => this.waiters.push(resolve));
1198
+ }
1199
+
1200
+ ask() {
1201
+ return this.nextLine();
1202
+ }
1203
+
1204
+ submit(text) {
1205
+ const waiter = this.waiters.shift();
1206
+ if (waiter) waiter(text);
1207
+ else this.queue.push(text);
1208
+ }
1209
+
1210
+ /**
1211
+ * A question, as a popup over the input box: Yes, No, and - where there is
1212
+ * something to remember - Always. Answered as it always was, y, n or a and
1213
+ * enter; or the arrows and enter, starting on No, so an enter pressed out of
1214
+ * habit never runs anything; esc is no. A key on its own answers nothing:
1215
+ * the question can pop up mid-sentence, and the "a" of "and then" is not
1216
+ * "always". Whatever else is typed stays a message and is sent as one.
1217
+ */
1218
+ confirm({ action, detail, risk, always }) {
1219
+ // As plain text: a colour or cursor code inside a command could make it
1220
+ // show as something other than what runs. Stripped, all of it shows.
1221
+ const plainText = (s) => bare(printable(String(s ?? '')));
1222
+ this.push('');
1223
+ this.push(`${chalk.inverse(theme.warn(badge(risk)))} ${chalk.white(plainText(action))}`);
1224
+ this.scroll = 0;
1225
+ return new Promise((resolve) => {
1226
+ const options = [
1227
+ { label: 'Yes', value: true, key: 'y', word: 'yes' },
1228
+ { label: 'No', value: false, key: 'n', word: 'no' },
1229
+ ...(always ? [{ label: `Always allow ${plainText(always)}`, value: 'always', key: 'a', word: 'always' }] : []),
1230
+ ];
1231
+ this.asking = {
1232
+ title: `${badge(risk).trim()} · ${plainText(action)}`,
1233
+ detail: plainText(detail).split('\n').filter(Boolean),
1234
+ options,
1235
+ index: 1,
1236
+ scroll: 0,
1237
+ resolve: (yes) => {
1238
+ this.asking = null;
1239
+ this.push(dim(yes === 'always' ? ` approved — ${always} is always allowed here now` : yes ? ' approved' : ' declined'));
1240
+ this.push('');
1241
+ this.render();
1242
+ resolve(yes);
1243
+ },
1244
+ };
1245
+ this.render();
1246
+ });
1247
+ }
1248
+
1249
+ /**
1250
+ * Which answer enter gives now: the one typed, if what is typed is one;
1251
+ * the highlighted one when nothing is typed; -1 when what is typed is a
1252
+ * message rather than an answer.
1253
+ */
1254
+ askedPick() {
1255
+ const a = this.asking;
1256
+ const said = this.buffer.trim().toLowerCase();
1257
+ if (!said) return a.index;
1258
+ return a.options.findIndex((o) => said === o.key || said === o.word);
1259
+ }
1260
+
1261
+ /**
1262
+ * Information in a popup over the input box - /stats, /help, /mcp and the
1263
+ * rest - instead of more lines in the conversation. Arrows scroll it; esc,
1264
+ * enter or q closes it, and the promise settles then.
1265
+ */
1266
+ panel(title, lines) {
1267
+ // Cleaned like the transcript: MCP errors, file names and git output are not ours.
1268
+ const all = lines.flatMap((l) => printable(String(l)).split(NEWLINE));
1269
+ const filled = (l) => bare(l).trim() !== '';
1270
+ const body = all.slice(Math.max(0, all.findIndex(filled)), all.findLastIndex(filled) + 1);
1271
+ return new Promise((resolve) => {
1272
+ this.panelOpen = { title, lines: body, scroll: 0, resolve };
1273
+ this.render();
1274
+ });
1275
+ }
1276
+
1277
+ closePanel() {
1278
+ const resolve = this.panelOpen?.resolve;
1279
+ this.panelOpen = null;
1280
+ this.render();
1281
+ resolve?.();
1282
+ }
1283
+
1284
+ /**
1285
+ * The commands that match what has been typed after "/", for the palette.
1286
+ * Matches at the start of the name come first, then anywhere in it.
1287
+ */
1288
+ paletteItems() {
1289
+ if (this.picker || this.asking || this.panelOpen || this.pendingPrompt) return [];
1290
+ const typed = this.buffer;
1291
+ if (!typed.startsWith('/') || /\s/.test(typed)) return [];
1292
+ const q = typed.slice(1).toLowerCase();
1293
+ const all = this.listCommands?.() ?? COMMANDS.map((name) => ({ name, what: '' }));
1294
+ const first = all.filter((c) => c.name.slice(1).toLowerCase().startsWith(q));
1295
+ const then = all.filter((c) => !first.includes(c) && c.name.toLowerCase().includes(q));
1296
+ return [...first, ...then];
1297
+ }
1298
+
1299
+ /**
1300
+ * Whatever floats over the input box right now - a picker, a question, a
1301
+ * panel or the command palette - as framed rows `width` wide, at most
1302
+ * `room` tall. Empty when there is nothing to show.
1303
+ */
1304
+ popupLines(width, room) {
1305
+ const inner = Math.max(10, width - 4);
1306
+ let title = null;
1307
+ let rows = []; // { text, chosen?, warn? }
1308
+ let index = 0;
1309
+ let hint = '';
1310
+ let scroll = null;
1311
+
1312
+ if (this.picker) {
1313
+ const { items, armed } = this.picker;
1314
+ title = this.picker.title ?? null;
1315
+ index = this.picker.index;
1316
+ // Conversation titles come from what was said, so they are cleaned like the transcript.
1317
+ items.forEach((item, i) => {
1318
+ const body = typeof item === 'string' ? item : item.label;
1319
+ rows.push({ text: printable(body), chosen: i === index, warn: i === armed, item: i });
1320
+ if (typeof item !== 'string' && item.sub) rows.push({ text: printable(item.sub), sub: true, item: i });
1321
+ });
1322
+ hint = armed !== null && armed !== undefined
1323
+ ? 'press d again to delete this conversation · any other key keeps it'
1324
+ : this.picker.hint;
1325
+ } else if (this.asking) {
1326
+ // Wrapped, never cut, and scrolled with pgup/pgdn when it is taller than
1327
+ // the room: what is being approved has to be readable in full. The
1328
+ // answers stay in view below it.
1329
+ const a = this.asking;
1330
+ const pick = this.askedPick();
1331
+ const text = [
1332
+ ...wrapAnsi(a.title, inner).map((t) => chalk.bold.white(t)),
1333
+ ...(a.detail.length ? [''] : []),
1334
+ ...a.detail.flatMap((t) => wrapAnsi(t, inner)).map((t) => dim(t)),
1335
+ ];
1336
+ const fit = Math.max(1, room - 3 - a.options.length - 1); // borders, hint, the gap above the answers
1337
+ a.scroll = Math.min(Math.max(0, a.scroll), Math.max(0, text.length - fit));
1338
+ rows = [
1339
+ ...text.slice(a.scroll, a.scroll + fit).map((t) => ({ text: t })),
1340
+ { text: '' },
1341
+ ...a.options.map((o, i) => ({ text: `${o.key} ${o.label}`, chosen: i === pick })),
1342
+ ];
1343
+ scroll = 0;
1344
+ hint = (text.length > fit ? `${a.scroll + fit}/${text.length} · pgup pgdn · ` : '')
1345
+ + (pick < 0 ? 'enter sends what you typed as a message · esc no'
1346
+ : `y, n${a.options.length > 2 ? ', a' : ''} or ↑↓, then enter · esc no`);
1347
+ } else if (this.panelOpen) {
1348
+ title = this.panelOpen.title;
1349
+ rows = this.panelOpen.lines.flatMap((text) => wrapAnsi(text, inner)).map((text) => ({ text }));
1350
+ scroll = this.panelOpen.scroll;
1351
+ hint = '↑↓ scroll · esc closes';
1352
+ } else {
1353
+ const items = this.paletteItems();
1354
+ if (!items.length) return [];
1355
+ index = Math.min(this.paletteIndex ?? 0, items.length - 1);
1356
+ const nameWidth = Math.min(20, Math.max(...items.map((c) => c.name.length)) + 3);
1357
+ // Your own commands' descriptions come from files in the project, so they are cleaned too.
1358
+ rows = items.map((c, i) => {
1359
+ const what = printable(c.what ?? '');
1360
+ return { text: printable(c.name.padEnd(nameWidth)) + dim(what), plain: printable(c.name.padEnd(nameWidth)) + what, chosen: i === index };
1361
+ });
1362
+ hint = 'enter runs · tab completes · esc closes';
1363
+ }
1364
+
1365
+ // Fit: borders, a title and its gap, the hint - and the rows in what is left.
1366
+ const fixed = 2 + (title ? 2 : 0) + (hint ? 1 : 0);
1367
+ const space = Math.max(1, room - fixed);
1368
+ let first = 0;
1369
+ if (scroll !== null) {
1370
+ first = Math.min(Math.max(0, scroll), Math.max(0, rows.length - space));
1371
+ if (this.panelOpen) this.panelOpen.scroll = first;
1372
+ } else {
1373
+ const at = Math.max(0, rows.findIndex((r) => r.chosen));
1374
+ first = Math.min(Math.max(0, at - Math.floor(space / 2)), Math.max(0, rows.length - space));
1375
+ }
1376
+ const shown = rows.slice(first, first + space);
1377
+
1378
+ const paint = sky;
1379
+ const line = (content) => boxRow(` ${content}`, width, paint);
1380
+ const out = [boxTop(width, paint)];
1381
+ if (title) out.push(line(chalk.bold.white(clip(title, inner))), line(''));
1382
+ for (const r of shown) {
1383
+ if (r.warn) out.push(line(theme.warn(`✗ ${bare(r.text)}`)));
1384
+ else if (r.chosen) out.push(paint(BOX.v) + SELECTED(padVis(` ${clip(bare(r.plain ?? r.text), inner)}`, width - 2)) + paint(BOX.v));
1385
+ // boxRow cuts at the border by what shows; clip() counts colour codes
1386
+ // as letters and could cut one in half, leaving "22…" on screen.
1387
+ else out.push(line(r.sub ? ` ${r.text}` : r.text));
1388
+ }
1389
+ const more = rows.length > shown.length ? `${first + shown.length}/${rows.length} · ` : '';
1390
+ if (hint) out.push(line(dim(clip(more + hint, inner))));
1391
+ out.push(boxBottom(width, paint));
1392
+ return out;
1393
+ }
1394
+
1395
+ /**
1396
+ * A modal list: arrows move, Enter picks, Esc cancels.
1397
+ *
1398
+ * Only while this is open do the arrows stop scrolling the transcript. They
1399
+ * cannot be given up permanently, because under alternate scroll the mouse
1400
+ * wheel arrives as arrow keys.
1401
+ */
1402
+ pick(items, { title = null, active = 0, hint = 'enter to choose · esc to cancel', deletable = false } = {}) {
1403
+ this.picker = {
1404
+ title,
1405
+ items,
1406
+ index: Math.min(Math.max(0, active), Math.max(0, items.length - 1)),
1407
+ hint,
1408
+ // With deletable, `d` twice on a row resolves { delete: index }. Twice,
1409
+ // because a single stray keypress should never cost a conversation.
1410
+ deletable,
1411
+ armed: null,
1412
+ };
1413
+ this.render();
1414
+ return new Promise((resolve) => { this.pickerResolve = resolve; });
1415
+ }
1416
+
1417
+ closePicker(value) {
1418
+ const resolve = this.pickerResolve;
1419
+ this.picker = null;
1420
+ this.pickerResolve = null;
1421
+ this.render();
1422
+ resolve?.(value);
1423
+ }
1424
+
1425
+ /** A numbered list, answered on the input line. */
1426
+ async choose(prompt, items, { allowNone = true } = {}) {
1427
+ items.forEach((item, i) => this.push(` ${blue(String(i + 1).padStart(2))}. ${item}`));
1428
+ if (allowNone) this.push(dim(' 0. none — start fresh'));
1429
+ this.push('');
1430
+
1431
+ this.pendingPrompt = prompt;
1432
+ this.render();
1433
+
1434
+ const answer = await this.nextLine();
1435
+ this.pendingPrompt = null;
1436
+
1437
+ const trimmed = String(answer ?? '').trim();
1438
+ if (trimmed === '' || trimmed === '0') return null;
1439
+
1440
+ const index = Number(trimmed);
1441
+ if (!Number.isInteger(index) || index < 1 || index > items.length) {
1442
+ this.push(theme.warn(` "${trimmed}" is not one of 1-${items.length}.`));
1443
+ return null;
1444
+ }
1445
+ return index - 1;
1446
+ }
1447
+
1448
+ // -- keyboard and mouse --------------------------------------------------
1449
+
1450
+ /**
1451
+ * Scroll the transcript, clamped at both ends.
1452
+ *
1453
+ * When there is nothing above the fold, say so. Silence is indistinguishable
1454
+ * from broken input, and the difference matters: one means the conversation
1455
+ * simply fits, the other means the terminal is not forwarding keys at all.
1456
+ */
1457
+ scrollBy(delta) {
1458
+ const max = Math.max(0, this.lines.length - this.viewportHeight());
1459
+ if (max === 0) {
1460
+ this.flash('nothing above — it all fits on screen');
1461
+ return;
1462
+ }
1463
+ const before = this.scroll;
1464
+ this.scroll = Math.min(Math.max(0, this.scroll + delta), max);
1465
+ if (this.scroll === before && delta > 0) this.flash('already at the top');
1466
+ // One wheel notch arrives as three arrow keys in one chunk; one frame, not three.
1467
+ this.soon();
1468
+ }
1469
+
1470
+ /**
1471
+ * Text arriving as a paste rather than as typing.
1472
+ *
1473
+ * A terminal in bracketed-paste mode wraps pasted text in markers, which is
1474
+ * the only way to tell forty lines pasted at once from forty lines typed
1475
+ * very fast. Without it every newline in the paste reads as Enter, so a
1476
+ * pasted block submits itself a line at a time and arrives as forty
1477
+ * messages. Inside the markers a newline is just a character.
1478
+ */
1479
+ onPaste(text) {
1480
+ const clean = String(text).replace(/\r\n?/g, '\n');
1481
+ this.buffer = this.buffer.slice(0, this.cursor) + clean + this.buffer.slice(this.cursor);
1482
+ this.cursor += clean.length;
1483
+ this.render();
1484
+ }
1485
+
1486
+ onData(chunk) {
1487
+ // Pasted text first: it is wrapped in markers and must not be read as
1488
+ // keys, or its newlines submit it in pieces.
1489
+ const paste = /\[200~([\s\S]*?)\[201~/g;
1490
+ if (paste.test(chunk)) {
1491
+ paste.lastIndex = 0;
1492
+ let at = 0;
1493
+ let m;
1494
+ while ((m = paste.exec(chunk))) {
1495
+ if (m.index > at) this.onData(chunk.slice(at, m.index));
1496
+ this.onPaste(m[1]);
1497
+ at = m.index + m[0].length;
1498
+ }
1499
+ if (at < chunk.length) this.onData(chunk.slice(at));
1500
+ return;
1501
+ }
1502
+ // An unterminated paste: hold what has arrived and wait for the rest.
1503
+ const open = chunk.indexOf('[200~');
1504
+ if (open !== -1) {
1505
+ if (open > 0) this.onData(chunk.slice(0, open));
1506
+ this.pasting = chunk.slice(open + 6);
1507
+ return;
1508
+ }
1509
+ if (this.pasting !== undefined && this.pasting !== null) {
1510
+ const close = chunk.indexOf('[201~');
1511
+ if (close === -1) { this.pasting += chunk; return; }
1512
+ this.onPaste(this.pasting + chunk.slice(0, close));
1513
+ this.pasting = null;
1514
+ const after = chunk.slice(close + 6);
1515
+ if (after) this.onData(after);
1516
+ return;
1517
+ }
1518
+
1519
+ // UCODE_DEBUG_KEYS=1 logs every byte the terminal sends to
1520
+ // ~/.ucode/keys.log. Whether mouse reporting works at all depends on the
1521
+ // terminal forwarding it; this is how to find out.
1522
+ if (process.env.UCODE_DEBUG_KEYS) {
1523
+ appendFile(path.join(homedir(), '.ucode', 'keys.log'), `${JSON.stringify(chunk)}\n`).catch(() => {});
1524
+ }
1525
+
1526
+ // Pull mouse reports out of the chunk wherever they sit. Anchoring the
1527
+ // match to the whole chunk meant a wheel event arriving alongside any
1528
+ // other byte was silently treated as typing.
1529
+ let rest = '';
1530
+ let index = 0;
1531
+ // Two encodings: SGR (ESC [ < b ; x ; y M|m), and the legacy form
1532
+ // (ESC [ M then three bytes offset by 32) for terminals that ignore 1006.
1533
+ const mouse = /\x1b\[<(\d+);(\d+);(\d+)([Mm])|\x1b\[M([\s\S])([\s\S])([\s\S])/g;
1534
+ let match;
1535
+
1536
+ while ((match = mouse.exec(chunk)) !== null) {
1537
+ rest += chunk.slice(index, match.index);
1538
+ index = match.index + match[0].length;
1539
+ if (match[1] !== undefined) {
1540
+ this.onMouse(Number(match[1]), Number(match[2]), Number(match[3]), match[4]);
1541
+ } else {
1542
+ this.onMouse(
1543
+ match[5].charCodeAt(0) - 32,
1544
+ match[6].charCodeAt(0) - 32,
1545
+ match[7].charCodeAt(0) - 32,
1546
+ 'M'
1547
+ );
1548
+ }
1549
+ }
1550
+ rest += chunk.slice(index);
1551
+
1552
+ // A chunk carrying a line break *and* other text did not come from a
1553
+ // keyboard: nobody types a newline in the middle of a burst. Many
1554
+ // terminals, Windows ones especially, send a paste with no markers at
1555
+ // all, so without this every newline in it reads as Enter and the paste
1556
+ // submits itself a line at a time.
1557
+ if (looksPasted(rest)) { this.onPaste(rest); return; }
1558
+
1559
+ const keys = splitKeys(rest);
1560
+ // Under a question, a wheel notch (three arrows in one go) scrolls the
1561
+ // conversation behind it, so reading back never moves the answer to Yes.
1562
+ if (this.asking && keys.length > 1 && keys.every((k) => k === `${ESC}[A` || k === `${ESC}[B`)) {
1563
+ this.scrollBy(keys[0] === `${ESC}[A` ? 3 : -3);
1564
+ return;
1565
+ }
1566
+ for (const key of keys) this.onKey(key);
1567
+ }
1568
+
1569
+ onMouse(button, col, row, press) {
1570
+ // Wheel reports set bit 6; bit 0 says which way.
1571
+ if (button >= 64) {
1572
+ this.scrollBy(button % 2 === 0 ? 3 : -3);
1573
+ return;
1574
+ }
1575
+ if (press !== 'M' || button !== 0) return;
1576
+ if (this.welcoming()) {
1577
+ const g = this.welcomeGeometry();
1578
+ const statusRow = g.boxTop + g.inputRows + 3; // 1-based
1579
+ if (row !== statusRow) return;
1580
+ if (col > g.left + 1 && col <= g.left + this.chipTo) this.toggleMode();
1581
+ else if (col >= g.left + this.plusFrom && col <= g.left + this.plusTo) this.onAttach?.();
1582
+ else if (col >= g.left + this.micFrom && col <= g.left + this.micTo) this.onMic?.('toggle');
1583
+ return;
1584
+ }
1585
+ // The mode chip, "+ file" and mic buttons, at the left of the bottom row.
1586
+ if (row !== this.rows - 1) return;
1587
+ if (col >= 2 && col <= this.chipTo) this.toggleMode();
1588
+ else if (col >= this.plusFrom && col <= this.plusTo) this.onAttach?.();
1589
+ else if (col >= this.micFrom && col <= this.micTo) this.onMic?.('toggle');
1590
+ }
1591
+
1592
+ /** The mic's state, for the chip: null, 'starting', 'listening' or 'writing'. */
1593
+ setListening(state) {
1594
+ this.listening = state;
1595
+ this.render();
1596
+ }
1597
+
1598
+ /** Words from the mic, into the box at the cursor; `send` presses enter for them. */
1599
+ insertText(text, { send = false } = {}) {
1600
+ const before = this.buffer.slice(0, this.cursor);
1601
+ const joined = before && !before.endsWith(' ') ? `${before} ${text}` : before + text;
1602
+ this.buffer = joined + this.buffer.slice(this.cursor);
1603
+ this.cursor = joined.length;
1604
+ // With a popup open, enter would answer it instead of sending the words.
1605
+ if (send && !this.asking && !this.panelOpen && !this.picker) this.onKey('\r');
1606
+ else this.render();
1607
+ }
1608
+
1609
+ /** A file chosen with "+ file", to go with the next message. */
1610
+ addAttachment(file) {
1611
+ if (!this.attachments.includes(file)) this.attachments.push(file);
1612
+ this.render();
1613
+ }
1614
+
1615
+ /** The files for the message being sent; the button starts empty again. */
1616
+ takeAttachments() {
1617
+ const files = this.attachments;
1618
+ this.attachments = [];
1619
+ this.render();
1620
+ return files;
1621
+ }
1622
+
1623
+ onKey(key) {
1624
+ // A question takes the arrows, pgup/pgdn, esc and enter; letters are typing.
1625
+ if (this.asking) {
1626
+ const a = this.asking;
1627
+ if (key === `${ESC}[5~` || key === `${ESC}[6~`) { a.scroll += key === `${ESC}[5~` ? -5 : 5; this.render(); return; }
1628
+ if (key === `${ESC}[A` || key === `${ESC}[D`) { a.index = Math.max(0, a.index - 1); this.render(); return; }
1629
+ if (key === `${ESC}[B` || key === `${ESC}[C` || key === '\t') { a.index = Math.min(a.options.length - 1, a.index + 1); this.render(); return; }
1630
+ if (key === ESC || key === '\x03') { a.resolve(false); return; }
1631
+ if (key === '\r' || key === '\n') {
1632
+ const pick = a.options[this.askedPick()];
1633
+ if (pick) { this.buffer = ''; this.cursor = 0; a.resolve(pick.value); return; }
1634
+ // Not an answer: a message, sent below as any other, and the question stays.
1635
+ }
1636
+ }
1637
+
1638
+ // A panel scrolls, and anything that means "done" closes it.
1639
+ if (this.panelOpen) {
1640
+ const step = { [`${ESC}[A`]: -1, [`${ESC}[B`]: 1, [`${ESC}[5~`]: -10, [`${ESC}[6~`]: 10 }[key];
1641
+ if (step) { this.panelOpen.scroll = Math.max(0, this.panelOpen.scroll + step); this.render(); return; }
1642
+ if (key === ESC || key === '\r' || key === '\n' || key === 'q' || key === ' ' || key === '\x03') { this.closePanel(); return; }
1643
+ return;
1644
+ }
1645
+
1646
+ // The command palette: shown while a "/command" is being typed.
1647
+ const palette = this.paletteItems();
1648
+ if (palette.length) {
1649
+ const last = palette.length - 1;
1650
+ const at = Math.min(this.paletteIndex ?? 0, last);
1651
+ if (key === `${ESC}[A`) { this.paletteIndex = at === 0 ? last : at - 1; this.render(); return; }
1652
+ if (key === `${ESC}[B`) { this.paletteIndex = at === last ? 0 : at + 1; this.render(); return; }
1653
+ if (key === '\t') { this.buffer = palette[at].name; this.cursor = this.buffer.length; this.paletteIndex = 0; this.render(); return; }
1654
+ if (key === ESC) { this.buffer = ''; this.cursor = 0; this.paletteIndex = 0; this.render(); return; }
1655
+ if (key === '\r' || key === '\n') { this.buffer = palette[at].name; this.cursor = this.buffer.length; this.paletteIndex = 0; }
1656
+ }
1657
+
1658
+ // An open picker owns the keyboard until it closes.
1659
+ if (this.picker) {
1660
+ const last = this.picker.items.length - 1;
1661
+ if (this.picker.deletable && (key === 'd' || key === 'D' || key === `${ESC}[3~`)) {
1662
+ if (this.picker.armed === this.picker.index) { this.closePicker({ delete: this.picker.index }); return; }
1663
+ this.picker.armed = this.picker.index;
1664
+ this.render();
1665
+ return;
1666
+ }
1667
+ this.picker.armed = null; // any other key takes the delete back
1668
+ if (key === `${ESC}[A`) { this.picker.index = Math.max(0, this.picker.index - 1); this.render(); return; }
1669
+ if (key === `${ESC}[B`) { this.picker.index = Math.min(last, this.picker.index + 1); this.render(); return; }
1670
+ if (key === '\r' || key === '\n') { this.closePicker(this.picker.index); return; }
1671
+ if (key === ESC || key === '\x03') { this.closePicker(null); return; }
1672
+ return;
1673
+ }
1674
+
1675
+ // While the mic listens, enter means "done, send it" and esc means "never mind".
1676
+ if (this.listening === 'listening') {
1677
+ if (key === '\r' || key === '\n') { this.onMic?.('send'); return; }
1678
+ if (key === ESC || key === '\x03') { this.onMic?.('cancel'); return; }
1679
+ }
1680
+
1681
+ switch (key) {
1682
+ case '\r':
1683
+ case '\n': {
1684
+ const text = this.buffer;
1685
+ this.buffer = '';
1686
+ this.cursor = 0;
1687
+ this.historyIndex = -1;
1688
+ if (text.trim()) {
1689
+ this.history.unshift(text);
1690
+ // Answers to a y/N or a numbered pick are not messages, so they are
1691
+ // not echoed: the prompt reports its own outcome.
1692
+ if (!this.pendingPrompt) {
1693
+ this.userMessage(text);
1694
+ for (const f of this.attachments) this.push(dim(` + ${path.basename(f)}`));
1695
+ }
1696
+ }
1697
+ this.render();
1698
+ this.submit(text);
1699
+ return;
1700
+ }
1701
+
1702
+ case '\x7f': // backspace
1703
+ case '\b':
1704
+ if (this.cursor > 0) {
1705
+ this.buffer = this.buffer.slice(0, this.cursor - 1) + this.buffer.slice(this.cursor);
1706
+ this.cursor--;
1707
+ }
1708
+ this.paletteIndex = 0;
1709
+ break;
1710
+
1711
+ case '\x03': // ctrl+c
1712
+ if (this.status.busy && this.onInterrupt) this.onInterrupt();
1713
+ else { this.buffer = ''; this.cursor = 0; }
1714
+ break;
1715
+
1716
+ case '\x04': // ctrl+d
1717
+ this.close();
1718
+ return;
1719
+
1720
+ case '\x02': // ctrl+b — swap plan and build
1721
+ this.toggleMode();
1722
+ return;
1723
+
1724
+ case '\x0f': // ctrl+o — the "+ file" button, for terminals that send no clicks
1725
+ this.onAttach?.();
1726
+ return;
1727
+
1728
+ case '\x14': // ctrl+t — the mic button: start listening, or stop and put the words in the box
1729
+ this.onMic?.('toggle');
1730
+ return;
1731
+
1732
+ case '\x15': // ctrl+u — clear the line; on an empty line, the attached files
1733
+ if (!this.buffer) this.attachments = [];
1734
+ this.buffer = this.buffer.slice(this.cursor);
1735
+ this.cursor = 0;
1736
+ break;
1737
+
1738
+ case ESC: // esc — stop the turn in flight
1739
+ if (this.onInterrupt) this.onInterrupt();
1740
+ return;
1741
+
1742
+ case '\t': {
1743
+ const hit = COMMANDS.find((c) => c.startsWith(this.buffer));
1744
+ if (hit) { this.buffer = hit; this.cursor = hit.length; }
1745
+ break;
1746
+ }
1747
+
1748
+ // With an empty line the arrows scroll the conversation; once there is
1749
+ // something typed they walk history. Terminals often swallow PgUp and
1750
+ // PgDn for their own scrollback, so this is the path that always works.
1751
+ case `${ESC}[A`:
1752
+ if (!this.buffer) { this.scrollBy(2); return; }
1753
+ if (this.history.length) {
1754
+ this.historyIndex = Math.min(this.historyIndex + 1, this.history.length - 1);
1755
+ this.buffer = this.history[this.historyIndex] ?? '';
1756
+ this.cursor = this.buffer.length;
1757
+ }
1758
+ break;
1759
+
1760
+ case `${ESC}[B`:
1761
+ if (!this.buffer) { this.scrollBy(-2); return; }
1762
+ this.historyIndex = Math.max(this.historyIndex - 1, -1);
1763
+ this.buffer = this.historyIndex === -1 ? '' : (this.history[this.historyIndex] ?? '');
1764
+ this.cursor = this.buffer.length;
1765
+ break;
1766
+
1767
+ case `${ESC}[1;5A`: this.scrollBy(2); return; // ctrl+up
1768
+ case `${ESC}[1;5B`: this.scrollBy(-2); return; // ctrl+down
1769
+ case `${ESC}[5~`: this.scrollBy(this.viewportHeight()); return;
1770
+ case `${ESC}[6~`: this.scrollBy(-this.viewportHeight()); return;
1771
+
1772
+ case `${ESC}[H`: this.scrollBy(this.lines.length); return;
1773
+ case `${ESC}[F`: this.scroll = 0; this.render(); return;
1774
+
1775
+ case `${ESC}[C`: this.cursor = Math.min(this.cursor + 1, this.buffer.length); break;
1776
+ case `${ESC}[D`: this.cursor = Math.max(this.cursor - 1, 0); break;
1777
+
1778
+ default:
1779
+ if (key >= ' ' && !key.startsWith(ESC)) {
1780
+ this.buffer = this.buffer.slice(0, this.cursor) + key + this.buffer.slice(this.cursor);
1781
+ this.cursor += key.length;
1782
+ this.paletteIndex = 0;
1783
+ } else {
1784
+ return;
1785
+ }
1786
+ }
1787
+
1788
+ this.render();
1789
+ }
1790
+
1791
+ // -- painting ------------------------------------------------------------
1792
+
1793
+ render() {
1794
+ if (this.closed) return;
1795
+ if (this.welcoming()) {
1796
+ this.renderWelcome();
1797
+ return;
1798
+ }
1799
+
1800
+ const width = this.width();
1801
+ const height = this.viewportHeight();
1802
+
1803
+ // Scrolled back, the view stays on what is being read while output grows
1804
+ // underneath: every line added since the last paint extends the scroll by
1805
+ // the same amount, so the same rows stay on screen. Snapping to the
1806
+ // bottom on every streamed line fought the wheel sixteen times a second.
1807
+ // A streamed reply truncates its tail and rebuilds it on every delta, so
1808
+ // the growth since the last paint is net — but the tail sits below a
1809
+ // scrolled-back viewport, and net growth still moves the scroll by exactly
1810
+ // the amount that keeps the visible rows put.
1811
+ if (this.scroll > 0 && this.paintedLines != null) {
1812
+ const grown = this.lines.length - this.paintedLines;
1813
+ this.scroll = Math.min(Math.max(0, this.scroll + grown), Math.max(0, this.lines.length - height));
1814
+ }
1815
+ this.paintedLines = this.lines.length;
1816
+
1817
+ // The live line is the last line of the conversation, not a fixture above
1818
+ // the input box. Pinned down there it sat at the bottom of the screen while
1819
+ // the message that started it was at the top, with the empty middle of the
1820
+ // viewport between them — so the thing being done looked unrelated to the
1821
+ // thing that had been asked. On the end of the transcript it arrives
1822
+ // directly under the prompt, which is where the eye already is.
1823
+ const live = this.activityLine(width);
1824
+ const said = live ? [...this.lines, live] : this.lines;
1825
+
1826
+ const end = Math.max(0, said.length - this.scroll);
1827
+ const start = Math.max(0, end - height);
1828
+ const window = said.slice(start, end);
1829
+ while (window.length < height) window.push('');
1830
+ // A popup sits on the conversation's last rows, directly over the input box.
1831
+ const pop = this.popupLines(width, height);
1832
+ window.splice(Math.max(0, window.length - pop.length), pop.length, ...pop.slice(-height));
1833
+
1834
+ const frame = [
1835
+ ...this.headerLines(),
1836
+ '',
1837
+ ...window,
1838
+ // Always one clear row between the last thing said and the box you type
1839
+ // in. Without it the newest line of output sits against the border and
1840
+ // reads as part of the input rather than as the answer above it.
1841
+ '',
1842
+ ...this.inputBox(),
1843
+ ];
1844
+
1845
+ // The cursor is hidden for the duration of the paint. Without this it is
1846
+ // dragged through every line as the frame is written, which shows up as a
1847
+ // dot flickering above the input box on every keystroke.
1848
+ const out = [PAINT_BEGIN, HIDE];
1849
+ for (let i = 0; i < this.rows; i++) {
1850
+ out.push(at(i + 1, 1) + CLEAR_LINE + padVis(frame[i] ?? '', width));
1851
+ }
1852
+
1853
+ const [row, col] = this.caret();
1854
+ out.push(at(row, col) + SHOW + PAINT_END);
1855
+ this.paintedBusy = this.busy();
1856
+ this.output.write(out.join(''));
1857
+ }
1858
+
1859
+ /**
1860
+ * Where the typing caret belongs, 1-based.
1861
+ *
1862
+ * Column three is the first character inside the box: border, a space of
1863
+ * padding, then the text.
1864
+ */
1865
+ caret() {
1866
+ if (this.welcoming()) {
1867
+ const g = this.welcomeGeometry();
1868
+ const { row, col } = this.caretAt(g.boxWidth - 4);
1869
+ // g.boxTop is 0-based and the typed lines start one below the border.
1870
+ return [g.boxTop + 2 + row, g.left + 3 + col];
1871
+ }
1872
+
1873
+ const { row, col: at, rows } = this.caretAt();
1874
+ const col = 3 + at;
1875
+ // Counting up from the bottom: the box border is the last row, the status
1876
+ // row is above it, then the blank row, then the typed lines.
1877
+ const firstRow = this.rows - 2 - rows.length;
1878
+ return [firstRow + row, col];
1879
+ }
1880
+
1881
+ // -- start screen ----------------------------------------------------------
1882
+
1883
+ /**
1884
+ * Nothing has been said yet, so there is nothing to scroll: the screen is the
1885
+ * wordmark and the place to type, centred, and nothing else.
1886
+ *
1887
+ * It comes back after /clear and /new too, since those empty the transcript —
1888
+ * a fresh conversation starts from the same quiet screen as a fresh launch.
1889
+ */
1890
+ welcoming() {
1891
+ return this.lines.length === 0;
1892
+ }
1893
+
1894
+ /** Where everything on the start screen goes, 0-based rows. */
1895
+ welcomeGeometry() {
1896
+ const cols = this.width();
1897
+ const boxWidth = Math.max(30, Math.min(cols - 4, 84));
1898
+ const left = Math.max(0, Math.floor((cols - boxWidth) / 2));
1899
+ const big = cols >= BANNER_WIDTH + 4 && this.rows >= 18;
1900
+ const art = big ? BANNER : ['u c o d e'];
1901
+ const inputRows = this.inputLines(boxWidth - 4).rows.length;
1902
+ const boxRows = inputRows + 4; // borders, typed rows, gap, status
1903
+ const suggest = this.rows >= boxRows + art.length + SUGGEST_ROWS + 6;
1904
+ const block = art.length + 2 + boxRows + (suggest ? SUGGEST_ROWS : 0);
1905
+ // A touch above true centre reads as centred; exact centre looks low.
1906
+ const top = Math.max(0, Math.floor((this.rows - block) / 2) - 1);
1907
+ return { cols, boxWidth, left, big, art, inputRows, boxRows, suggest, top, boxTop: top + art.length + 2 };
1908
+ }
1909
+
1910
+ renderWelcome() {
1911
+ const g = this.welcomeGeometry();
1912
+ const frame = new Array(this.rows).fill('');
1913
+
1914
+ // The wordmark lit from the top: sky at the crown, deep in the shadow
1915
+ // rows. Across the rows rather than along them — a name split down its
1916
+ // middle reads as two words, where a name that fades downward reads as
1917
+ // one object with a light on it.
1918
+ const elapsed = this.intro ? Date.now() - this.intro : Infinity;
1919
+ g.art.forEach((line, i) => {
1920
+ const pad = ' '.repeat(Math.max(0, Math.floor((g.cols - line.length) / 2)));
1921
+ frame[g.top + i] = pad + (g.big
1922
+ ? bannerSweep(line, i, g.art.length, elapsed)
1923
+ : blue.bold(line));
1924
+ });
1925
+
1926
+ const indent = ' '.repeat(g.left);
1927
+ this.inputBox(g.boxWidth).forEach((row, i) => {
1928
+ frame[g.boxTop + i] = indent + row;
1929
+ });
1930
+
1931
+ // A popup goes above the box, over the wordmark if it has to.
1932
+ const pop = this.popupLines(g.boxWidth, Math.max(3, g.boxTop));
1933
+ pop.forEach((row, i) => {
1934
+ const at = g.boxTop - pop.length + i;
1935
+ if (at >= 0) frame[at] = indent + row;
1936
+ });
1937
+
1938
+ // Three things to try, aligned with the text inside the box above them.
1939
+ if (g.suggest) {
1940
+ const at = g.boxTop + g.boxRows + 1;
1941
+ SUGGESTIONS.forEach((text, i) => {
1942
+ const label = i === 0 ? 'try'.padEnd(SUGGEST_LABEL) : ' '.repeat(SUGGEST_LABEL);
1943
+ frame[at + i] = `${indent} ${dim(sky(label))}${dim(clip(text, Math.max(8, g.boxWidth - SUGGEST_LABEL - 2)))}`;
1944
+ });
1945
+ }
1946
+
1947
+ // The version, in the corner, and nothing else on the screen.
1948
+ if (VERSION) {
1949
+ const tag = dim(`${CREDIT} · ${this.facts.update ? `v${VERSION} · v${this.facts.update} installed, starts next time` : `v${VERSION}`}`);
1950
+ frame[this.rows - 1] = ' '.repeat(Math.max(0, g.cols - visLen(tag) - 2)) + tag;
1951
+ }
1952
+
1953
+ const out = [PAINT_BEGIN, HIDE];
1954
+ for (let i = 0; i < this.rows; i++) {
1955
+ out.push(at(i + 1, 1) + CLEAR_LINE + padVis(frame[i], g.cols));
1956
+ }
1957
+ const [row, col] = this.caret();
1958
+ out.push(at(row, col) + SHOW + PAINT_END);
1959
+ this.paintedBusy = this.busy();
1960
+ this.output.write(out.join(''));
1961
+ }
1962
+ }
1963
+
1964
+ /**
1965
+ * Split a raw stdin chunk into keys, keeping escape sequences whole.
1966
+ *
1967
+ * Application cursor key mode (DECCKM) makes a terminal send ESC O A for the
1968
+ * up arrow rather than ESC [ A. Both are normalised to the bracket form here
1969
+ * so the key handler only ever sees one of them.
1970
+ */
1971
+ /**
1972
+ * Did this arrive as a paste, judged by shape rather than by markers?
1973
+ *
1974
+ * Someone pressing Enter sends one carriage return on its own. A paste sends
1975
+ * a line break with text around it, in a single read. That difference is all
1976
+ * there is to go on when a terminal does not implement bracketed paste, and
1977
+ * it is enough.
1978
+ *
1979
+ * Anything carrying an escape sequence is left alone: that is a key or a
1980
+ * mouse report, and reading one as text would put gibberish in the input.
1981
+ */
1982
+ export function looksPasted(chunk) {
1983
+ const text = String(chunk ?? '');
1984
+ if (text.length < 2 || text.includes(ESC)) return false;
1985
+ const breaks = (text.match(/[\r\n]/g) ?? []).length;
1986
+ if (breaks === 0) return false;
1987
+ // One trailing break is someone finishing a line, not pasting one.
1988
+ if (breaks === 1 && /[\r\n]$/.test(text)) return false;
1989
+ return true;
1990
+ }
1991
+
1992
+ export function splitKeys(chunk) {
1993
+ const keys = [];
1994
+ let i = 0;
1995
+
1996
+ while (i < chunk.length) {
1997
+ const c = chunk[i];
1998
+ if (c !== ESC) { keys.push(c); i++; continue; }
1999
+
2000
+ const rest = chunk.slice(i);
2001
+ const csi = /^\x1b\[[0-9;?]*[A-Za-z~]/.exec(rest);
2002
+ if (csi) { keys.push(csi[0]); i += csi[0].length; continue; }
2003
+
2004
+ const ss3 = /^\x1bO([A-Za-z])/.exec(rest);
2005
+ if (ss3) { keys.push(`${ESC}[${ss3[1]}`); i += ss3[0].length; continue; }
2006
+
2007
+ keys.push(ESC);
2008
+ i++;
2009
+ }
2010
+
2011
+ return keys;
2012
+ }