ucode-agent 1.27.0 → 1.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ui/screen.js CHANGED
@@ -1,1484 +1,1574 @@
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 } from './theme.js';
43
- import { FRAME_MS, fitActivity, shimmer, spinnerGlyph, formatDuration, doneLine, stepPaint } from './activity.js';
44
- import { renderer, render, polish } from './markdown.js';
45
- import { VERSION } from '../core/version.js';
46
-
47
- /**
48
- * One line of narration, in the model's own words: "Reading screen.js".
49
- * Anything longer than this is prose, and prose belongs in the answer.
50
- */
51
- export const MAX_LABEL = 120;
52
-
53
- export function isLabel(text) {
54
- const t = String(text ?? '').trim();
55
- return t.length > 0 && t.length <= MAX_LABEL && !t.includes('\n');
56
- }
57
-
58
- export const COMMANDS = [
59
- '/help', '/model', '/models', '/session', '/sessions', '/resume',
60
- '/new', '/remember', '/skills', '/clear', '/search', '/copy', '/exit',
61
- '/stats', '/doctor', '/deploy', '/look',
62
- ];
63
-
64
- // ANSI ----------------------------------------------------------------------
65
- const ESC = '\x1b';
66
- const ALT_ON = `${ESC}[?1049h`;
67
- const ALT_OFF = `${ESC}[?1049l`;
68
-
69
- /**
70
- * Mouse setup, decided by measurement rather than by documentation.
71
- *
72
- * 1007 is alternate scroll: inside the alternate screen the terminal turns
73
- * wheel events into arrow keys. On Windows that is the only way a wheel ever
74
- * reaches the program, because ConPTY forwards no mouse input at all — a probe
75
- * that enabled every tracking mode received nothing from a scroll.
76
- *
77
- * And mouse tracking suppresses alternate scroll. So on Windows tracking is
78
- * deliberately not requested: it delivers nothing there, and asking for it
79
- * would cost the wheel. Elsewhere tracking works, so the mode chip is
80
- * clickable on those platforms.
81
- */
82
- const TRACK = process.platform === 'win32'
83
- ? '' : `${ESC}[?1000h${ESC}[?1002h${ESC}[?1015h${ESC}[?1006h`;
84
- const UNTRACK = process.platform === 'win32'
85
- ? '' : `${ESC}[?1006l${ESC}[?1015l${ESC}[?1002l${ESC}[?1000l`;
86
-
87
- const PASTE_ON = `${ESC}[?2004h`;
88
- const PASTE_OFF = `${ESC}[?2004l`;
89
- const MOUSE_ON = `${ESC}[?1007h${TRACK}`;
90
- const MOUSE_OFF = `${UNTRACK}${ESC}[?1007l`;
91
- const HIDE = `${ESC}[?25l`;
92
- const SHOW = `${ESC}[?25h`;
93
- const HOME = `${ESC}[H`;
94
- const CLEAR_LINE = `${ESC}[K`;
95
- /** Written out rather than inline, so no edit can turn it into a real break. */
96
- const NEWLINE = String.fromCharCode(10);
97
- const at = (row, col) => `${ESC}[${row};${col}H`;
98
- const title = (t) => `${ESC}]0;${t}\x07`;
99
-
100
- /**
101
- * Fixed rows below the header: the gap under it, the gap above the input box,
102
- * the input box's two borders, the blank row inside it, and the status row.
103
- */
104
- const CHROME_BELOW = 6;
105
-
106
- /** How long one sentence of reasoning holds the line before the next takes it. */
107
- const THOUGHT_HOLD_MS = 1100;
108
-
109
- /** Reasoning that is about the request rather than about the work. */
110
- 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;
111
-
112
- /** The wordmark only earns its place with room for the facts column beside it. */
113
- const WORDMARK_NEEDS = BANNER_WIDTH + 30;
114
-
115
- /** Where the U ends and CODE begins in each row of the wordmark. */
116
- const WORDMARK_SPLIT = 9;
117
-
118
- /** What the empty input box says before anything is typed. */
119
- const PLACEHOLDER = 'Ask anything…';
120
-
121
- export class Screen {
122
- constructor({ cwd, input = process.stdin, output = process.stdout } = {}) {
123
- this.cwd = cwd;
124
- this.input = input;
125
- this.output = output;
126
-
127
- this.lines = []; // the rendered transcript
128
- this.scroll = 0; // rows scrolled up from the bottom
129
- this.buffer = ''; // what is being typed
130
- this.cursor = 0;
131
- this.history = [];
132
- this.historyIndex = -1;
133
-
134
- this.status = { busy: false, text: '', frame: 0, since: 0 };
135
- this.facts = {};
136
- this.model = '';
137
-
138
- this.waiters = [];
139
- this.queue = [];
140
- this.closed = false;
141
-
142
- // 'build' may edit and run; 'plan' is read-only. Ctrl+B swaps them, and
143
- // the chip is clickable wherever the terminal forwards clicks.
144
- this.mode = 'build';
145
- this.chipTo = 0;
146
- this.onInterrupt = null;
147
- this.onModeChange = null;
148
- this.spinTimer = null;
149
- this.activity = null; // the turn in flight: when it began, how many steps
150
- this.tick = 0; // animation frames painted, for the spinner
151
- this.pendingPrompt = null;
152
-
153
- this.cols = output.columns || 80;
154
- this.rows = output.rows || 24;
155
- this.md = renderer(this.width());
156
- }
157
-
158
- // -- lifecycle -----------------------------------------------------------
159
-
160
- async start() {
161
- ensureColour(this.output);
162
- this.output.write(ALT_ON + MOUSE_ON + PASTE_ON + HIDE + title(`ucode — ${path.basename(this.cwd)}`));
163
- this.input.setRawMode?.(true);
164
- this.input.resume();
165
- this.input.setEncoding('utf8');
166
- this.input.on('data', (chunk) => this.onData(chunk));
167
-
168
- this.onResize = () => {
169
- this.cols = this.output.columns || 80;
170
- this.rows = this.output.rows || 24;
171
- this.md = renderer(this.width());
172
- this.render();
173
- };
174
- this.output.on('resize', this.onResize);
175
-
176
- this.render();
177
- }
178
-
179
- stop() {
180
- this.activity = null;
181
- this.stopSpinner();
182
- this.stopTimer();
183
- this.output.off?.('resize', this.onResize);
184
- this.input.setRawMode?.(false);
185
- this.input.pause();
186
- this.output.write(PASTE_OFF + MOUSE_OFF + ALT_OFF + SHOW);
187
- }
188
-
189
- close() {
190
- if (this.closed) return;
191
- this.closed = true;
192
- this.stop();
193
- while (this.waiters.length) this.waiters.shift()(null);
194
- }
195
-
196
- width() {
197
- return Math.max(30, this.cols);
198
- }
199
-
200
- /** Usable width inside a box: two borders and a space of padding each side. */
201
- inner() {
202
- return Math.max(8, this.width() - 4);
203
- }
204
-
205
- // -- transcript ----------------------------------------------------------
206
-
207
- /**
208
- * Append without painting.
209
- *
210
- * Anything replacing a region of the transcript has to build the whole
211
- * region and then render once. Painting between the delete and the re-add
212
- * puts a frame on screen with the text missing, and at streaming speed that
213
- * reads as flicker.
214
- */
215
- add(text = '') {
216
- const width = this.width();
217
- for (const raw of String(text).split('\n')) {
218
- if (visLen(raw) <= width) this.lines.push(raw);
219
- else for (const wrapped of wrapAnsi(raw, width)) this.lines.push(wrapped);
220
- }
221
- this.scroll = 0; // new output snaps back to the bottom
222
- }
223
-
224
- push(text = '') {
225
- this.add(text);
226
- this.soon();
227
- }
228
-
229
- /**
230
- * Collapse a burst of pushes into one frame.
231
- *
232
- * Printing a list one line at a time repaints the screen per line — a model
233
- * list of fifty entries drew a hundred frames back to back, which is visible
234
- * as a cascade. A microtask runs before any I/O, so everything pushed in one
235
- * synchronous stretch becomes a single render, while a push after an await
236
- * still paints immediately.
237
- */
238
- soon() {
239
- if (this.queued) return;
240
- this.queued = true;
241
- queueMicrotask(() => {
242
- this.queued = false;
243
- this.render();
244
- });
245
- }
246
-
247
- write(text = '') { this.push(text); }
248
- blank() { this.push(''); }
249
- note(text) { this.push(dim(` ${text}`)); }
250
-
251
- clearScreen() {
252
- this.lines = [];
253
- this.scroll = 0;
254
- this.render();
255
- }
256
-
257
- /**
258
- * The reply, at full strength, with room either side.
259
- *
260
- * `closing` says this is the last thing the turn will say. It is then also
261
- * the last thing left on screen, and what the whole session reads like
262
- * afterwards, so it is cut to eight lines — see trimAnswer.
263
- */
264
- assistant(text, { closing = false } = {}) {
265
- if (!text?.trim()) return;
266
- const body = closing ? trimAnswer(tidyReply(text)) : tidyReply(text);
267
- if (!body.trim()) return;
268
- this.endRun();
269
- this.add('');
270
- this.add(render(this.md, body));
271
- this.add('');
272
- this.render();
273
- }
274
-
275
- /**
276
- * Something the user said, in the conversation, in the same blue box as the
277
- * input it was typed into.
278
- *
279
- * A long session is mostly the agent's output — tool calls, diffs, answers.
280
- * Your own messages are the landmarks you scroll back looking for, so they
281
- * get the frame: every one of them is findable at a glance, and the box
282
- * matches the one below so it is plain where each came from.
283
- */
284
- userMessage(text) {
285
- const width = this.width();
286
- const room = Math.max(8, width - 6); // borders, padding, and the caret column
287
-
288
- const rows = [];
289
- for (const paragraph of String(text).replace(/\r/g, '').split('\n')) {
290
- for (const line of wrapAnsi(paragraph, room)) rows.push(line);
291
- }
292
-
293
- this.add('');
294
- this.add(boxTop(width, edge));
295
- rows.forEach((row, i) => {
296
- const lead = i === 0 ? blue('›') : ' ';
297
- this.add(boxRow(` ${lead} ${chalk.white(row)}`, width, edge));
298
- });
299
- this.add(boxBottom(width, edge));
300
- // Room between what you asked for and what came back. Without it the reply
301
- // starts against the bottom of your own message and the two read as one
302
- // block of text.
303
- this.add('');
304
- this.add('');
305
- this.render();
306
- }
307
-
308
- /**
309
- * A tool call, as it happens: "● Listing src".
310
- *
311
- * This lives in the transcript rather than only on the status line. The
312
- * status line overwrites itself and is empty by the end of the turn, so work
313
- * announced only there scrolls past unseen — and the diff underneath ends up
314
- * with nothing above it explaining where it came from.
315
- */
316
- toolCall(label) {
317
- // U+25CF, not U+23FA: the latter carries emoji presentation, which Windows
318
- // Terminal draws as a white circle on a blue tile.
319
- const kind = groupKind(label);
320
- // One line per kind of work for as long as the model is working on one
321
- // thing. Reading, writing and reading again used to draw six lines that
322
- // said three things; now the "Reading files" line it already has is the
323
- // one that counts up, wherever it sits.
324
- const run = (this.segment ??= new Map()).get(kind);
325
-
326
- if (run && this.lines[run.at] !== undefined) {
327
- run.count++;
328
- run.label = label;
329
- run.targets.push(groupTarget(label));
330
- this.run = run;
331
- this.paintRun();
332
- } else {
333
- this.push(`${narrationMark()} ${narration(asLabel(label))}`);
334
- this.run = {
335
- kind, count: 1, at: this.lines.length - 1, label,
336
- targets: [groupTarget(label)], added: 0, removed: 0,
337
- };
338
- this.segment.set(kind, this.run);
339
- this.paintRun();
340
- }
341
- this.updateSpinner(label);
342
- }
343
-
344
- /**
345
- * The model speaking — or a plan, or a failure — ends the segment.
346
- *
347
- * Up to that point a kind of work keeps one line and counts up on it. After
348
- * it, the next read is a new piece of work and deserves its own line, which
349
- * is what makes the transcript read as a sequence of things done rather
350
- * than a set of running totals.
351
- */
352
- /**
353
- * Stop adding to the current run, but keep the lines already on screen.
354
- *
355
- * A kind of work gets one line for the whole turn. Starting a fresh set
356
- * whenever the model spoke meant "Creating Tide from the HTML starter" five
357
- * times down the page and "Reading files" four, each saying the same thing
358
- * about a different moment. One line that counts up says all of it and
359
- * costs one row.
360
- */
361
- endRun() { this.run = null; }
362
-
363
- /** A new turn starts with a clean page's worth of lines. */
364
- newSegment() { this.run = null; this.segment = new Map(); }
365
-
366
- /** Redraw the run's single line from what it has accumulated. */
367
- /**
368
- * Redraw the run's single line from what it has accumulated.
369
- *
370
- * While its step is still running the text shimmers, which is the only
371
- * thing on screen saying "this is happening now" once the per-step result
372
- * lines are gone. It settles to plain dim the moment the step finishes, so
373
- * the finished ones above stay quiet.
374
- */
375
- paintRun() {
376
- if (!this.run) return;
377
- this.lines[this.run.at] = `${narrationMark()} ${narration(asLabel(runLine(this.run)))}`;
378
- this.render();
379
- }
380
-
381
- /**
382
- * A change, as its two numbers.
383
- *
384
- * The diff itself used to go into the transcript. A 539-line file printed
385
- * there buries the answer under a copy of something already on disk, so
386
- * what is kept is the shape of the change: how much arrived, how much left.
387
- */
388
- diffStat({ added = 0, removed = 0 } = {}) {
389
- if (!this.run) return;
390
- this.run.added += added;
391
- this.run.removed += removed;
392
- this.paintRun();
393
- }
394
-
395
- /** The checklist, when the model updates it. One line, wrapped if it must. */
396
- plan(items) {
397
- const rows = planRows(items);
398
- if (!rows.length) return;
399
- this.endRun(); // a plan is not another step of whatever came before
400
- for (const row of rows) this.push(row);
401
- }
402
-
403
- /**
404
- * What came of a step.
405
- *
406
- * Nothing goes underneath the bullet any more: a line of its own for every
407
- * result doubles the height of the transcript to say "ok". The bullet
408
- * already names the step, and a change adds its numbers to that same line.
409
- * Only a failure earns a line of its own.
410
- */
411
- toolResult() {}
412
-
413
- /**
414
- * Something went wrong, and the model is the one who can do anything about it.
415
- *
416
- * A red line of machinery — a failed edit, a command that exited non-zero —
417
- * reads as the tool being broken, when almost always it is a step the model
418
- * corrects on its own a second later. It goes to the model; the screen stays
419
- * for what is being built. Whatever is genuinely unrecoverable surfaces as
420
- * the model saying so in words, which is the form worth reading.
421
- */
422
- toolFailed() {
423
- this.endRun();
424
- }
425
-
426
- /**
427
- * The change itself, under the result.
428
- *
429
- * A line-number gutter, then the sign and the code tinted right across the
430
- * row. The numbers are the point: a diff you cannot navigate from is a
431
- * picture of a change rather than a record of one.
432
- */
433
- diff(lines) {
434
- const gutter = 6;
435
- // Two spaces of indent, the gutter, one space, then the tint fills the
436
- // rest. One column over and every row wraps, splitting the whole diff.
437
- const room = Math.max(12, this.width() - gutter - 3);
438
-
439
- for (const line of lines) {
440
- // A file heading in a multi-file write.
441
- if (line.startsWith('~')) {
442
- this.add(` ${dim(' '.repeat(gutter))} ${sky(line.slice(1))}`);
443
- continue;
444
- }
445
-
446
- const added = line.startsWith('+');
447
- const rest = line.slice(1);
448
- // Tools emit "<line>| <text>". A row with no number is the "12 more
449
- // lines" note, which is not part of the change, so it stays dim.
450
- const parsed = /^(\d+)\|\s?([\s\S]*)$/.exec(rest);
451
- if (!parsed) {
452
- this.add(` ${dim(' '.repeat(gutter))} ${dim(rest)}`);
453
- continue;
454
- }
455
-
456
- const [, number, body] = parsed;
457
- const tint = added ? ADDED : REMOVED;
458
- this.add(
459
- ` ${dim(number.padStart(gutter))} ` +
460
- // Tabs would leave the tint ending short of the row, so they widen.
461
- tint(padVis(clip(`${added ? '+' : '-'} ${body.replace(/\t/g, ' ')}`, room), room))
462
- );
463
- }
464
- this.render(); // a sixteen-line diff is one frame, not sixteen
465
- }
466
-
467
- /** Captured output under a command, dimmed so it reads as evidence. */
468
- commandOutput(lines) {
469
- for (const line of lines) this.add(` ${dim(line)}`);
470
- this.render();
471
- }
472
-
473
- /**
474
- * A running command's output, live — on the status line and nowhere else.
475
- *
476
- * Only the newest line, gone as soon as the next arrives. Appending each one
477
- * instead would mean a test run leaving sixty lines of "ok" in the
478
- * conversation permanently, which is noise the moment it scrolls. What
479
- * survives a command is decided when it ends: nothing if it worked, the tail
480
- * if it did not.
481
- */
482
- progress(lines) {
483
- const last = lines[lines.length - 1]?.trim();
484
- if (last) this.updateSpinner(last);
485
- }
486
-
487
- /**
488
- * The model's own account of the step it is taking, before it takes it.
489
- *
490
- * Not called status(): `this.status` holds the spinner state, and a method
491
- * of the same name would be shadowed by it on every instance.
492
- */
493
- narrate(text) {
494
- const line = asLabel(text);
495
- if (!line) return;
496
- this.push(dim(` ⋮ ${clip(line, this.width() - 6)}`));
497
- this.updateSpinner(line);
498
- }
499
-
500
- // -- streaming -----------------------------------------------------------
501
- // Deltas appear as plain text as they arrive, then get replaced in place by
502
- // properly rendered markdown once the reply is complete.
503
-
504
- streamBegin() {
505
- this.stopSpinner();
506
- this.streamAt = this.lines.length;
507
- this.streamBuf = '';
508
- this.streamPainted = 0;
509
- }
510
-
511
- streamDelta(delta) {
512
- if (this.streamAt === undefined) this.streamBegin();
513
- this.streamBuf += delta;
514
- const now = Date.now();
515
- if (now - this.streamPainted < 60) return; // about 16fps is plenty
516
- this.streamPainted = now;
517
- this.repaintStream();
518
- }
519
-
520
- /**
521
- * Repaint the partial reply.
522
- *
523
- * polish() runs on the partial text so bold, inline code and bullets are
524
- * already styled while it streams. Without it the text arrives raw and then
525
- * visibly re-renders at the end, which reads as a glitch.
526
- */
527
- repaintStream() {
528
- this.lines.length = this.streamAt;
529
- this.add('');
530
- this.add(polish(this.streamBuf));
531
- this.render(); // one frame, and never one without the reply in it
532
- }
533
-
534
- /**
535
- * Finish a streamed reply.
536
- *
537
- * `asLabel` says the text turned out to be narration ahead of a tool call
538
- * rather than an answer, in which case one short line folds down into the
539
- * status line it was always meant to be.
540
- */
541
- streamEnd({ asNarration = false, closing = false } = {}) {
542
- if (this.streamAt === undefined) return '';
543
- const text = this.streamBuf;
544
- this.lines.length = this.streamAt;
545
- this.streamAt = undefined;
546
- this.streamBuf = '';
547
-
548
- if (asNarration && isLabel(text)) this.narrate(text);
549
- else if (text.trim()) this.assistant(text, { closing });
550
- else this.render();
551
- return text;
552
- }
553
-
554
- // -- thinking ------------------------------------------------------------
555
- // A reasoning model does all its working before it says anything. None of it
556
- // is printed: it is long, repetitive, and guesses drawn from it read worse
557
- // than silence. The spinner counts the seconds so the wait is visibly alive,
558
- // and the transcript gets one line afterwards saying how long it took.
559
-
560
- /**
561
- * The model's reasoning does not go on screen.
562
- *
563
- * It was surfaced here to fill the wait before the first tool call, and what
564
- * it actually filled it with was the model talking to itself: "I need to
565
- * build this", "The user wants a tasks app". Nobody needs their own request
566
- * read back to them, and half-formed working-out is not something to publish.
567
- * What the model *says* is its reply, and that is the only thing shown.
568
- */
569
- thinkingDelta() {}
570
-
571
- thinkingEnd() {}
572
-
573
- error(err, { debug = false } = {}) {
574
- const known = err && typeof err === 'object' && err.attempted;
575
- this.push('');
576
- if (known) {
577
- this.push(`${theme.error('✗')} ${chalk.white(`Failed while ${err.attempted}.`)}`);
578
- this.push(` ${err.failed}`);
579
- if (err.fix) this.push(` ${blue('→')} ${err.fix}`);
580
- if (err.kind) this.push(dim(` (${err.kind})`));
581
- } else {
582
- this.push(`${theme.error('✗')} ${chalk.white('Something broke inside ucode.')}`);
583
- this.push(` ${err?.message ?? String(err)}`);
584
- this.push(` ${blue('→')} That is a bug in ucode rather than in your project. Re-run with --debug.`);
585
- }
586
- if (debug) {
587
- const stack = (known && err.cause?.stack) || err?.stack;
588
- if (stack) this.push(dim(stack));
589
- }
590
- this.push('');
591
- }
592
-
593
- // -- header --------------------------------------------------------------
594
-
595
- setFacts(facts) {
596
- this.facts = { ...this.facts, ...facts };
597
- if (facts.model) this.model = facts.model;
598
- this.render();
599
- }
600
-
601
- /** Same shape as the plain UI's header(), so the loop needs no branch. */
602
- header({ cwd, model, used, limit, title: sessionTitle }) {
603
- this.setFacts({
604
- cwd,
605
- model,
606
- title: sessionTitle,
607
- percent: limit > 0 ? Math.min(100, Math.round((used / limit) * 100)) : 0,
608
- });
609
- }
610
-
611
- /**
612
- * How many rows the header box occupies.
613
- *
614
- * The frame has to be exactly as tall as the terminal or every row below the
615
- * shortfall is off by that much — including the one the caret is parked on.
616
- * So this is derived, never assumed.
617
- */
618
- headerHeight() {
619
- return this.width() >= WORDMARK_NEEDS ? BANNER.length + 2 : 5;
620
- }
621
-
622
- headerLines() {
623
- const width = this.width();
624
- const inner = width - 2; // between the borders
625
-
626
- if (width < WORDMARK_NEEDS) {
627
- // Too narrow for the wordmark: stack it rather than wrap it into noise.
628
- const rows = [
629
- ` ${blue.bold('U C O D E')} ${dim('terminal coding agent')}`,
630
- ` ${dim('dir'.padEnd(8))}${chalk.white(clip(shortenPath(this.facts.cwd ?? this.cwd, inner - 12), inner - 12))}`,
631
- ];
632
- return [boxTop(width), ...rows.map((r) => boxRow(r, width)), boxBottom(width)];
633
- }
634
-
635
- // Two spaces of padding, the wordmark, a gap, then the facts column.
636
- //
637
- // Only what you cannot work out by looking: where you are, and how to get
638
- // help. How full the window is belongs on the status row next to the model
639
- // it describes, and the session title is already the terminal's own window
640
- // title — repeating either here is a second place to keep in sync for no
641
- // reader who needed it.
642
- const room = Math.max(8, inner - BANNER_WIDTH - 6);
643
- const facts = [
644
- ['dir', shortenPath(this.facts.cwd ?? this.cwd, room - 9)],
645
- ['keys', '/help · esc interrupts'],
646
- ['', ''],
647
- ['', ''],
648
- ['', ''],
649
- ['', 'made with ❤️ by om dixit'],
650
- ];
651
-
652
- const rows = BANNER.map((art, i) => {
653
- const [label, value] = facts[i] ?? ['', ''];
654
- const right = label
655
- ? `${dim(label.padEnd(9))}${chalk.white(clip(value, room - 9))}`
656
- : (value ? dim(value) : '');
657
- return ` ${blue(art)} ${right}`;
658
- });
659
-
660
- return [boxTop(width), ...rows.map((r) => boxRow(r, width)), boxBottom(width)];
661
- }
662
-
663
- // -- input box -----------------------------------------------------------
664
-
665
- /** The typed line, wrapped to the inside of a box `width` characters across. */
666
- /**
667
- * The typed text, laid out as rows inside the box.
668
- *
669
- * A line break in the buffer is a row of its own before any wrapping is
670
- * considered. Slicing the text into fixed widths without looking for one
671
- * put the newline into the frame instead, and the terminal obeyed it — the
672
- * pasted text walked out of the box and over the transcript beside it.
673
- *
674
- * `starts` records where each row begins in the text, so the caret can be
675
- * placed by looking up rather than by counting characters a second way and
676
- * hoping the two agree.
677
- */
678
- inputLines(width = this.inner()) {
679
- const prefix = this.pendingPrompt ? `${this.pendingPrompt} ` : '› ';
680
- const full = prefix + this.buffer;
681
-
682
- const rows = [];
683
- const starts = [];
684
- let at = 0;
685
-
686
- for (const para of full.split(NEWLINE)) {
687
- let i = 0;
688
- do {
689
- rows.push(para.slice(i, i + width));
690
- starts.push(at + i);
691
- i += width;
692
- } while (i < para.length);
693
- at += para.length + 1; // the newline itself
694
- }
695
-
696
- if (rows.length === 0) { rows.push(prefix); starts.push(0); }
697
- return { rows, prefix, width, starts };
698
- }
699
-
700
- /** Which row the caret sits on, and how far along it. */
701
- caretAt(width) {
702
- const { rows, prefix, starts } = this.inputLines(width);
703
- const index = prefix.length + this.cursor;
704
- let row = 0;
705
- while (row + 1 < starts.length && starts[row + 1] <= index) row++;
706
- return { row, col: Math.min(index - starts[row], rows[row].length), rows };
707
- }
708
-
709
- viewportHeight() {
710
- return Math.max(
711
- 3,
712
- this.rows - this.headerHeight() - CHROME_BELOW - this.inputLines().rows.length
713
- );
714
- }
715
-
716
- /**
717
- * The input box: what you are typing, and directly under it, inside the same
718
- * border, the three things worth knowing while you type.
719
- *
720
- * The status used to sit outside the box on the last row of the screen,
721
- * which made it a separate object floating under the input. Inside the
722
- * border it reads as part of the thing you are using — the box says "this is
723
- * where you work", and the row underneath says what you are working with.
724
- */
725
- inputBox(width = this.width()) {
726
- const { rows } = this.inputLines(width - 4);
727
- // Nothing typed yet: a quiet prompt where the text will go. The caret sits
728
- // on its first letter and typing replaces it.
729
- const empty = !this.buffer && !this.pendingPrompt;
730
- const painted = rows.map((row, i) =>
731
- i === 0
732
- ? boxRow(` ${blue('›')}${empty ? ` ${dim(PLACEHOLDER)}` : row.slice(1)}`, width, edge)
733
- : boxRow(` ${row}`, width, edge)
734
- );
735
- return [
736
- boxTop(width, edge),
737
- ...painted,
738
- // A blank row between the two. Sitting directly under the caret, the
739
- // status read as a second line of the thing being typed; one row of air
740
- // separates what you are writing from what you are writing it with.
741
- boxRow('', width, edge),
742
- boxRow(this.statusRow(width), width, edge),
743
- boxBottom(width, edge),
744
- ];
745
- }
746
-
747
- // -- status row ----------------------------------------------------------
748
-
749
- modeChip() {
750
- return this.mode === 'plan' ? `${sky('◇')} ${sky('Plan')}` : `${blue('◆')} ${blue('Build')}`;
751
- }
752
-
753
- /**
754
- * How full the context window is, as a bare number.
755
- *
756
- * It turns amber at 75% because that is where turns start being folded away
757
- * into a summary — the one moment the number predicts something you would
758
- * want to know before it happens.
759
- */
760
- percentChip() {
761
- const percent = Math.round(this.facts.percent ?? 0);
762
- return percent >= 75 ? theme.warn(`${percent}%`) : dim(`${percent}%`);
763
- }
764
-
765
- /**
766
- * Which mode is live, which model is answering, and how full the window is.
767
- *
768
- * Nothing else earns a place. The provider name was there and was cut: it is
769
- * the same on every line of every session, so it was decoration that had to
770
- * be read past to reach the two things that do change.
771
- *
772
- * The middle is borrowed while something is running, for the spinner and the
773
- * way out of it, and handed straight back when it finishes.
774
- */
775
- statusRow(width = this.width()) {
776
- const inner = width - 2; // the space between the two borders
777
- const chip = this.modeChip();
778
- const left = ` ${chip} ${dim('·')} ${chalk.white(this.model || '—')}`;
779
- const right = `${this.percentChip()} `;
780
-
781
- // Where a click on the bottom row still counts as hitting the mode chip.
782
- this.chipTo = 2 + visLen(chip);
783
-
784
- const between = Math.max(1, inner - visLen(left) - visLen(right));
785
-
786
- let middle = '';
787
- if (this.flashText) {
788
- middle = dim(clip(this.flashText, between - 2));
789
- } else if (this.status.busy || this.activity) {
790
- // The whole turn, not just the current tool: the timer and step count
791
- // keep going through the gaps between calls, so a long build never
792
- // looks like it has stopped.
793
- const now = Date.now();
794
- const a = this.activity;
795
- const since = a?.start ?? this.status.since ?? now;
796
- const meta = [];
797
- // No step count. It measures how much machinery ran, which is not
798
- // something the person waiting has any use for; the elapsed time is.
799
- void stepPaint;
800
- if (now - since >= 1000) meta.push({ text: formatDuration(now - since), keep: true });
801
- middle = fitActivity({
802
- glyph: spinnerGlyph(this.tick, now),
803
- label: this.status.busy ? this.status.text : 'working',
804
- meta,
805
- hint: 'esc to stop',
806
- paint: (s) => shimmer(s, now),
807
- }, between - 3);
808
- }
809
-
810
- // The percentage is pinned to the right border whatever is in the middle,
811
- // with a gap kept in front of it so a long spinner label cannot run into
812
- // the number and read as part of it.
813
- const tail = middle ? `${middle} ` : '';
814
- const pad = Math.max(1, inner - visLen(left) - visLen(tail) - visLen(right));
815
- return padVis(left + ' '.repeat(pad) + tail + right, inner);
816
- }
817
-
818
- /**
819
- * Repaint only the status row, leaving the caret where the user left it.
820
- *
821
- * It is the second row from the bottom now — the box's own border is below
822
- * it — so the row is written with its borders rather than as a bare line.
823
- */
824
- paintStatus() {
825
- if (this.closed) return;
826
- // On the start screen the status row is mid-screen, not second from the
827
- // bottom, so the cheap single-row repaint would draw it in the wrong place.
828
- if (this.welcoming()) {
829
- this.render();
830
- return;
831
- }
832
- const [row, col] = this.caret();
833
- this.output.write(
834
- HIDE +
835
- at(this.rows - 1, 1) + CLEAR_LINE + boxRow(this.statusRow(), this.width(), edge) +
836
- at(row, col) + SHOW
837
- );
838
- }
839
-
840
- toggleMode() {
841
- this.mode = this.mode === 'plan' ? 'build' : 'plan';
842
- this.flash(this.mode === 'plan'
843
- ? 'plan mode — reads and researches, changes nothing'
844
- : 'build mode — free to edit files and run commands');
845
- this.onModeChange?.(this.mode);
846
- this.render();
847
- }
848
-
849
- /** A message on the status line that fades on its own. */
850
- flash(text) {
851
- this.flashText = text;
852
- clearTimeout(this.flashTimer);
853
- this.flashTimer = setTimeout(() => {
854
- this.flashText = null;
855
- this.paintStatus();
856
- }, 2500);
857
- this.flashTimer.unref?.();
858
- this.paintStatus();
859
- }
860
-
861
- // -- spinner -------------------------------------------------------------
862
-
863
- startSpinner(text = 'thinking') {
864
- // `since` is what makes a long think legible: the label may not change for
865
- // a minute, so the seconds beside it are the proof it is still alive.
866
- this.status = { busy: true, text: asLabel(text), frame: 0, since: Date.now() };
867
- this.startTimer();
868
- this.paintStatus();
869
- }
870
-
871
- updateSpinner(text) {
872
- if (!this.status.busy) return;
873
- this.status.text = asLabel(text);
874
- this.paintStatus();
875
- }
876
-
877
- stopSpinner() {
878
- if (!this.activity) this.stopTimer();
879
- if (this.status.busy) {
880
- this.status = { busy: false, text: '', frame: 0, since: 0 };
881
- this.paintStatus();
882
- }
883
- }
884
-
885
- // -- the turn in flight ----------------------------------------------------
886
-
887
- /** A turn begins: the timer and step count run until turnEnd(). */
888
- turnStart() {
889
- this.newSegment();
890
- this.activity = { start: Date.now(), steps: 0, movedAt: 0 };
891
- this.startTimer();
892
- }
893
-
894
- /** One more model step in this turn. */
895
- step() {
896
- if (!this.activity) return;
897
- this.activity.steps++;
898
- this.activity.movedAt = Date.now();
899
- }
900
-
901
- /** The turn is over: leave "✓ Done in 6m 12s · 25 steps" under the answer. */
902
- turnEnd({ ok = true } = {}) {
903
- const a = this.activity;
904
- this.activity = null;
905
- if (!this.status.busy) this.stopTimer();
906
- // Nothing is written when a turn finishes. The reply is the end of the
907
- // turn, and a timing line under it is bookkeeping the reader did not ask
908
- // for. A turn that stopped *without* finishing still says so, because
909
- // silence there is indistinguishable from a crash.
910
- if (a && !ok && Date.now() - a.start >= 2000) {
911
- this.push(` ${doneLine(Date.now() - a.start, a.steps, { ok })}`);
912
- }
913
- this.paintStatus();
914
- }
915
-
916
- /** The animation clock: only the status row repaints, about twelve times a second. */
917
- startTimer() {
918
- if (this.spinTimer) return;
919
- this.spinTimer = setInterval(() => {
920
- // Only the status row repaints on a tick. Animating a transcript line
921
- // meant redrawing the whole frame twelve times a second, and the input
922
- // box was being rebuilt under the user's cursor as they typed.
923
- this.tick++;
924
- this.paintStatus();
925
- }, FRAME_MS);
926
- this.spinTimer.unref?.();
927
- }
928
-
929
- stopTimer() {
930
- if (!this.spinTimer) return;
931
- clearInterval(this.spinTimer);
932
- this.spinTimer = null;
933
- }
934
-
935
- // -- input ---------------------------------------------------------------
936
-
937
- nextLine() {
938
- if (this.queue.length) return Promise.resolve(this.queue.shift());
939
- if (this.closed) return Promise.resolve(null);
940
- return new Promise((resolve) => this.waiters.push(resolve));
941
- }
942
-
943
- ask() {
944
- return this.nextLine();
945
- }
946
-
947
- submit(text) {
948
- const waiter = this.waiters.shift();
949
- if (waiter) waiter(text);
950
- else this.queue.push(text);
951
- }
952
-
953
- /** y/n, answered on the input line. */
954
- confirm({ action, detail, risk }) {
955
- this.push('');
956
- this.push(`${chalk.inverse(theme.warn(risk === 'command' ? ' shell ' : ' outside project '))} ${chalk.white(action)}`);
957
- for (const line of String(detail ?? '').split('\n')) {
958
- if (line) this.push(dim(` ${line}`));
959
- }
960
-
961
- this.pendingPrompt = 'go ahead? [y/N]';
962
- this.render();
963
-
964
- return this.nextLine().then((answer) => {
965
- this.pendingPrompt = null;
966
- // End of input counts as no. Never run something nobody approved.
967
- const yes = /^(y|yes)$/i.test(String(answer ?? '').trim());
968
- this.push(dim(yes ? ' approved' : ' declined'));
969
- this.push('');
970
- return yes;
971
- });
972
- }
973
-
974
- /**
975
- * A modal list: arrows move, Enter picks, Esc cancels.
976
- *
977
- * Only while this is open do the arrows stop scrolling the transcript. They
978
- * cannot be given up permanently, because under alternate scroll the mouse
979
- * wheel arrives as arrow keys.
980
- */
981
- pick(items, { active = 0, hint = 'enter to choose · esc to cancel', deletable = false } = {}) {
982
- this.picker = {
983
- items,
984
- index: Math.min(Math.max(0, active), Math.max(0, items.length - 1)),
985
- hint,
986
- // With deletable, `d` twice on a row resolves { delete: index }. Twice,
987
- // because a single stray keypress should never cost a conversation.
988
- deletable,
989
- armed: null,
990
- };
991
- this.render();
992
- return new Promise((resolve) => { this.pickerResolve = resolve; });
993
- }
994
-
995
- closePicker(value) {
996
- const resolve = this.pickerResolve;
997
- this.picker = null;
998
- this.pickerResolve = null;
999
- this.render();
1000
- resolve?.(value);
1001
- }
1002
-
1003
- /**
1004
- * Rows for an open picker, windowed so a long list still fits.
1005
- *
1006
- * An item may carry a `sub` line — a second, dimmer row underneath it. That
1007
- * is what lets a list of saved conversations show what each one was actually
1008
- * about instead of a column of near-identical titles.
1009
- */
1010
- pickerLines(height) {
1011
- const { items, index, hint, armed } = this.picker;
1012
- const room = Math.max(1, height - 2);
1013
-
1014
- // Rows per item, so the window can be sized in rows rather than in items.
1015
- const rowsFor = (item) => (typeof item !== 'string' && item.sub ? 2 : 1);
1016
- const perItem = items.map(rowsFor);
1017
-
1018
- // Walk outward from the selection until the window is full. Starting from
1019
- // the selection guarantees it is on screen however long the list is.
1020
- let first = index;
1021
- let last = index;
1022
- let used = perItem[index] ?? 1;
1023
- while (used < room && (first > 0 || last < items.length - 1)) {
1024
- if (first > 0 && used + perItem[first - 1] <= room) { first--; used += perItem[first]; }
1025
- else if (last < items.length - 1 && used + perItem[last + 1] <= room) { last++; used += perItem[last]; }
1026
- else break;
1027
- }
1028
-
1029
- const out = [];
1030
- for (let i = first; i <= last; i++) {
1031
- const item = items[i];
1032
- const body = typeof item === 'string' ? item : item.label;
1033
- if (i === armed) out.push(`${theme.warn('✗')} ${theme.warn(bare(body))}`);
1034
- else out.push(i === index ? `${blue('❯')} ${chalk.bold.white(body)}` : ` ${dim(body)}`);
1035
- if (typeof item !== 'string' && item.sub) out.push(` ${item.sub}`);
1036
- }
1037
-
1038
- out.push('');
1039
- out.push(armed !== null && armed !== undefined
1040
- ? theme.warn(' press d again to delete this conversation · any other key keeps it')
1041
- : dim(` ${hint}`));
1042
- return out;
1043
- }
1044
-
1045
- /** A numbered list, answered on the input line. */
1046
- async choose(prompt, items, { allowNone = true } = {}) {
1047
- items.forEach((item, i) => this.push(` ${blue(String(i + 1).padStart(2))}. ${item}`));
1048
- if (allowNone) this.push(dim(' 0. none — start fresh'));
1049
- this.push('');
1050
-
1051
- this.pendingPrompt = prompt;
1052
- this.render();
1053
-
1054
- const answer = await this.nextLine();
1055
- this.pendingPrompt = null;
1056
-
1057
- const trimmed = String(answer ?? '').trim();
1058
- if (trimmed === '' || trimmed === '0') return null;
1059
-
1060
- const index = Number(trimmed);
1061
- if (!Number.isInteger(index) || index < 1 || index > items.length) {
1062
- this.push(theme.warn(` "${trimmed}" is not one of 1-${items.length}.`));
1063
- return null;
1064
- }
1065
- return index - 1;
1066
- }
1067
-
1068
- // -- keyboard and mouse --------------------------------------------------
1069
-
1070
- /**
1071
- * Scroll the transcript, clamped at both ends.
1072
- *
1073
- * When there is nothing above the fold, say so. Silence is indistinguishable
1074
- * from broken input, and the difference matters: one means the conversation
1075
- * simply fits, the other means the terminal is not forwarding keys at all.
1076
- */
1077
- scrollBy(delta) {
1078
- const max = Math.max(0, this.lines.length - this.viewportHeight());
1079
- if (max === 0) {
1080
- this.flash('nothing above — it all fits on screen');
1081
- return;
1082
- }
1083
- const before = this.scroll;
1084
- this.scroll = Math.min(Math.max(0, this.scroll + delta), max);
1085
- if (this.scroll === before && delta > 0) this.flash('already at the top');
1086
- this.render();
1087
- }
1088
-
1089
- /**
1090
- * Text arriving as a paste rather than as typing.
1091
- *
1092
- * A terminal in bracketed-paste mode wraps pasted text in markers, which is
1093
- * the only way to tell forty lines pasted at once from forty lines typed
1094
- * very fast. Without it every newline in the paste reads as Enter, so a
1095
- * pasted block submits itself a line at a time and arrives as forty
1096
- * messages. Inside the markers a newline is just a character.
1097
- */
1098
- onPaste(text) {
1099
- const clean = String(text).replace(/\r\n?/g, '\n');
1100
- this.buffer = this.buffer.slice(0, this.cursor) + clean + this.buffer.slice(this.cursor);
1101
- this.cursor += clean.length;
1102
- this.render();
1103
- }
1104
-
1105
- onData(chunk) {
1106
- // Pasted text first: it is wrapped in markers and must not be read as
1107
- // keys, or its newlines submit it in pieces.
1108
- const paste = /\[200~([\s\S]*?)\[201~/g;
1109
- if (paste.test(chunk)) {
1110
- paste.lastIndex = 0;
1111
- let at = 0;
1112
- let m;
1113
- while ((m = paste.exec(chunk))) {
1114
- if (m.index > at) this.onData(chunk.slice(at, m.index));
1115
- this.onPaste(m[1]);
1116
- at = m.index + m[0].length;
1117
- }
1118
- if (at < chunk.length) this.onData(chunk.slice(at));
1119
- return;
1120
- }
1121
- // An unterminated paste: hold what has arrived and wait for the rest.
1122
- const open = chunk.indexOf('[200~');
1123
- if (open !== -1) {
1124
- if (open > 0) this.onData(chunk.slice(0, open));
1125
- this.pasting = chunk.slice(open + 6);
1126
- return;
1127
- }
1128
- if (this.pasting !== undefined && this.pasting !== null) {
1129
- const close = chunk.indexOf('[201~');
1130
- if (close === -1) { this.pasting += chunk; return; }
1131
- this.onPaste(this.pasting + chunk.slice(0, close));
1132
- this.pasting = null;
1133
- const after = chunk.slice(close + 6);
1134
- if (after) this.onData(after);
1135
- return;
1136
- }
1137
-
1138
- // UCODE_DEBUG_KEYS=1 logs every byte the terminal sends to
1139
- // ~/.ucode/keys.log. Whether mouse reporting works at all depends on the
1140
- // terminal forwarding it; this is how to find out.
1141
- if (process.env.UCODE_DEBUG_KEYS) {
1142
- appendFile(path.join(homedir(), '.ucode', 'keys.log'), `${JSON.stringify(chunk)}\n`).catch(() => {});
1143
- }
1144
-
1145
- // Pull mouse reports out of the chunk wherever they sit. Anchoring the
1146
- // match to the whole chunk meant a wheel event arriving alongside any
1147
- // other byte was silently treated as typing.
1148
- let rest = '';
1149
- let index = 0;
1150
- // Two encodings: SGR (ESC [ < b ; x ; y M|m), and the legacy form
1151
- // (ESC [ M then three bytes offset by 32) for terminals that ignore 1006.
1152
- const mouse = /\x1b\[<(\d+);(\d+);(\d+)([Mm])|\x1b\[M([\s\S])([\s\S])([\s\S])/g;
1153
- let match;
1154
-
1155
- while ((match = mouse.exec(chunk)) !== null) {
1156
- rest += chunk.slice(index, match.index);
1157
- index = match.index + match[0].length;
1158
- if (match[1] !== undefined) {
1159
- this.onMouse(Number(match[1]), Number(match[2]), Number(match[3]), match[4]);
1160
- } else {
1161
- this.onMouse(
1162
- match[5].charCodeAt(0) - 32,
1163
- match[6].charCodeAt(0) - 32,
1164
- match[7].charCodeAt(0) - 32,
1165
- 'M'
1166
- );
1167
- }
1168
- }
1169
- rest += chunk.slice(index);
1170
-
1171
- // A chunk carrying a line break *and* other text did not come from a
1172
- // keyboard: nobody types a newline in the middle of a burst. Many
1173
- // terminals, Windows ones especially, send a paste with no markers at
1174
- // all, so without this every newline in it reads as Enter and the paste
1175
- // submits itself a line at a time.
1176
- if (looksPasted(rest)) { this.onPaste(rest); return; }
1177
-
1178
- for (const key of splitKeys(rest)) this.onKey(key);
1179
- }
1180
-
1181
- onMouse(button, col, row, press) {
1182
- // Wheel reports set bit 6; bit 0 says which way.
1183
- if (button >= 64) {
1184
- this.scrollBy(button % 2 === 0 ? 3 : -3);
1185
- return;
1186
- }
1187
- if (press !== 'M' || button !== 0) return;
1188
- if (this.welcoming()) {
1189
- const g = this.welcomeGeometry();
1190
- const statusRow = g.boxTop + g.inputRows + 3; // 1-based
1191
- if (row === statusRow && col > g.left + 1 && col <= g.left + this.chipTo) this.toggleMode();
1192
- return;
1193
- }
1194
- // The mode chip, at the left of the bottom row.
1195
- if (row === this.rows - 1 && col >= 2 && col <= this.chipTo) this.toggleMode();
1196
- }
1197
-
1198
- onKey(key) {
1199
- // An open picker owns the keyboard until it closes.
1200
- if (this.picker) {
1201
- const last = this.picker.items.length - 1;
1202
- if (this.picker.deletable && (key === 'd' || key === 'D' || key === `${ESC}[3~`)) {
1203
- if (this.picker.armed === this.picker.index) { this.closePicker({ delete: this.picker.index }); return; }
1204
- this.picker.armed = this.picker.index;
1205
- this.render();
1206
- return;
1207
- }
1208
- this.picker.armed = null; // any other key takes the delete back
1209
- if (key === `${ESC}[A`) { this.picker.index = Math.max(0, this.picker.index - 1); this.render(); return; }
1210
- if (key === `${ESC}[B`) { this.picker.index = Math.min(last, this.picker.index + 1); this.render(); return; }
1211
- if (key === '\r' || key === '\n') { this.closePicker(this.picker.index); return; }
1212
- if (key === ESC || key === '\x03') { this.closePicker(null); return; }
1213
- return;
1214
- }
1215
-
1216
- switch (key) {
1217
- case '\r':
1218
- case '\n': {
1219
- const text = this.buffer;
1220
- this.buffer = '';
1221
- this.cursor = 0;
1222
- this.historyIndex = -1;
1223
- if (text.trim()) {
1224
- this.history.unshift(text);
1225
- // Answers to a y/N or a numbered pick are not messages, so they are
1226
- // not echoed: the prompt reports its own outcome.
1227
- if (!this.pendingPrompt) this.userMessage(text);
1228
- }
1229
- this.render();
1230
- this.submit(text);
1231
- return;
1232
- }
1233
-
1234
- case '\x7f': // backspace
1235
- case '\b':
1236
- if (this.cursor > 0) {
1237
- this.buffer = this.buffer.slice(0, this.cursor - 1) + this.buffer.slice(this.cursor);
1238
- this.cursor--;
1239
- }
1240
- break;
1241
-
1242
- case '\x03': // ctrl+c
1243
- if (this.status.busy && this.onInterrupt) this.onInterrupt();
1244
- else { this.buffer = ''; this.cursor = 0; }
1245
- break;
1246
-
1247
- case '\x04': // ctrl+d
1248
- this.close();
1249
- return;
1250
-
1251
- case '\x02': // ctrl+b — swap plan and build
1252
- this.toggleMode();
1253
- return;
1254
-
1255
- case '\x15': // ctrl+u — clear the line
1256
- this.buffer = this.buffer.slice(this.cursor);
1257
- this.cursor = 0;
1258
- break;
1259
-
1260
- case ESC: // esc — stop the turn in flight
1261
- if (this.onInterrupt) this.onInterrupt();
1262
- return;
1263
-
1264
- case '\t': {
1265
- const hit = COMMANDS.find((c) => c.startsWith(this.buffer));
1266
- if (hit) { this.buffer = hit; this.cursor = hit.length; }
1267
- break;
1268
- }
1269
-
1270
- // With an empty line the arrows scroll the conversation; once there is
1271
- // something typed they walk history. Terminals often swallow PgUp and
1272
- // PgDn for their own scrollback, so this is the path that always works.
1273
- case `${ESC}[A`:
1274
- if (!this.buffer) { this.scrollBy(2); return; }
1275
- if (this.history.length) {
1276
- this.historyIndex = Math.min(this.historyIndex + 1, this.history.length - 1);
1277
- this.buffer = this.history[this.historyIndex] ?? '';
1278
- this.cursor = this.buffer.length;
1279
- }
1280
- break;
1281
-
1282
- case `${ESC}[B`:
1283
- if (!this.buffer) { this.scrollBy(-2); return; }
1284
- this.historyIndex = Math.max(this.historyIndex - 1, -1);
1285
- this.buffer = this.historyIndex === -1 ? '' : (this.history[this.historyIndex] ?? '');
1286
- this.cursor = this.buffer.length;
1287
- break;
1288
-
1289
- case `${ESC}[1;5A`: this.scrollBy(2); return; // ctrl+up
1290
- case `${ESC}[1;5B`: this.scrollBy(-2); return; // ctrl+down
1291
- case `${ESC}[5~`: this.scrollBy(this.viewportHeight()); return;
1292
- case `${ESC}[6~`: this.scrollBy(-this.viewportHeight()); return;
1293
-
1294
- case `${ESC}[H`: this.scrollBy(this.lines.length); return;
1295
- case `${ESC}[F`: this.scroll = 0; this.render(); return;
1296
-
1297
- case `${ESC}[C`: this.cursor = Math.min(this.cursor + 1, this.buffer.length); break;
1298
- case `${ESC}[D`: this.cursor = Math.max(this.cursor - 1, 0); break;
1299
-
1300
- default:
1301
- if (key >= ' ' && !key.startsWith(ESC)) {
1302
- this.buffer = this.buffer.slice(0, this.cursor) + key + this.buffer.slice(this.cursor);
1303
- this.cursor += key.length;
1304
- } else {
1305
- return;
1306
- }
1307
- }
1308
-
1309
- this.render();
1310
- }
1311
-
1312
- // -- painting ------------------------------------------------------------
1313
-
1314
- render() {
1315
- if (this.closed) return;
1316
- if (this.welcoming()) {
1317
- this.renderWelcome();
1318
- return;
1319
- }
1320
-
1321
- const width = this.width();
1322
- const height = this.viewportHeight();
1323
-
1324
- const end = Math.max(0, this.lines.length - this.scroll);
1325
- const start = Math.max(0, end - height);
1326
- const window = this.picker ? this.pickerLines(height) : this.lines.slice(start, end);
1327
- while (window.length < height) window.push('');
1328
-
1329
- const frame = [
1330
- ...this.headerLines(),
1331
- '',
1332
- ...window,
1333
- // Always one clear row between the last thing said and the box you type
1334
- // in. Without it the newest line of output sits against the border and
1335
- // reads as part of the input rather than as the answer above it.
1336
- '',
1337
- ...this.inputBox(),
1338
- ];
1339
-
1340
- // The cursor is hidden for the duration of the paint. Without this it is
1341
- // dragged through every line as the frame is written, which shows up as a
1342
- // dot flickering above the input box on every keystroke.
1343
- const out = [HIDE, HOME];
1344
- for (let i = 0; i < this.rows; i++) {
1345
- out.push(CLEAR_LINE + padVis(frame[i] ?? '', width) + (i === this.rows - 1 ? '' : '\n'));
1346
- }
1347
-
1348
- const [row, col] = this.caret();
1349
- out.push(at(row, col) + SHOW);
1350
- this.output.write(out.join(''));
1351
- }
1352
-
1353
- /**
1354
- * Where the typing caret belongs, 1-based.
1355
- *
1356
- * Column three is the first character inside the box: border, a space of
1357
- * padding, then the text.
1358
- */
1359
- caret() {
1360
- if (this.welcoming()) {
1361
- const g = this.welcomeGeometry();
1362
- const { row, col } = this.caretAt(g.boxWidth - 4);
1363
- // g.boxTop is 0-based and the typed lines start one below the border.
1364
- return [g.boxTop + 2 + row, g.left + 3 + col];
1365
- }
1366
-
1367
- const { row, col: at, rows } = this.caretAt();
1368
- const col = 3 + at;
1369
- // Counting up from the bottom: the box border is the last row, the status
1370
- // row is above it, then the blank row, then the typed lines.
1371
- const firstRow = this.rows - 2 - rows.length;
1372
- return [firstRow + row, col];
1373
- }
1374
-
1375
- // -- start screen ----------------------------------------------------------
1376
-
1377
- /**
1378
- * Nothing has been said yet, so there is nothing to scroll: the screen is the
1379
- * wordmark and the place to type, centred, and nothing else.
1380
- *
1381
- * It comes back after /clear and /new too, since those empty the transcript —
1382
- * a fresh conversation starts from the same quiet screen as a fresh launch.
1383
- */
1384
- welcoming() {
1385
- return this.lines.length === 0 && !this.picker;
1386
- }
1387
-
1388
- /** Where everything on the start screen goes, 0-based rows. */
1389
- welcomeGeometry() {
1390
- const cols = this.width();
1391
- const boxWidth = Math.max(30, Math.min(cols - 4, 84));
1392
- const left = Math.max(0, Math.floor((cols - boxWidth) / 2));
1393
- const big = cols >= BANNER_WIDTH + 4 && this.rows >= 18;
1394
- const art = big ? BANNER : ['u c o d e'];
1395
- const inputRows = this.inputLines(boxWidth - 4).rows.length;
1396
- const block = art.length + 2 + inputRows + 4; // wordmark, gap, box
1397
- // A touch above true centre reads as centred; exact centre looks low.
1398
- const top = Math.max(0, Math.floor((this.rows - block) / 2) - 1);
1399
- return { cols, boxWidth, left, big, art, inputRows, top, boxTop: top + art.length + 2 };
1400
- }
1401
-
1402
- renderWelcome() {
1403
- const g = this.welcomeGeometry();
1404
- const frame = new Array(this.rows).fill('');
1405
-
1406
- // The wordmark in two tones of the one blue, the way a name reads in two
1407
- // halves: the U quieter, CODE brighter.
1408
- g.art.forEach((line, i) => {
1409
- const pad = ' '.repeat(Math.max(0, Math.floor((g.cols - line.length) / 2)));
1410
- frame[g.top + i] = pad + (g.big
1411
- ? deep(line.slice(0, WORDMARK_SPLIT)) + sky(line.slice(WORDMARK_SPLIT))
1412
- : blue.bold(line));
1413
- });
1414
-
1415
- const indent = ' '.repeat(g.left);
1416
- this.inputBox(g.boxWidth).forEach((row, i) => {
1417
- frame[g.boxTop + i] = indent + row;
1418
- });
1419
-
1420
- // The version, in the corner, and nothing else on the screen.
1421
- if (VERSION) {
1422
- const tag = dim(this.facts.update ? `v${VERSION} · v${this.facts.update} installed, starts next time` : `v${VERSION}`);
1423
- frame[this.rows - 1] = ' '.repeat(Math.max(0, g.cols - visLen(tag) - 2)) + tag;
1424
- }
1425
-
1426
- const out = [HIDE, HOME];
1427
- for (let i = 0; i < this.rows; i++) {
1428
- out.push(CLEAR_LINE + padVis(frame[i], g.cols) + (i === this.rows - 1 ? '' : '\n'));
1429
- }
1430
- const [row, col] = this.caret();
1431
- out.push(at(row, col) + SHOW);
1432
- this.output.write(out.join(''));
1433
- }
1434
- }
1435
-
1436
- /**
1437
- * Split a raw stdin chunk into keys, keeping escape sequences whole.
1438
- *
1439
- * Application cursor key mode (DECCKM) makes a terminal send ESC O A for the
1440
- * up arrow rather than ESC [ A. Both are normalised to the bracket form here
1441
- * so the key handler only ever sees one of them.
1442
- */
1443
- /**
1444
- * Did this arrive as a paste, judged by shape rather than by markers?
1445
- *
1446
- * Someone pressing Enter sends one carriage return on its own. A paste sends
1447
- * a line break with text around it, in a single read. That difference is all
1448
- * there is to go on when a terminal does not implement bracketed paste, and
1449
- * it is enough.
1450
- *
1451
- * Anything carrying an escape sequence is left alone: that is a key or a
1452
- * mouse report, and reading one as text would put gibberish in the input.
1453
- */
1454
- export function looksPasted(chunk) {
1455
- const text = String(chunk ?? '');
1456
- if (text.length < 2 || text.includes(ESC)) return false;
1457
- const breaks = (text.match(/[\r\n]/g) ?? []).length;
1458
- if (breaks === 0) return false;
1459
- // One trailing break is someone finishing a line, not pasting one.
1460
- if (breaks === 1 && /[\r\n]$/.test(text)) return false;
1461
- return true;
1462
- }
1463
-
1464
- export function splitKeys(chunk) {
1465
- const keys = [];
1466
- let i = 0;
1467
-
1468
- while (i < chunk.length) {
1469
- const c = chunk[i];
1470
- if (c !== ESC) { keys.push(c); i++; continue; }
1471
-
1472
- const rest = chunk.slice(i);
1473
- const csi = /^\x1b\[[0-9;?]*[A-Za-z~]/.exec(rest);
1474
- if (csi) { keys.push(csi[0]); i += csi[0].length; continue; }
1475
-
1476
- const ss3 = /^\x1bO([A-Za-z])/.exec(rest);
1477
- if (ss3) { keys.push(`${ESC}[${ss3[1]}`); i += ss3[0].length; continue; }
1478
-
1479
- keys.push(ESC);
1480
- i++;
1481
- }
1482
-
1483
- return keys;
1484
- }
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, answerMark, RAIL, MAX_WIDTH } from './theme.js';
44
+ import { FRAME_MS, fitActivity, shimmer, spinnerGlyph, formatDuration, doneLine, stepPaint } from './activity.js';
45
+ import { renderer, render, polish } from './markdown.js';
46
+ import { VERSION } from '../core/version.js';
47
+
48
+ /**
49
+ * One line of narration, in the model's own words: "Reading screen.js".
50
+ * Anything longer than this is prose, and prose belongs in the answer.
51
+ */
52
+ export const MAX_LABEL = 120;
53
+
54
+ export function isLabel(text) {
55
+ const t = String(text ?? '').trim();
56
+ return t.length > 0 && t.length <= MAX_LABEL && !t.includes('\n');
57
+ }
58
+
59
+ export const COMMANDS = [
60
+ '/help', '/model', '/models', '/session', '/sessions', '/resume',
61
+ '/new', '/remember', '/skills', '/clear', '/search', '/copy', '/exit',
62
+ '/stats', '/doctor', '/deploy', '/look',
63
+ ];
64
+
65
+ // ANSI ----------------------------------------------------------------------
66
+ const ESC = '\x1b';
67
+ const ALT_ON = `${ESC}[?1049h`;
68
+ const ALT_OFF = `${ESC}[?1049l`;
69
+
70
+ /**
71
+ * Mouse setup, decided by measurement rather than by documentation.
72
+ *
73
+ * 1007 is alternate scroll: inside the alternate screen the terminal turns
74
+ * wheel events into arrow keys. On Windows that is the only way a wheel ever
75
+ * reaches the program, because ConPTY forwards no mouse input at all — a probe
76
+ * that enabled every tracking mode received nothing from a scroll.
77
+ *
78
+ * And mouse tracking suppresses alternate scroll. So on Windows tracking is
79
+ * deliberately not requested: it delivers nothing there, and asking for it
80
+ * would cost the wheel. Elsewhere tracking works, so the mode chip is
81
+ * clickable on those platforms.
82
+ */
83
+ const TRACK = process.platform === 'win32'
84
+ ? '' : `${ESC}[?1000h${ESC}[?1002h${ESC}[?1015h${ESC}[?1006h`;
85
+ const UNTRACK = process.platform === 'win32'
86
+ ? '' : `${ESC}[?1006l${ESC}[?1015l${ESC}[?1002l${ESC}[?1000l`;
87
+
88
+ const PASTE_ON = `${ESC}[?2004h`;
89
+ const PASTE_OFF = `${ESC}[?2004l`;
90
+ const MOUSE_ON = `${ESC}[?1007h${TRACK}`;
91
+ const MOUSE_OFF = `${UNTRACK}${ESC}[?1007l`;
92
+ const HIDE = `${ESC}[?25l`;
93
+ const SHOW = `${ESC}[?25h`;
94
+ const HOME = `${ESC}[H`;
95
+ const CLEAR_LINE = `${ESC}[K`;
96
+ /** Written out rather than inline, so no edit can turn it into a real break. */
97
+ const NEWLINE = String.fromCharCode(10);
98
+ const at = (row, col) => `${ESC}[${row};${col}H`;
99
+ const title = (t) => `${ESC}]0;${t}\x07`;
100
+
101
+ /**
102
+ * Fixed rows below the header: the gap under it, the gap above the input box,
103
+ * the input box's two borders, the blank row inside it, and the status row.
104
+ */
105
+ const CHROME_BELOW = 6;
106
+
107
+ /** How long one sentence of reasoning holds the line before the next takes it. */
108
+ const THOUGHT_HOLD_MS = 1100;
109
+
110
+ /** Reasoning that is about the request rather than about the work. */
111
+ 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;
112
+
113
+ /** The wordmark only earns its place with room for the facts column beside it. */
114
+ const WORDMARK_NEEDS = BANNER_WIDTH + 30;
115
+
116
+ /** What the empty input box says before anything is typed. */
117
+ const PLACEHOLDER = 'Ask anything…';
118
+
119
+ /**
120
+ * What it says instead while the agent has the turn.
121
+ *
122
+ * The status row beside it already says "esc to stop", so this carries the
123
+ * half nothing else on screen does: that the box is still live, and a line
124
+ * typed into it now is kept and sent when the turn ends rather than lost. The
125
+ * short form is for a terminal too narrow to hold the sentence, where a cut
126
+ * one would read as a glitch.
127
+ */
128
+ const WORKING_HINT = 'Working… type to queue your next message';
129
+ const WORKING_HINT_SHORT = 'Working…';
130
+ const WORKING_HINT_NEEDS = WORKING_HINT.length + 8;
131
+
132
+ export class Screen {
133
+ constructor({ cwd, input = process.stdin, output = process.stdout } = {}) {
134
+ this.cwd = cwd;
135
+ this.input = input;
136
+ this.output = output;
137
+
138
+ this.lines = []; // the rendered transcript
139
+ this.scroll = 0; // rows scrolled up from the bottom
140
+ this.buffer = ''; // what is being typed
141
+ this.cursor = 0;
142
+ this.history = [];
143
+ this.historyIndex = -1;
144
+
145
+ this.status = { busy: false, text: '', frame: 0, since: 0 };
146
+ this.facts = {};
147
+ this.model = '';
148
+
149
+ this.waiters = [];
150
+ this.queue = [];
151
+ this.closed = false;
152
+
153
+ // 'build' may edit and run; 'plan' is read-only. Ctrl+B swaps them, and
154
+ // the chip is clickable wherever the terminal forwards clicks.
155
+ this.mode = 'build';
156
+ this.chipTo = 0;
157
+ this.onInterrupt = null;
158
+ this.onModeChange = null;
159
+ this.spinTimer = null;
160
+ this.paintedBusy = false; // whose turn the frame on screen was drawn for
161
+ this.activity = null; // the turn in flight: when it began, how many steps
162
+ this.tick = 0; // animation frames painted, for the spinner
163
+ this.pendingPrompt = null;
164
+
165
+ this.cols = output.columns || 80;
166
+ this.rows = output.rows || 24;
167
+ this.md = renderer(this.width());
168
+ }
169
+
170
+ // -- lifecycle -----------------------------------------------------------
171
+
172
+ async start() {
173
+ ensureColour(this.output);
174
+ this.output.write(ALT_ON + MOUSE_ON + PASTE_ON + HIDE + title(`ucode — ${path.basename(this.cwd)}`));
175
+ this.input.setRawMode?.(true);
176
+ this.input.resume();
177
+ this.input.setEncoding('utf8');
178
+ this.input.on('data', (chunk) => this.onData(chunk));
179
+
180
+ this.onResize = () => {
181
+ this.cols = this.output.columns || 80;
182
+ this.rows = this.output.rows || 24;
183
+ this.md = renderer(this.width());
184
+ this.render();
185
+ };
186
+ this.output.on('resize', this.onResize);
187
+
188
+ this.render();
189
+ }
190
+
191
+ stop() {
192
+ this.activity = null;
193
+ this.stopSpinner();
194
+ this.stopTimer();
195
+ this.output.off?.('resize', this.onResize);
196
+ this.input.setRawMode?.(false);
197
+ this.input.pause();
198
+ this.output.write(PASTE_OFF + MOUSE_OFF + ALT_OFF + SHOW);
199
+ }
200
+
201
+ close() {
202
+ if (this.closed) return;
203
+ this.closed = true;
204
+ this.stop();
205
+ while (this.waiters.length) this.waiters.shift()(null);
206
+ }
207
+
208
+ /** Every column the terminal has. Only the start screen, which centres, uses it. */
209
+ screenWidth() {
210
+ return Math.max(30, this.cols);
211
+ }
212
+
213
+ /**
214
+ * The width the interface actually draws to.
215
+ *
216
+ * On a wide monitor an uncapped frame stretched its boxes to two hundred
217
+ * columns and ran prose the same distance, which is past the point a line
218
+ * can be read without losing the start of it — and reads as the app not
219
+ * having an opinion rather than as it filling the space. The cap is the
220
+ * width the markdown renderer was already holding answers to, so prose,
221
+ * boxes and diffs now end in the same column instead of three.
222
+ *
223
+ * Left, not centred: the shell prompt before and after a session sits on the
224
+ * left margin, and a frame that jumps to the middle of the screen reads as a
225
+ * different program. What is past the cap is cleared, never written to.
226
+ */
227
+ width() {
228
+ return Math.min(this.screenWidth(), MAX_WIDTH);
229
+ }
230
+
231
+ /** Usable width inside a box: two borders and a space of padding each side. */
232
+ inner() {
233
+ return Math.max(8, this.width() - 4);
234
+ }
235
+
236
+ // -- transcript ----------------------------------------------------------
237
+
238
+ /**
239
+ * Append without painting.
240
+ *
241
+ * Anything replacing a region of the transcript has to build the whole
242
+ * region and then render once. Painting between the delete and the re-add
243
+ * puts a frame on screen with the text missing, and at streaming speed that
244
+ * reads as flicker.
245
+ */
246
+ add(text = '') {
247
+ const width = this.width();
248
+ for (const raw of String(text).split('\n')) {
249
+ if (visLen(raw) <= width) this.lines.push(raw);
250
+ else for (const wrapped of wrapAnsi(raw, width)) this.lines.push(wrapped);
251
+ }
252
+ this.scroll = 0; // new output snaps back to the bottom
253
+ }
254
+
255
+ push(text = '') {
256
+ this.add(text);
257
+ this.soon();
258
+ }
259
+
260
+ /**
261
+ * Collapse a burst of pushes into one frame.
262
+ *
263
+ * Printing a list one line at a time repaints the screen per line — a model
264
+ * list of fifty entries drew a hundred frames back to back, which is visible
265
+ * as a cascade. A microtask runs before any I/O, so everything pushed in one
266
+ * synchronous stretch becomes a single render, while a push after an await
267
+ * still paints immediately.
268
+ */
269
+ soon() {
270
+ if (this.queued) return;
271
+ this.queued = true;
272
+ queueMicrotask(() => {
273
+ this.queued = false;
274
+ this.render();
275
+ });
276
+ }
277
+
278
+ write(text = '') { this.push(text); }
279
+ blank() { this.push(''); }
280
+ note(text) { this.push(dim(` ${text}`)); }
281
+
282
+ clearScreen() {
283
+ this.lines = [];
284
+ this.scroll = 0;
285
+ this.render();
286
+ }
287
+
288
+ /**
289
+ * The reply, at full strength, with room either side.
290
+ *
291
+ * `closing` says this is the last thing the turn will say. It is then also
292
+ * the last thing left on screen, and what the whole session reads like
293
+ * afterwards, so it is cut to eight lines — see trimAnswer.
294
+ */
295
+ assistant(text, { closing = false } = {}) {
296
+ if (!text?.trim()) return;
297
+ const body = closing ? trimAnswer(tidyReply(text)) : tidyReply(text);
298
+ if (!body.trim()) return;
299
+ this.endRun();
300
+ this.add('');
301
+
302
+ // A bullet on the first line that has words on it, and the rest of the
303
+ // answer indented to clear it. Without the mark the reply is white text at
304
+ // the same margin as the narration above it, and scrolling back there is
305
+ // nothing to aim at — you find where the answer starts by reading until
306
+ // the sentences stop being about files.
307
+ //
308
+ // Wrapped here rather than left to add(), which knows nothing about the
309
+ // indent: marked-terminal is told not to reflow, so a long line arrives
310
+ // whole, and a row add() broke for itself came back out at column zero
311
+ // with the rest of the answer sitting two columns to its right.
312
+ const room = Math.max(8, this.width() - 2);
313
+ let marked = false;
314
+ for (const row of render(this.md, body).split('\n')) {
315
+ if (!row.trim()) { this.add(''); continue; }
316
+ for (const line of wrapAnsi(row, room)) {
317
+ this.add(marked ? ` ${line}` : `${answerMark()} ${line}`);
318
+ marked = true;
319
+ }
320
+ }
321
+
322
+ this.add('');
323
+ this.render();
324
+ }
325
+
326
+ /**
327
+ * Something the user said, marked down its left edge in the same blue as the
328
+ * box it was typed into.
329
+ *
330
+ * A long session is mostly the agent's output — tool calls, diffs, answers.
331
+ * Your own messages are the landmarks you scroll back looking for, so they
332
+ * get a mark of their own. It was a full box, and forty turns of that is a
333
+ * ladder of rules across the page: two horizontal lines per message, each as
334
+ * loud as the input box, none of them saying anything the rail does not.
335
+ */
336
+ userMessage(text) {
337
+ const room = Math.max(8, this.width() - 2); // the rail and the space after it
338
+
339
+ const rows = [];
340
+ for (const paragraph of String(text).replace(/\r/g, '').split('\n')) {
341
+ for (const line of wrapAnsi(paragraph, room)) rows.push(line);
342
+ }
343
+
344
+ // Room between what you asked for and what came back: without it the reply
345
+ // starts against your own message and the two read as one block of text.
346
+ this.add('');
347
+ for (const row of rows) this.add(`${blue(RAIL)} ${chalk.white(row)}`);
348
+ this.add('');
349
+ this.add('');
350
+ this.render();
351
+ }
352
+
353
+ /**
354
+ * A tool call, as it happens: "● Listing src".
355
+ *
356
+ * This lives in the transcript rather than only on the status line. The
357
+ * status line overwrites itself and is empty by the end of the turn, so work
358
+ * announced only there scrolls past unseen — and the diff underneath ends up
359
+ * with nothing above it explaining where it came from.
360
+ */
361
+ toolCall(label) {
362
+ // U+25CF, not U+23FA: the latter carries emoji presentation, which Windows
363
+ // Terminal draws as a white circle on a blue tile.
364
+ // Trimmed here rather than at paint time: runLine hands back a coloured
365
+ // string, and asLabel's regexes run off the end of one of those into the
366
+ // escape sequence instead of the last word.
367
+ const clean = asLabel(label);
368
+ const kind = groupKind(clean);
369
+ // One line per kind of work for as long as the model is working on one
370
+ // thing. Reading, writing and reading again used to draw six lines that
371
+ // said three things; now the "Reading files" line it already has is the
372
+ // one that counts up, wherever it sits.
373
+ const run = (this.segment ??= new Map()).get(kind);
374
+
375
+ if (run && this.lines[run.at] !== undefined) {
376
+ run.count++;
377
+ run.label = clean;
378
+ run.targets.push(groupTarget(clean));
379
+ this.run = run;
380
+ this.paintRun();
381
+ } else {
382
+ const fresh = {
383
+ kind, count: 1, at: 0, label: clean,
384
+ targets: [groupTarget(clean)], added: 0, removed: 0,
385
+ };
386
+ this.push(`${narrationMark()} ${runLine(fresh)}`);
387
+ fresh.at = this.lines.length - 1;
388
+ this.run = fresh;
389
+ this.segment.set(kind, fresh);
390
+ }
391
+ this.updateSpinner(label);
392
+ }
393
+
394
+ /**
395
+ * The model speaking — or a plan, or a failure — ends the segment.
396
+ *
397
+ * Up to that point a kind of work keeps one line and counts up on it. After
398
+ * it, the next read is a new piece of work and deserves its own line, which
399
+ * is what makes the transcript read as a sequence of things done rather
400
+ * than a set of running totals.
401
+ */
402
+ /**
403
+ * Stop adding to the current run, but keep the lines already on screen.
404
+ *
405
+ * A kind of work gets one line for the whole turn. Starting a fresh set
406
+ * whenever the model spoke meant "Creating Tide from the HTML starter" five
407
+ * times down the page and "Reading files" four, each saying the same thing
408
+ * about a different moment. One line that counts up says all of it and
409
+ * costs one row.
410
+ */
411
+ endRun() { this.run = null; }
412
+
413
+ /** A new turn starts with a clean page's worth of lines. */
414
+ newSegment() { this.run = null; this.segment = new Map(); }
415
+
416
+ /** Redraw the run's single line from what it has accumulated. */
417
+ /**
418
+ * Redraw the run's single line from what it has accumulated.
419
+ *
420
+ * While its step is still running the text shimmers, which is the only
421
+ * thing on screen saying "this is happening now" once the per-step result
422
+ * lines are gone. It settles to plain dim the moment the step finishes, so
423
+ * the finished ones above stay quiet.
424
+ */
425
+ paintRun() {
426
+ if (!this.run) return;
427
+ this.lines[this.run.at] = `${narrationMark()} ${runLine(this.run)}`;
428
+ this.render();
429
+ }
430
+
431
+ /**
432
+ * A change, as its two numbers.
433
+ *
434
+ * The diff itself used to go into the transcript. A 539-line file printed
435
+ * there buries the answer under a copy of something already on disk, so
436
+ * what is kept is the shape of the change: how much arrived, how much left.
437
+ */
438
+ diffStat({ added = 0, removed = 0 } = {}) {
439
+ if (!this.run) return;
440
+ this.run.added += added;
441
+ this.run.removed += removed;
442
+ this.paintRun();
443
+ }
444
+
445
+ /** The checklist, when the model updates it. One line, wrapped if it must. */
446
+ plan(items) {
447
+ const rows = planRows(items);
448
+ if (!rows.length) return;
449
+ this.endRun(); // a plan is not another step of whatever came before
450
+ for (const row of rows) this.push(row);
451
+ }
452
+
453
+ /**
454
+ * What came of a step.
455
+ *
456
+ * Nothing goes underneath the bullet any more: a line of its own for every
457
+ * result doubles the height of the transcript to say "ok". The bullet
458
+ * already names the step, and a change adds its numbers to that same line.
459
+ * Only a failure earns a line of its own.
460
+ */
461
+ toolResult() {}
462
+
463
+ /**
464
+ * Something went wrong, and the model is the one who can do anything about it.
465
+ *
466
+ * A red line of machinery — a failed edit, a command that exited non-zero —
467
+ * reads as the tool being broken, when almost always it is a step the model
468
+ * corrects on its own a second later. It goes to the model; the screen stays
469
+ * for what is being built. Whatever is genuinely unrecoverable surfaces as
470
+ * the model saying so in words, which is the form worth reading.
471
+ */
472
+ toolFailed() {
473
+ this.endRun();
474
+ }
475
+
476
+ /**
477
+ * The change itself, under the result.
478
+ *
479
+ * A line-number gutter, then the sign and the code tinted right across the
480
+ * row. The numbers are the point: a diff you cannot navigate from is a
481
+ * picture of a change rather than a record of one.
482
+ */
483
+ diff(lines) {
484
+ const gutter = 6;
485
+ // Two spaces of indent, the gutter, one space, then the tint fills the
486
+ // rest. One column over and every row wraps, splitting the whole diff.
487
+ const room = Math.max(12, this.width() - gutter - 3);
488
+
489
+ for (const line of lines) {
490
+ // A file heading in a multi-file write.
491
+ if (line.startsWith('~')) {
492
+ this.add(` ${dim(' '.repeat(gutter))} ${sky(line.slice(1))}`);
493
+ continue;
494
+ }
495
+
496
+ const added = line.startsWith('+');
497
+ const rest = line.slice(1);
498
+ // Tools emit "<line>| <text>". A row with no number is the "12 more
499
+ // lines" note, which is not part of the change, so it stays dim.
500
+ const parsed = /^(\d+)\|\s?([\s\S]*)$/.exec(rest);
501
+ if (!parsed) {
502
+ this.add(` ${dim(' '.repeat(gutter))} ${dim(rest)}`);
503
+ continue;
504
+ }
505
+
506
+ const [, number, body] = parsed;
507
+ const tint = added ? ADDED : REMOVED;
508
+ this.add(
509
+ ` ${dim(number.padStart(gutter))} ` +
510
+ // Tabs would leave the tint ending short of the row, so they widen.
511
+ tint(padVis(clip(`${added ? '+' : '-'} ${body.replace(/\t/g, ' ')}`, room), room))
512
+ );
513
+ }
514
+ this.render(); // a sixteen-line diff is one frame, not sixteen
515
+ }
516
+
517
+ /** Captured output under a command, dimmed so it reads as evidence. */
518
+ commandOutput(lines) {
519
+ for (const line of lines) this.add(` ${dim(line)}`);
520
+ this.render();
521
+ }
522
+
523
+ /**
524
+ * A running command's output, live — on the status line and nowhere else.
525
+ *
526
+ * Only the newest line, gone as soon as the next arrives. Appending each one
527
+ * instead would mean a test run leaving sixty lines of "ok" in the
528
+ * conversation permanently, which is noise the moment it scrolls. What
529
+ * survives a command is decided when it ends: nothing if it worked, the tail
530
+ * if it did not.
531
+ */
532
+ progress(lines) {
533
+ const last = lines[lines.length - 1]?.trim();
534
+ if (last) this.updateSpinner(last);
535
+ }
536
+
537
+ /**
538
+ * The model's own account of the step it is taking, before it takes it.
539
+ *
540
+ * Not called status(): `this.status` holds the spinner state, and a method
541
+ * of the same name would be shadowed by it on every instance.
542
+ */
543
+ narrate(text) {
544
+ const line = asLabel(text);
545
+ if (!line) return;
546
+ this.push(dim(` ⋮ ${clip(line, this.width() - 6)}`));
547
+ this.updateSpinner(line);
548
+ }
549
+
550
+ // -- streaming -----------------------------------------------------------
551
+ // Deltas appear as plain text as they arrive, then get replaced in place by
552
+ // properly rendered markdown once the reply is complete.
553
+
554
+ streamBegin() {
555
+ this.stopSpinner();
556
+ this.streamAt = this.lines.length;
557
+ this.streamBuf = '';
558
+ this.streamPainted = 0;
559
+ }
560
+
561
+ streamDelta(delta) {
562
+ if (this.streamAt === undefined) this.streamBegin();
563
+ this.streamBuf += delta;
564
+ const now = Date.now();
565
+ if (now - this.streamPainted < 60) return; // about 16fps is plenty
566
+ this.streamPainted = now;
567
+ this.repaintStream();
568
+ }
569
+
570
+ /**
571
+ * Repaint the partial reply.
572
+ *
573
+ * polish() runs on the partial text so bold, inline code and bullets are
574
+ * already styled while it streams. Without it the text arrives raw and then
575
+ * visibly re-renders at the end, which reads as a glitch.
576
+ */
577
+ repaintStream() {
578
+ this.lines.length = this.streamAt;
579
+ this.add('');
580
+ this.add(polish(this.streamBuf));
581
+ this.render(); // one frame, and never one without the reply in it
582
+ }
583
+
584
+ /**
585
+ * Finish a streamed reply.
586
+ *
587
+ * `asLabel` says the text turned out to be narration ahead of a tool call
588
+ * rather than an answer, in which case one short line folds down into the
589
+ * status line it was always meant to be.
590
+ */
591
+ streamEnd({ asNarration = false, closing = false } = {}) {
592
+ if (this.streamAt === undefined) return '';
593
+ const text = this.streamBuf;
594
+ this.lines.length = this.streamAt;
595
+ this.streamAt = undefined;
596
+ this.streamBuf = '';
597
+
598
+ if (asNarration && isLabel(text)) this.narrate(text);
599
+ else if (text.trim()) this.assistant(text, { closing });
600
+ else this.render();
601
+ return text;
602
+ }
603
+
604
+ // -- thinking ------------------------------------------------------------
605
+ // A reasoning model does all its working before it says anything. None of it
606
+ // is printed: it is long, repetitive, and guesses drawn from it read worse
607
+ // than silence. The spinner counts the seconds so the wait is visibly alive,
608
+ // and the transcript gets one line afterwards saying how long it took.
609
+
610
+ /**
611
+ * The model's reasoning does not go on screen.
612
+ *
613
+ * It was surfaced here to fill the wait before the first tool call, and what
614
+ * it actually filled it with was the model talking to itself: "I need to
615
+ * build this", "The user wants a tasks app". Nobody needs their own request
616
+ * read back to them, and half-formed working-out is not something to publish.
617
+ * What the model *says* is its reply, and that is the only thing shown.
618
+ */
619
+ thinkingDelta() {}
620
+
621
+ thinkingEnd() {}
622
+
623
+ error(err, { debug = false } = {}) {
624
+ const known = err && typeof err === 'object' && err.attempted;
625
+ this.push('');
626
+ if (known) {
627
+ this.push(`${theme.error('✗')} ${chalk.white(`Failed while ${err.attempted}.`)}`);
628
+ this.push(` ${err.failed}`);
629
+ if (err.fix) this.push(` ${blue('→')} ${err.fix}`);
630
+ if (err.kind) this.push(dim(` (${err.kind})`));
631
+ } else {
632
+ this.push(`${theme.error('✗')} ${chalk.white('Something broke inside ucode.')}`);
633
+ this.push(` ${err?.message ?? String(err)}`);
634
+ this.push(` ${blue('→')} That is a bug in ucode rather than in your project. Re-run with --debug.`);
635
+ }
636
+ if (debug) {
637
+ const stack = (known && err.cause?.stack) || err?.stack;
638
+ if (stack) this.push(dim(stack));
639
+ }
640
+ this.push('');
641
+ }
642
+
643
+ // -- header --------------------------------------------------------------
644
+
645
+ setFacts(facts) {
646
+ this.facts = { ...this.facts, ...facts };
647
+ if (facts.model) this.model = facts.model;
648
+ this.render();
649
+ }
650
+
651
+ /** Same shape as the plain UI's header(), so the loop needs no branch. */
652
+ header({ cwd, model, used, limit, title: sessionTitle }) {
653
+ this.setFacts({
654
+ cwd,
655
+ model,
656
+ title: sessionTitle,
657
+ percent: limit > 0 ? Math.min(100, Math.round((used / limit) * 100)) : 0,
658
+ });
659
+ }
660
+
661
+ /**
662
+ * How many rows the header box occupies.
663
+ *
664
+ * The frame has to be exactly as tall as the terminal or every row below the
665
+ * shortfall is off by that much — including the one the caret is parked on.
666
+ * So this is derived, never assumed.
667
+ */
668
+ headerHeight() {
669
+ return this.width() >= WORDMARK_NEEDS ? BANNER.length + 2 : 5;
670
+ }
671
+
672
+ headerLines() {
673
+ const width = this.width();
674
+ const inner = width - 2; // between the borders
675
+
676
+ if (width < WORDMARK_NEEDS) {
677
+ // Too narrow for the wordmark: stack it rather than wrap it into noise.
678
+ const rows = [
679
+ ` ${blue.bold('U C O D E')} ${dim('terminal coding agent')}`,
680
+ ` ${dim('dir'.padEnd(8))}${chalk.white(clip(shortenPath(this.facts.cwd ?? this.cwd, inner - 12), inner - 12))}`,
681
+ ];
682
+ return [boxTop(width), ...rows.map((r) => boxRow(r, width)), boxBottom(width)];
683
+ }
684
+
685
+ // Two spaces of padding, the wordmark, a gap, then the facts column.
686
+ //
687
+ // Only what you cannot work out by looking: where you are, and how to get
688
+ // help. How full the window is belongs on the status row next to the model
689
+ // it describes, and the session title is already the terminal's own window
690
+ // title — repeating either here is a second place to keep in sync for no
691
+ // reader who needed it.
692
+ const room = Math.max(8, inner - BANNER_WIDTH - 6);
693
+ const facts = [
694
+ ['dir', shortenPath(this.facts.cwd ?? this.cwd, room - 9)],
695
+ ['keys', '/help · esc interrupts'],
696
+ ['', ''],
697
+ ['', ''],
698
+ ['', ''],
699
+ ['', 'made with ❤️ by om dixit'],
700
+ ];
701
+
702
+ const rows = BANNER.map((art, i) => {
703
+ const [label, value] = facts[i] ?? ['', ''];
704
+ const right = label
705
+ ? `${dim(label.padEnd(9))}${chalk.white(clip(value, room - 9))}`
706
+ : (value ? dim(value) : '');
707
+ return ` ${bannerPaint(i)(art)} ${right}`;
708
+ });
709
+
710
+ return [boxTop(width), ...rows.map((r) => boxRow(r, width)), boxBottom(width)];
711
+ }
712
+
713
+ // -- input box -----------------------------------------------------------
714
+
715
+ /** The typed line, wrapped to the inside of a box `width` characters across. */
716
+ /**
717
+ * The typed text, laid out as rows inside the box.
718
+ *
719
+ * A line break in the buffer is a row of its own before any wrapping is
720
+ * considered. Slicing the text into fixed widths without looking for one
721
+ * put the newline into the frame instead, and the terminal obeyed it — the
722
+ * pasted text walked out of the box and over the transcript beside it.
723
+ *
724
+ * `starts` records where each row begins in the text, so the caret can be
725
+ * placed by looking up rather than by counting characters a second way and
726
+ * hoping the two agree.
727
+ */
728
+ inputLines(width = this.inner()) {
729
+ const prefix = this.pendingPrompt ? `${this.pendingPrompt} ` : '› ';
730
+ const full = prefix + this.buffer;
731
+
732
+ const rows = [];
733
+ const starts = [];
734
+ let at = 0;
735
+
736
+ for (const para of full.split(NEWLINE)) {
737
+ let i = 0;
738
+ do {
739
+ rows.push(para.slice(i, i + width));
740
+ starts.push(at + i);
741
+ i += width;
742
+ } while (i < para.length);
743
+ at += para.length + 1; // the newline itself
744
+ }
745
+
746
+ if (rows.length === 0) { rows.push(prefix); starts.push(0); }
747
+ return { rows, prefix, width, starts };
748
+ }
749
+
750
+ /** Which row the caret sits on, and how far along it. */
751
+ caretAt(width) {
752
+ const { rows, prefix, starts } = this.inputLines(width);
753
+ const index = prefix.length + this.cursor;
754
+ let row = 0;
755
+ while (row + 1 < starts.length && starts[row + 1] <= index) row++;
756
+ return { row, col: Math.min(index - starts[row], rows[row].length), rows };
757
+ }
758
+
759
+ viewportHeight() {
760
+ return Math.max(
761
+ 3,
762
+ this.rows - this.headerHeight() - CHROME_BELOW - this.inputLines().rows.length
763
+ );
764
+ }
765
+
766
+ /**
767
+ * The input box: what you are typing, and directly under it, inside the same
768
+ * border, the three things worth knowing while you type.
769
+ *
770
+ * The status used to sit outside the box on the last row of the screen,
771
+ * which made it a separate object floating under the input. Inside the
772
+ * border it reads as part of the thing you are using — the box says "this is
773
+ * where you work", and the row underneath says what you are working with.
774
+ */
775
+ inputBox(width = this.width()) {
776
+ const { rows } = this.inputLines(width - 4);
777
+ const border = this.borderPaint();
778
+ const busy = this.busy();
779
+ // Nothing typed yet: a quiet prompt where the text will go. The caret sits
780
+ // on its first letter and typing replaces it.
781
+ const empty = !this.buffer && !this.pendingPrompt;
782
+ const hint = !busy ? PLACEHOLDER
783
+ : (width >= WORKING_HINT_NEEDS ? WORKING_HINT : WORKING_HINT_SHORT);
784
+ const painted = rows.map((row, i) =>
785
+ i === 0
786
+ ? boxRow(` ${border('›')}${empty ? ` ${dim(hint)}` : row.slice(1)}`, width, border)
787
+ : boxRow(` ${row}`, width, border)
788
+ );
789
+ return [
790
+ boxTop(width, border),
791
+ ...painted,
792
+ // A blank row between the two. Sitting directly under the caret, the
793
+ // status read as a second line of the thing being typed; one row of air
794
+ // separates what you are writing from what you are writing it with.
795
+ boxRow('', width, border),
796
+ boxRow(this.statusRow(width), width, border),
797
+ boxBottom(width, border),
798
+ ];
799
+ }
800
+
801
+ /** Is the agent holding the turn? */
802
+ busy() {
803
+ return this.status.busy || !!this.activity;
804
+ }
805
+
806
+ /**
807
+ * The input box's edge, which says whose turn it is.
808
+ *
809
+ * Bold blue while the box is yours, quiet while the agent has it. The status
810
+ * row inside the same box already carries the words; this is the half you
811
+ * catch without reading, from the corner of your eye, in the one place on
812
+ * screen you were already looking.
813
+ */
814
+ borderPaint() {
815
+ return this.busy() ? deep : edge;
816
+ }
817
+
818
+ /**
819
+ * Repaint after something that may have changed whose turn it is.
820
+ *
821
+ * The cheap path redraws one row, which is right twelve times a second for a
822
+ * spinner and wrong at a turn boundary: the border above and below would
823
+ * still be the old weight while the status row had the new one, and the box
824
+ * would be drawn in two colours. A whole frame costs nothing twice a turn.
825
+ */
826
+ paintBusy() {
827
+ if (this.busy() === this.paintedBusy) this.paintStatus();
828
+ else this.render();
829
+ }
830
+
831
+ // -- status row ----------------------------------------------------------
832
+
833
+ modeChip() {
834
+ return this.mode === 'plan' ? `${sky('◇')} ${sky('Plan')}` : `${blue('◆')} ${blue('Build')}`;
835
+ }
836
+
837
+ /**
838
+ * How full the context window is, as a bare number.
839
+ *
840
+ * It turns amber at 75% because that is where turns start being folded away
841
+ * into a summary — the one moment the number predicts something you would
842
+ * want to know before it happens.
843
+ */
844
+ percentChip() {
845
+ const percent = Math.round(this.facts.percent ?? 0);
846
+ return percent >= 75 ? theme.warn(`${percent}%`) : dim(`${percent}%`);
847
+ }
848
+
849
+ /**
850
+ * Which mode is live, which model is answering, and how full the window is.
851
+ *
852
+ * Nothing else earns a place. The provider name was there and was cut: it is
853
+ * the same on every line of every session, so it was decoration that had to
854
+ * be read past to reach the two things that do change.
855
+ *
856
+ * The middle is borrowed while something is running, for the spinner and the
857
+ * way out of it, and handed straight back when it finishes.
858
+ */
859
+ statusRow(width = this.width()) {
860
+ const inner = width - 2; // the space between the two borders
861
+ const chip = this.modeChip();
862
+ const left = ` ${chip} ${dim('·')} ${chalk.white(this.model || '—')}`;
863
+ const right = `${this.percentChip()} `;
864
+
865
+ // Where a click on the bottom row still counts as hitting the mode chip.
866
+ this.chipTo = 2 + visLen(chip);
867
+
868
+ const between = Math.max(1, inner - visLen(left) - visLen(right));
869
+
870
+ let middle = '';
871
+ if (this.flashText) {
872
+ middle = dim(clip(this.flashText, between - 2));
873
+ } else if (this.status.busy || this.activity) {
874
+ // The whole turn, not just the current tool: the timer and step count
875
+ // keep going through the gaps between calls, so a long build never
876
+ // looks like it has stopped.
877
+ const now = Date.now();
878
+ const a = this.activity;
879
+ const since = a?.start ?? this.status.since ?? now;
880
+ const meta = [];
881
+ // No step count. It measures how much machinery ran, which is not
882
+ // something the person waiting has any use for; the elapsed time is.
883
+ void stepPaint;
884
+ if (now - since >= 1000) meta.push({ text: formatDuration(now - since), keep: true });
885
+ middle = fitActivity({
886
+ glyph: spinnerGlyph(this.tick, now),
887
+ label: this.status.busy ? this.status.text : 'working',
888
+ meta,
889
+ hint: 'esc to stop',
890
+ paint: (s) => shimmer(s, now),
891
+ }, between - 3);
892
+ }
893
+
894
+ // The percentage is pinned to the right border whatever is in the middle,
895
+ // with a gap kept in front of it so a long spinner label cannot run into
896
+ // the number and read as part of it.
897
+ const tail = middle ? `${middle} ` : '';
898
+ const pad = Math.max(1, inner - visLen(left) - visLen(tail) - visLen(right));
899
+ return padVis(left + ' '.repeat(pad) + tail + right, inner);
900
+ }
901
+
902
+ /**
903
+ * Repaint only the status row, leaving the caret where the user left it.
904
+ *
905
+ * It is the second row from the bottom now — the box's own border is below
906
+ * it — so the row is written with its borders rather than as a bare line.
907
+ */
908
+ paintStatus() {
909
+ if (this.closed) return;
910
+ // On the start screen the status row is mid-screen, not second from the
911
+ // bottom, so the cheap single-row repaint would draw it in the wrong place.
912
+ if (this.welcoming()) {
913
+ this.render();
914
+ return;
915
+ }
916
+ const [row, col] = this.caret();
917
+ this.output.write(
918
+ HIDE +
919
+ at(this.rows - 1, 1) + CLEAR_LINE + boxRow(this.statusRow(), this.width(), this.borderPaint()) +
920
+ at(row, col) + SHOW
921
+ );
922
+ }
923
+
924
+ toggleMode() {
925
+ this.mode = this.mode === 'plan' ? 'build' : 'plan';
926
+ this.flash(this.mode === 'plan'
927
+ ? 'plan mode — reads and researches, changes nothing'
928
+ : 'build mode — free to edit files and run commands');
929
+ this.onModeChange?.(this.mode);
930
+ this.render();
931
+ }
932
+
933
+ /** A message on the status line that fades on its own. */
934
+ flash(text) {
935
+ this.flashText = text;
936
+ clearTimeout(this.flashTimer);
937
+ this.flashTimer = setTimeout(() => {
938
+ this.flashText = null;
939
+ this.paintStatus();
940
+ }, 2500);
941
+ this.flashTimer.unref?.();
942
+ this.paintStatus();
943
+ }
944
+
945
+ // -- spinner -------------------------------------------------------------
946
+
947
+ startSpinner(text = 'thinking') {
948
+ // `since` is what makes a long think legible: the label may not change for
949
+ // a minute, so the seconds beside it are the proof it is still alive.
950
+ this.status = { busy: true, text: asLabel(text), frame: 0, since: Date.now() };
951
+ this.startTimer();
952
+ this.paintBusy();
953
+ }
954
+
955
+ updateSpinner(text) {
956
+ if (!this.status.busy) return;
957
+ this.status.text = asLabel(text);
958
+ this.paintStatus();
959
+ }
960
+
961
+ stopSpinner() {
962
+ if (!this.activity) this.stopTimer();
963
+ if (this.status.busy) {
964
+ this.status = { busy: false, text: '', frame: 0, since: 0 };
965
+ this.paintBusy();
966
+ }
967
+ }
968
+
969
+ // -- the turn in flight ----------------------------------------------------
970
+
971
+ /** A turn begins: the timer and step count run until turnEnd(). */
972
+ turnStart() {
973
+ this.newSegment();
974
+ this.activity = { start: Date.now(), steps: 0, movedAt: 0 };
975
+ this.startTimer();
976
+ this.paintBusy();
977
+ }
978
+
979
+ /** One more model step in this turn. */
980
+ step() {
981
+ if (!this.activity) return;
982
+ this.activity.steps++;
983
+ this.activity.movedAt = Date.now();
984
+ }
985
+
986
+ /** The turn is over: leave "✓ Done in 6m 12s · 25 steps" under the answer. */
987
+ turnEnd({ ok = true } = {}) {
988
+ const a = this.activity;
989
+ this.activity = null;
990
+ if (!this.status.busy) this.stopTimer();
991
+ // Nothing is written when a turn finishes. The reply is the end of the
992
+ // turn, and a timing line under it is bookkeeping the reader did not ask
993
+ // for. A turn that stopped *without* finishing still says so, because
994
+ // silence there is indistinguishable from a crash.
995
+ if (a && !ok && Date.now() - a.start >= 2000) {
996
+ this.push(` ${doneLine(Date.now() - a.start, a.steps, { ok })}`);
997
+ }
998
+ this.paintBusy();
999
+ }
1000
+
1001
+ /** The animation clock: only the status row repaints, about twelve times a second. */
1002
+ startTimer() {
1003
+ if (this.spinTimer) return;
1004
+ this.spinTimer = setInterval(() => {
1005
+ // Only the status row repaints on a tick. Animating a transcript line
1006
+ // meant redrawing the whole frame twelve times a second, and the input
1007
+ // box was being rebuilt under the user's cursor as they typed.
1008
+ this.tick++;
1009
+ this.paintStatus();
1010
+ }, FRAME_MS);
1011
+ this.spinTimer.unref?.();
1012
+ }
1013
+
1014
+ stopTimer() {
1015
+ if (!this.spinTimer) return;
1016
+ clearInterval(this.spinTimer);
1017
+ this.spinTimer = null;
1018
+ }
1019
+
1020
+ // -- input ---------------------------------------------------------------
1021
+
1022
+ nextLine() {
1023
+ if (this.queue.length) return Promise.resolve(this.queue.shift());
1024
+ if (this.closed) return Promise.resolve(null);
1025
+ return new Promise((resolve) => this.waiters.push(resolve));
1026
+ }
1027
+
1028
+ ask() {
1029
+ return this.nextLine();
1030
+ }
1031
+
1032
+ submit(text) {
1033
+ const waiter = this.waiters.shift();
1034
+ if (waiter) waiter(text);
1035
+ else this.queue.push(text);
1036
+ }
1037
+
1038
+ /** y/n, answered on the input line. */
1039
+ confirm({ action, detail, risk }) {
1040
+ this.push('');
1041
+ this.push(`${chalk.inverse(theme.warn(risk === 'command' ? ' shell ' : ' outside project '))} ${chalk.white(action)}`);
1042
+ for (const line of String(detail ?? '').split('\n')) {
1043
+ if (line) this.push(dim(` ${line}`));
1044
+ }
1045
+
1046
+ this.pendingPrompt = 'go ahead? [y/N]';
1047
+ this.render();
1048
+
1049
+ return this.nextLine().then((answer) => {
1050
+ this.pendingPrompt = null;
1051
+ // End of input counts as no. Never run something nobody approved.
1052
+ const yes = /^(y|yes)$/i.test(String(answer ?? '').trim());
1053
+ this.push(dim(yes ? ' approved' : ' declined'));
1054
+ this.push('');
1055
+ return yes;
1056
+ });
1057
+ }
1058
+
1059
+ /**
1060
+ * A modal list: arrows move, Enter picks, Esc cancels.
1061
+ *
1062
+ * Only while this is open do the arrows stop scrolling the transcript. They
1063
+ * cannot be given up permanently, because under alternate scroll the mouse
1064
+ * wheel arrives as arrow keys.
1065
+ */
1066
+ pick(items, { active = 0, hint = 'enter to choose · esc to cancel', deletable = false } = {}) {
1067
+ this.picker = {
1068
+ items,
1069
+ index: Math.min(Math.max(0, active), Math.max(0, items.length - 1)),
1070
+ hint,
1071
+ // With deletable, `d` twice on a row resolves { delete: index }. Twice,
1072
+ // because a single stray keypress should never cost a conversation.
1073
+ deletable,
1074
+ armed: null,
1075
+ };
1076
+ this.render();
1077
+ return new Promise((resolve) => { this.pickerResolve = resolve; });
1078
+ }
1079
+
1080
+ closePicker(value) {
1081
+ const resolve = this.pickerResolve;
1082
+ this.picker = null;
1083
+ this.pickerResolve = null;
1084
+ this.render();
1085
+ resolve?.(value);
1086
+ }
1087
+
1088
+ /**
1089
+ * Rows for an open picker, windowed so a long list still fits.
1090
+ *
1091
+ * An item may carry a `sub` line — a second, dimmer row underneath it. That
1092
+ * is what lets a list of saved conversations show what each one was actually
1093
+ * about instead of a column of near-identical titles.
1094
+ */
1095
+ pickerLines(height) {
1096
+ const { items, index, hint, armed } = this.picker;
1097
+ const room = Math.max(1, height - 2);
1098
+
1099
+ // Rows per item, so the window can be sized in rows rather than in items.
1100
+ const rowsFor = (item) => (typeof item !== 'string' && item.sub ? 2 : 1);
1101
+ const perItem = items.map(rowsFor);
1102
+
1103
+ // Walk outward from the selection until the window is full. Starting from
1104
+ // the selection guarantees it is on screen however long the list is.
1105
+ let first = index;
1106
+ let last = index;
1107
+ let used = perItem[index] ?? 1;
1108
+ while (used < room && (first > 0 || last < items.length - 1)) {
1109
+ if (first > 0 && used + perItem[first - 1] <= room) { first--; used += perItem[first]; }
1110
+ else if (last < items.length - 1 && used + perItem[last + 1] <= room) { last++; used += perItem[last]; }
1111
+ else break;
1112
+ }
1113
+
1114
+ const out = [];
1115
+ for (let i = first; i <= last; i++) {
1116
+ const item = items[i];
1117
+ const body = typeof item === 'string' ? item : item.label;
1118
+ if (i === armed) out.push(`${theme.warn('✗')} ${theme.warn(bare(body))}`);
1119
+ else out.push(i === index ? `${blue('❯')} ${chalk.bold.white(body)}` : ` ${dim(body)}`);
1120
+ if (typeof item !== 'string' && item.sub) out.push(` ${item.sub}`);
1121
+ }
1122
+
1123
+ out.push('');
1124
+ out.push(armed !== null && armed !== undefined
1125
+ ? theme.warn(' press d again to delete this conversation · any other key keeps it')
1126
+ : dim(` ${hint}`));
1127
+ return out;
1128
+ }
1129
+
1130
+ /** A numbered list, answered on the input line. */
1131
+ async choose(prompt, items, { allowNone = true } = {}) {
1132
+ items.forEach((item, i) => this.push(` ${blue(String(i + 1).padStart(2))}. ${item}`));
1133
+ if (allowNone) this.push(dim(' 0. none — start fresh'));
1134
+ this.push('');
1135
+
1136
+ this.pendingPrompt = prompt;
1137
+ this.render();
1138
+
1139
+ const answer = await this.nextLine();
1140
+ this.pendingPrompt = null;
1141
+
1142
+ const trimmed = String(answer ?? '').trim();
1143
+ if (trimmed === '' || trimmed === '0') return null;
1144
+
1145
+ const index = Number(trimmed);
1146
+ if (!Number.isInteger(index) || index < 1 || index > items.length) {
1147
+ this.push(theme.warn(` "${trimmed}" is not one of 1-${items.length}.`));
1148
+ return null;
1149
+ }
1150
+ return index - 1;
1151
+ }
1152
+
1153
+ // -- keyboard and mouse --------------------------------------------------
1154
+
1155
+ /**
1156
+ * Scroll the transcript, clamped at both ends.
1157
+ *
1158
+ * When there is nothing above the fold, say so. Silence is indistinguishable
1159
+ * from broken input, and the difference matters: one means the conversation
1160
+ * simply fits, the other means the terminal is not forwarding keys at all.
1161
+ */
1162
+ scrollBy(delta) {
1163
+ const max = Math.max(0, this.lines.length - this.viewportHeight());
1164
+ if (max === 0) {
1165
+ this.flash('nothing above — it all fits on screen');
1166
+ return;
1167
+ }
1168
+ const before = this.scroll;
1169
+ this.scroll = Math.min(Math.max(0, this.scroll + delta), max);
1170
+ if (this.scroll === before && delta > 0) this.flash('already at the top');
1171
+ this.render();
1172
+ }
1173
+
1174
+ /**
1175
+ * Text arriving as a paste rather than as typing.
1176
+ *
1177
+ * A terminal in bracketed-paste mode wraps pasted text in markers, which is
1178
+ * the only way to tell forty lines pasted at once from forty lines typed
1179
+ * very fast. Without it every newline in the paste reads as Enter, so a
1180
+ * pasted block submits itself a line at a time and arrives as forty
1181
+ * messages. Inside the markers a newline is just a character.
1182
+ */
1183
+ onPaste(text) {
1184
+ const clean = String(text).replace(/\r\n?/g, '\n');
1185
+ this.buffer = this.buffer.slice(0, this.cursor) + clean + this.buffer.slice(this.cursor);
1186
+ this.cursor += clean.length;
1187
+ this.render();
1188
+ }
1189
+
1190
+ onData(chunk) {
1191
+ // Pasted text first: it is wrapped in markers and must not be read as
1192
+ // keys, or its newlines submit it in pieces.
1193
+ const paste = /\[200~([\s\S]*?)\[201~/g;
1194
+ if (paste.test(chunk)) {
1195
+ paste.lastIndex = 0;
1196
+ let at = 0;
1197
+ let m;
1198
+ while ((m = paste.exec(chunk))) {
1199
+ if (m.index > at) this.onData(chunk.slice(at, m.index));
1200
+ this.onPaste(m[1]);
1201
+ at = m.index + m[0].length;
1202
+ }
1203
+ if (at < chunk.length) this.onData(chunk.slice(at));
1204
+ return;
1205
+ }
1206
+ // An unterminated paste: hold what has arrived and wait for the rest.
1207
+ const open = chunk.indexOf('[200~');
1208
+ if (open !== -1) {
1209
+ if (open > 0) this.onData(chunk.slice(0, open));
1210
+ this.pasting = chunk.slice(open + 6);
1211
+ return;
1212
+ }
1213
+ if (this.pasting !== undefined && this.pasting !== null) {
1214
+ const close = chunk.indexOf('[201~');
1215
+ if (close === -1) { this.pasting += chunk; return; }
1216
+ this.onPaste(this.pasting + chunk.slice(0, close));
1217
+ this.pasting = null;
1218
+ const after = chunk.slice(close + 6);
1219
+ if (after) this.onData(after);
1220
+ return;
1221
+ }
1222
+
1223
+ // UCODE_DEBUG_KEYS=1 logs every byte the terminal sends to
1224
+ // ~/.ucode/keys.log. Whether mouse reporting works at all depends on the
1225
+ // terminal forwarding it; this is how to find out.
1226
+ if (process.env.UCODE_DEBUG_KEYS) {
1227
+ appendFile(path.join(homedir(), '.ucode', 'keys.log'), `${JSON.stringify(chunk)}\n`).catch(() => {});
1228
+ }
1229
+
1230
+ // Pull mouse reports out of the chunk wherever they sit. Anchoring the
1231
+ // match to the whole chunk meant a wheel event arriving alongside any
1232
+ // other byte was silently treated as typing.
1233
+ let rest = '';
1234
+ let index = 0;
1235
+ // Two encodings: SGR (ESC [ < b ; x ; y M|m), and the legacy form
1236
+ // (ESC [ M then three bytes offset by 32) for terminals that ignore 1006.
1237
+ const mouse = /\x1b\[<(\d+);(\d+);(\d+)([Mm])|\x1b\[M([\s\S])([\s\S])([\s\S])/g;
1238
+ let match;
1239
+
1240
+ while ((match = mouse.exec(chunk)) !== null) {
1241
+ rest += chunk.slice(index, match.index);
1242
+ index = match.index + match[0].length;
1243
+ if (match[1] !== undefined) {
1244
+ this.onMouse(Number(match[1]), Number(match[2]), Number(match[3]), match[4]);
1245
+ } else {
1246
+ this.onMouse(
1247
+ match[5].charCodeAt(0) - 32,
1248
+ match[6].charCodeAt(0) - 32,
1249
+ match[7].charCodeAt(0) - 32,
1250
+ 'M'
1251
+ );
1252
+ }
1253
+ }
1254
+ rest += chunk.slice(index);
1255
+
1256
+ // A chunk carrying a line break *and* other text did not come from a
1257
+ // keyboard: nobody types a newline in the middle of a burst. Many
1258
+ // terminals, Windows ones especially, send a paste with no markers at
1259
+ // all, so without this every newline in it reads as Enter and the paste
1260
+ // submits itself a line at a time.
1261
+ if (looksPasted(rest)) { this.onPaste(rest); return; }
1262
+
1263
+ for (const key of splitKeys(rest)) this.onKey(key);
1264
+ }
1265
+
1266
+ onMouse(button, col, row, press) {
1267
+ // Wheel reports set bit 6; bit 0 says which way.
1268
+ if (button >= 64) {
1269
+ this.scrollBy(button % 2 === 0 ? 3 : -3);
1270
+ return;
1271
+ }
1272
+ if (press !== 'M' || button !== 0) return;
1273
+ if (this.welcoming()) {
1274
+ const g = this.welcomeGeometry();
1275
+ const statusRow = g.boxTop + g.inputRows + 3; // 1-based
1276
+ if (row === statusRow && col > g.left + 1 && col <= g.left + this.chipTo) this.toggleMode();
1277
+ return;
1278
+ }
1279
+ // The mode chip, at the left of the bottom row.
1280
+ if (row === this.rows - 1 && col >= 2 && col <= this.chipTo) this.toggleMode();
1281
+ }
1282
+
1283
+ onKey(key) {
1284
+ // An open picker owns the keyboard until it closes.
1285
+ if (this.picker) {
1286
+ const last = this.picker.items.length - 1;
1287
+ if (this.picker.deletable && (key === 'd' || key === 'D' || key === `${ESC}[3~`)) {
1288
+ if (this.picker.armed === this.picker.index) { this.closePicker({ delete: this.picker.index }); return; }
1289
+ this.picker.armed = this.picker.index;
1290
+ this.render();
1291
+ return;
1292
+ }
1293
+ this.picker.armed = null; // any other key takes the delete back
1294
+ if (key === `${ESC}[A`) { this.picker.index = Math.max(0, this.picker.index - 1); this.render(); return; }
1295
+ if (key === `${ESC}[B`) { this.picker.index = Math.min(last, this.picker.index + 1); this.render(); return; }
1296
+ if (key === '\r' || key === '\n') { this.closePicker(this.picker.index); return; }
1297
+ if (key === ESC || key === '\x03') { this.closePicker(null); return; }
1298
+ return;
1299
+ }
1300
+
1301
+ switch (key) {
1302
+ case '\r':
1303
+ case '\n': {
1304
+ const text = this.buffer;
1305
+ this.buffer = '';
1306
+ this.cursor = 0;
1307
+ this.historyIndex = -1;
1308
+ if (text.trim()) {
1309
+ this.history.unshift(text);
1310
+ // Answers to a y/N or a numbered pick are not messages, so they are
1311
+ // not echoed: the prompt reports its own outcome.
1312
+ if (!this.pendingPrompt) this.userMessage(text);
1313
+ }
1314
+ this.render();
1315
+ this.submit(text);
1316
+ return;
1317
+ }
1318
+
1319
+ case '\x7f': // backspace
1320
+ case '\b':
1321
+ if (this.cursor > 0) {
1322
+ this.buffer = this.buffer.slice(0, this.cursor - 1) + this.buffer.slice(this.cursor);
1323
+ this.cursor--;
1324
+ }
1325
+ break;
1326
+
1327
+ case '\x03': // ctrl+c
1328
+ if (this.status.busy && this.onInterrupt) this.onInterrupt();
1329
+ else { this.buffer = ''; this.cursor = 0; }
1330
+ break;
1331
+
1332
+ case '\x04': // ctrl+d
1333
+ this.close();
1334
+ return;
1335
+
1336
+ case '\x02': // ctrl+b — swap plan and build
1337
+ this.toggleMode();
1338
+ return;
1339
+
1340
+ case '\x15': // ctrl+u — clear the line
1341
+ this.buffer = this.buffer.slice(this.cursor);
1342
+ this.cursor = 0;
1343
+ break;
1344
+
1345
+ case ESC: // esc — stop the turn in flight
1346
+ if (this.onInterrupt) this.onInterrupt();
1347
+ return;
1348
+
1349
+ case '\t': {
1350
+ const hit = COMMANDS.find((c) => c.startsWith(this.buffer));
1351
+ if (hit) { this.buffer = hit; this.cursor = hit.length; }
1352
+ break;
1353
+ }
1354
+
1355
+ // With an empty line the arrows scroll the conversation; once there is
1356
+ // something typed they walk history. Terminals often swallow PgUp and
1357
+ // PgDn for their own scrollback, so this is the path that always works.
1358
+ case `${ESC}[A`:
1359
+ if (!this.buffer) { this.scrollBy(2); return; }
1360
+ if (this.history.length) {
1361
+ this.historyIndex = Math.min(this.historyIndex + 1, this.history.length - 1);
1362
+ this.buffer = this.history[this.historyIndex] ?? '';
1363
+ this.cursor = this.buffer.length;
1364
+ }
1365
+ break;
1366
+
1367
+ case `${ESC}[B`:
1368
+ if (!this.buffer) { this.scrollBy(-2); return; }
1369
+ this.historyIndex = Math.max(this.historyIndex - 1, -1);
1370
+ this.buffer = this.historyIndex === -1 ? '' : (this.history[this.historyIndex] ?? '');
1371
+ this.cursor = this.buffer.length;
1372
+ break;
1373
+
1374
+ case `${ESC}[1;5A`: this.scrollBy(2); return; // ctrl+up
1375
+ case `${ESC}[1;5B`: this.scrollBy(-2); return; // ctrl+down
1376
+ case `${ESC}[5~`: this.scrollBy(this.viewportHeight()); return;
1377
+ case `${ESC}[6~`: this.scrollBy(-this.viewportHeight()); return;
1378
+
1379
+ case `${ESC}[H`: this.scrollBy(this.lines.length); return;
1380
+ case `${ESC}[F`: this.scroll = 0; this.render(); return;
1381
+
1382
+ case `${ESC}[C`: this.cursor = Math.min(this.cursor + 1, this.buffer.length); break;
1383
+ case `${ESC}[D`: this.cursor = Math.max(this.cursor - 1, 0); break;
1384
+
1385
+ default:
1386
+ if (key >= ' ' && !key.startsWith(ESC)) {
1387
+ this.buffer = this.buffer.slice(0, this.cursor) + key + this.buffer.slice(this.cursor);
1388
+ this.cursor += key.length;
1389
+ } else {
1390
+ return;
1391
+ }
1392
+ }
1393
+
1394
+ this.render();
1395
+ }
1396
+
1397
+ // -- painting ------------------------------------------------------------
1398
+
1399
+ render() {
1400
+ if (this.closed) return;
1401
+ if (this.welcoming()) {
1402
+ this.renderWelcome();
1403
+ return;
1404
+ }
1405
+
1406
+ const width = this.width();
1407
+ const height = this.viewportHeight();
1408
+
1409
+ const end = Math.max(0, this.lines.length - this.scroll);
1410
+ const start = Math.max(0, end - height);
1411
+ const window = this.picker ? this.pickerLines(height) : this.lines.slice(start, end);
1412
+ while (window.length < height) window.push('');
1413
+
1414
+ const frame = [
1415
+ ...this.headerLines(),
1416
+ '',
1417
+ ...window,
1418
+ // Always one clear row between the last thing said and the box you type
1419
+ // in. Without it the newest line of output sits against the border and
1420
+ // reads as part of the input rather than as the answer above it.
1421
+ '',
1422
+ ...this.inputBox(),
1423
+ ];
1424
+
1425
+ // The cursor is hidden for the duration of the paint. Without this it is
1426
+ // dragged through every line as the frame is written, which shows up as a
1427
+ // dot flickering above the input box on every keystroke.
1428
+ const out = [HIDE, HOME];
1429
+ for (let i = 0; i < this.rows; i++) {
1430
+ out.push(CLEAR_LINE + padVis(frame[i] ?? '', width) + (i === this.rows - 1 ? '' : '\n'));
1431
+ }
1432
+
1433
+ const [row, col] = this.caret();
1434
+ out.push(at(row, col) + SHOW);
1435
+ this.paintedBusy = this.busy();
1436
+ this.output.write(out.join(''));
1437
+ }
1438
+
1439
+ /**
1440
+ * Where the typing caret belongs, 1-based.
1441
+ *
1442
+ * Column three is the first character inside the box: border, a space of
1443
+ * padding, then the text.
1444
+ */
1445
+ caret() {
1446
+ if (this.welcoming()) {
1447
+ const g = this.welcomeGeometry();
1448
+ const { row, col } = this.caretAt(g.boxWidth - 4);
1449
+ // g.boxTop is 0-based and the typed lines start one below the border.
1450
+ return [g.boxTop + 2 + row, g.left + 3 + col];
1451
+ }
1452
+
1453
+ const { row, col: at, rows } = this.caretAt();
1454
+ const col = 3 + at;
1455
+ // Counting up from the bottom: the box border is the last row, the status
1456
+ // row is above it, then the blank row, then the typed lines.
1457
+ const firstRow = this.rows - 2 - rows.length;
1458
+ return [firstRow + row, col];
1459
+ }
1460
+
1461
+ // -- start screen ----------------------------------------------------------
1462
+
1463
+ /**
1464
+ * Nothing has been said yet, so there is nothing to scroll: the screen is the
1465
+ * wordmark and the place to type, centred, and nothing else.
1466
+ *
1467
+ * It comes back after /clear and /new too, since those empty the transcript —
1468
+ * a fresh conversation starts from the same quiet screen as a fresh launch.
1469
+ */
1470
+ welcoming() {
1471
+ return this.lines.length === 0 && !this.picker;
1472
+ }
1473
+
1474
+ /** Where everything on the start screen goes, 0-based rows. */
1475
+ welcomeGeometry() {
1476
+ // The true width here, not the capped one: the start screen centres itself,
1477
+ // and centring inside the cap would park it left of the middle of a wide
1478
+ // terminal. The session frame below is the thing that is left-aligned.
1479
+ const cols = this.screenWidth();
1480
+ const boxWidth = Math.max(30, Math.min(cols - 4, 84));
1481
+ const left = Math.max(0, Math.floor((cols - boxWidth) / 2));
1482
+ const big = cols >= BANNER_WIDTH + 4 && this.rows >= 18;
1483
+ const art = big ? BANNER : ['u c o d e'];
1484
+ const inputRows = this.inputLines(boxWidth - 4).rows.length;
1485
+ const block = art.length + 2 + inputRows + 4; // wordmark, gap, box
1486
+ // A touch above true centre reads as centred; exact centre looks low.
1487
+ const top = Math.max(0, Math.floor((this.rows - block) / 2) - 1);
1488
+ return { cols, boxWidth, left, big, art, inputRows, top, boxTop: top + art.length + 2 };
1489
+ }
1490
+
1491
+ renderWelcome() {
1492
+ const g = this.welcomeGeometry();
1493
+ const frame = new Array(this.rows).fill('');
1494
+
1495
+ // The wordmark lit from the top: sky at the crown, deep in the shadow
1496
+ // rows. Across the rows rather than along them — a name split down its
1497
+ // middle reads as two words, where a name that fades downward reads as
1498
+ // one object with a light on it.
1499
+ g.art.forEach((line, i) => {
1500
+ const pad = ' '.repeat(Math.max(0, Math.floor((g.cols - line.length) / 2)));
1501
+ frame[g.top + i] = pad + (g.big ? bannerPaint(i, g.art.length)(line) : blue.bold(line));
1502
+ });
1503
+
1504
+ const indent = ' '.repeat(g.left);
1505
+ this.inputBox(g.boxWidth).forEach((row, i) => {
1506
+ frame[g.boxTop + i] = indent + row;
1507
+ });
1508
+
1509
+ // The version, in the corner, and nothing else on the screen.
1510
+ if (VERSION) {
1511
+ const tag = dim(this.facts.update ? `v${VERSION} · v${this.facts.update} installed, starts next time` : `v${VERSION}`);
1512
+ frame[this.rows - 1] = ' '.repeat(Math.max(0, g.cols - visLen(tag) - 2)) + tag;
1513
+ }
1514
+
1515
+ const out = [HIDE, HOME];
1516
+ for (let i = 0; i < this.rows; i++) {
1517
+ out.push(CLEAR_LINE + padVis(frame[i], g.cols) + (i === this.rows - 1 ? '' : '\n'));
1518
+ }
1519
+ const [row, col] = this.caret();
1520
+ out.push(at(row, col) + SHOW);
1521
+ this.paintedBusy = this.busy();
1522
+ this.output.write(out.join(''));
1523
+ }
1524
+ }
1525
+
1526
+ /**
1527
+ * Split a raw stdin chunk into keys, keeping escape sequences whole.
1528
+ *
1529
+ * Application cursor key mode (DECCKM) makes a terminal send ESC O A for the
1530
+ * up arrow rather than ESC [ A. Both are normalised to the bracket form here
1531
+ * so the key handler only ever sees one of them.
1532
+ */
1533
+ /**
1534
+ * Did this arrive as a paste, judged by shape rather than by markers?
1535
+ *
1536
+ * Someone pressing Enter sends one carriage return on its own. A paste sends
1537
+ * a line break with text around it, in a single read. That difference is all
1538
+ * there is to go on when a terminal does not implement bracketed paste, and
1539
+ * it is enough.
1540
+ *
1541
+ * Anything carrying an escape sequence is left alone: that is a key or a
1542
+ * mouse report, and reading one as text would put gibberish in the input.
1543
+ */
1544
+ export function looksPasted(chunk) {
1545
+ const text = String(chunk ?? '');
1546
+ if (text.length < 2 || text.includes(ESC)) return false;
1547
+ const breaks = (text.match(/[\r\n]/g) ?? []).length;
1548
+ if (breaks === 0) return false;
1549
+ // One trailing break is someone finishing a line, not pasting one.
1550
+ if (breaks === 1 && /[\r\n]$/.test(text)) return false;
1551
+ return true;
1552
+ }
1553
+
1554
+ export function splitKeys(chunk) {
1555
+ const keys = [];
1556
+ let i = 0;
1557
+
1558
+ while (i < chunk.length) {
1559
+ const c = chunk[i];
1560
+ if (c !== ESC) { keys.push(c); i++; continue; }
1561
+
1562
+ const rest = chunk.slice(i);
1563
+ const csi = /^\x1b\[[0-9;?]*[A-Za-z~]/.exec(rest);
1564
+ if (csi) { keys.push(csi[0]); i += csi[0].length; continue; }
1565
+
1566
+ const ss3 = /^\x1bO([A-Za-z])/.exec(rest);
1567
+ if (ss3) { keys.push(`${ESC}[${ss3[1]}`); i += ss3[0].length; continue; }
1568
+
1569
+ keys.push(ESC);
1570
+ i++;
1571
+ }
1572
+
1573
+ return keys;
1574
+ }