ucode-agent 1.7.0 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ui/screen.js CHANGED
@@ -1,1253 +1,1252 @@
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,
43
- } 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',
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 MOUSE_ON = `${ESC}[?1007h${TRACK}`;
89
- const MOUSE_OFF = `${UNTRACK}${ESC}[?1007l`;
90
- const HIDE = `${ESC}[?25l`;
91
- const SHOW = `${ESC}[?25h`;
92
- const HOME = `${ESC}[H`;
93
- const CLEAR_LINE = `${ESC}[K`;
94
- const at = (row, col) => `${ESC}[${row};${col}H`;
95
- const title = (t) => `${ESC}]0;${t}\x07`;
96
-
97
- /**
98
- * Fixed rows below the header: the gap under it, the gap above the input box,
99
- * the input box's two borders, the blank row inside it, and the status row.
100
- */
101
- const CHROME_BELOW = 6;
102
-
103
- /** The wordmark only earns its place with room for the facts column beside it. */
104
- const WORDMARK_NEEDS = BANNER_WIDTH + 30;
105
-
106
- /** Where the U ends and CODE begins in each row of the wordmark. */
107
- const WORDMARK_SPLIT = 9;
108
-
109
- /** What the empty input box says before anything is typed. */
110
- const PLACEHOLDER = 'Ask anything…';
111
-
112
- export class Screen {
113
- constructor({ cwd, input = process.stdin, output = process.stdout } = {}) {
114
- this.cwd = cwd;
115
- this.input = input;
116
- this.output = output;
117
-
118
- this.lines = []; // the rendered transcript
119
- this.scroll = 0; // rows scrolled up from the bottom
120
- this.buffer = ''; // what is being typed
121
- this.cursor = 0;
122
- this.history = [];
123
- this.historyIndex = -1;
124
-
125
- this.status = { busy: false, text: '', frame: 0, since: 0 };
126
- this.facts = {};
127
- this.model = '';
128
-
129
- this.waiters = [];
130
- this.queue = [];
131
- this.closed = false;
132
-
133
- // 'build' may edit and run; 'plan' is read-only. Ctrl+B swaps them, and
134
- // the chip is clickable wherever the terminal forwards clicks.
135
- this.mode = 'build';
136
- this.chipTo = 0;
137
- this.onInterrupt = null;
138
- this.onModeChange = null;
139
- this.spinTimer = null;
140
- this.activity = null; // the turn in flight: when it began, how many steps
141
- this.tick = 0; // animation frames painted, for the spinner
142
- this.pendingPrompt = null;
143
-
144
- this.cols = output.columns || 80;
145
- this.rows = output.rows || 24;
146
- this.md = renderer(this.width());
147
- }
148
-
149
- // -- lifecycle -----------------------------------------------------------
150
-
151
- async start() {
152
- ensureColour(this.output);
153
- this.output.write(ALT_ON + MOUSE_ON + HIDE + title(`ucode — ${path.basename(this.cwd)}`));
154
- this.input.setRawMode?.(true);
155
- this.input.resume();
156
- this.input.setEncoding('utf8');
157
- this.input.on('data', (chunk) => this.onData(chunk));
158
-
159
- this.onResize = () => {
160
- this.cols = this.output.columns || 80;
161
- this.rows = this.output.rows || 24;
162
- this.md = renderer(this.width());
163
- this.render();
164
- };
165
- this.output.on('resize', this.onResize);
166
-
167
- this.render();
168
- }
169
-
170
- stop() {
171
- this.activity = null;
172
- this.stopSpinner();
173
- this.stopTimer();
174
- this.output.off?.('resize', this.onResize);
175
- this.input.setRawMode?.(false);
176
- this.input.pause();
177
- this.output.write(MOUSE_OFF + ALT_OFF + SHOW);
178
- }
179
-
180
- close() {
181
- if (this.closed) return;
182
- this.closed = true;
183
- this.stop();
184
- while (this.waiters.length) this.waiters.shift()(null);
185
- }
186
-
187
- width() {
188
- return Math.max(30, this.cols);
189
- }
190
-
191
- /** Usable width inside a box: two borders and a space of padding each side. */
192
- inner() {
193
- return Math.max(8, this.width() - 4);
194
- }
195
-
196
- // -- transcript ----------------------------------------------------------
197
-
198
- /**
199
- * Append without painting.
200
- *
201
- * Anything replacing a region of the transcript has to build the whole
202
- * region and then render once. Painting between the delete and the re-add
203
- * puts a frame on screen with the text missing, and at streaming speed that
204
- * reads as flicker.
205
- */
206
- add(text = '') {
207
- const width = this.width();
208
- for (const raw of String(text).split('\n')) {
209
- if (visLen(raw) <= width) this.lines.push(raw);
210
- else for (const wrapped of wrapAnsi(raw, width)) this.lines.push(wrapped);
211
- }
212
- this.scroll = 0; // new output snaps back to the bottom
213
- }
214
-
215
- push(text = '') {
216
- this.add(text);
217
- this.soon();
218
- }
219
-
220
- /**
221
- * Collapse a burst of pushes into one frame.
222
- *
223
- * Printing a list one line at a time repaints the screen per line a model
224
- * list of fifty entries drew a hundred frames back to back, which is visible
225
- * as a cascade. A microtask runs before any I/O, so everything pushed in one
226
- * synchronous stretch becomes a single render, while a push after an await
227
- * still paints immediately.
228
- */
229
- soon() {
230
- if (this.queued) return;
231
- this.queued = true;
232
- queueMicrotask(() => {
233
- this.queued = false;
234
- this.render();
235
- });
236
- }
237
-
238
- write(text = '') { this.push(text); }
239
- blank() { this.push(''); }
240
- note(text) { this.push(dim(` ${text}`)); }
241
-
242
- clearScreen() {
243
- this.lines = [];
244
- this.scroll = 0;
245
- this.render();
246
- }
247
-
248
- assistant(text) {
249
- if (!text?.trim()) return;
250
- this.add('');
251
- this.add(render(this.md, text));
252
- this.add('');
253
- this.render();
254
- }
255
-
256
- /**
257
- * Something the user said, in the conversation, in the same blue box as the
258
- * input it was typed into.
259
- *
260
- * A long session is mostly the agent's output tool calls, diffs, answers.
261
- * Your own messages are the landmarks you scroll back looking for, so they
262
- * get the frame: every one of them is findable at a glance, and the box
263
- * matches the one below so it is plain where each came from.
264
- */
265
- userMessage(text) {
266
- const width = this.width();
267
- const room = Math.max(8, width - 6); // borders, padding, and the caret column
268
-
269
- const rows = [];
270
- for (const paragraph of String(text).replace(/\r/g, '').split('\n')) {
271
- for (const line of wrapAnsi(paragraph, room)) rows.push(line);
272
- }
273
-
274
- this.add('');
275
- this.add(boxTop(width, edge));
276
- rows.forEach((row, i) => {
277
- const lead = i === 0 ? blue('›') : ' ';
278
- this.add(boxRow(` ${lead} ${chalk.white(row)}`, width, edge));
279
- });
280
- this.add(boxBottom(width, edge));
281
- this.render();
282
- }
283
-
284
- /**
285
- * A tool call, as it happens: "● Listing src".
286
- *
287
- * This lives in the transcript rather than only on the status line. The
288
- * status line overwrites itself and is empty by the end of the turn, so work
289
- * announced only there scrolls past unseen and the diff underneath ends up
290
- * with nothing above it explaining where it came from.
291
- */
292
- toolCall(label) {
293
- // U+25CF, not U+23FA: the latter carries emoji presentation, which Windows
294
- // Terminal draws as a white circle on a blue tile.
295
- this.push(`${blue('●')} ${asLabel(label)}`);
296
- this.updateSpinner(label);
297
- }
298
-
299
- /** The checklist, when the model updates it. One line, wrapped if it must. */
300
- plan(items) {
301
- const line = planLine(items);
302
- if (line) this.push(line);
303
- }
304
-
305
- toolResult(summary) {
306
- this.push(dim(` └ ${summary}`));
307
- }
308
-
309
- toolFailed(summary) {
310
- this.push(`${dim(' └ ')}${theme.error(summary)}`);
311
- }
312
-
313
- /**
314
- * The change itself, under the result.
315
- *
316
- * A line-number gutter, then the sign and the code tinted right across the
317
- * row. The numbers are the point: a diff you cannot navigate from is a
318
- * picture of a change rather than a record of one.
319
- */
320
- diff(lines) {
321
- const gutter = 6;
322
- // Two spaces of indent, the gutter, one space, then the tint fills the
323
- // rest. One column over and every row wraps, splitting the whole diff.
324
- const room = Math.max(12, this.width() - gutter - 3);
325
-
326
- for (const line of lines) {
327
- // A file heading in a multi-file write.
328
- if (line.startsWith('~')) {
329
- this.add(` ${dim(' '.repeat(gutter))} ${sky(line.slice(1))}`);
330
- continue;
331
- }
332
-
333
- const added = line.startsWith('+');
334
- const rest = line.slice(1);
335
- // Tools emit "<line>| <text>". A row with no number is the "12 more
336
- // lines" note, which is not part of the change, so it stays dim.
337
- const parsed = /^(\d+)\|\s?([\s\S]*)$/.exec(rest);
338
- if (!parsed) {
339
- this.add(` ${dim(' '.repeat(gutter))} ${dim(rest)}`);
340
- continue;
341
- }
342
-
343
- const [, number, body] = parsed;
344
- const tint = added ? ADDED : REMOVED;
345
- this.add(
346
- ` ${dim(number.padStart(gutter))} ` +
347
- // Tabs would leave the tint ending short of the row, so they widen.
348
- tint(padVis(clip(`${added ? '+' : '-'} ${body.replace(/\t/g, ' ')}`, room), room))
349
- );
350
- }
351
- this.render(); // a sixteen-line diff is one frame, not sixteen
352
- }
353
-
354
- /** Captured output under a command, dimmed so it reads as evidence. */
355
- commandOutput(lines) {
356
- for (const line of lines) this.add(` ${dim(line)}`);
357
- this.render();
358
- }
359
-
360
- /**
361
- * A running command's output, live — on the status line and nowhere else.
362
- *
363
- * Only the newest line, gone as soon as the next arrives. Appending each one
364
- * instead would mean a test run leaving sixty lines of "ok" in the
365
- * conversation permanently, which is noise the moment it scrolls. What
366
- * survives a command is decided when it ends: nothing if it worked, the tail
367
- * if it did not.
368
- */
369
- progress(lines) {
370
- const last = lines[lines.length - 1]?.trim();
371
- if (last) this.updateSpinner(last);
372
- }
373
-
374
- /**
375
- * The model's own account of the step it is taking, before it takes it.
376
- *
377
- * Not called status(): `this.status` holds the spinner state, and a method
378
- * of the same name would be shadowed by it on every instance.
379
- */
380
- narrate(text) {
381
- const line = asLabel(text);
382
- if (!line) return;
383
- this.push(dim(` ⋮ ${clip(line, this.width() - 6)}`));
384
- this.updateSpinner(line);
385
- }
386
-
387
- // -- streaming -----------------------------------------------------------
388
- // Deltas appear as plain text as they arrive, then get replaced in place by
389
- // properly rendered markdown once the reply is complete.
390
-
391
- streamBegin() {
392
- this.stopSpinner();
393
- this.streamAt = this.lines.length;
394
- this.streamBuf = '';
395
- this.streamPainted = 0;
396
- }
397
-
398
- streamDelta(delta) {
399
- if (this.streamAt === undefined) this.streamBegin();
400
- this.streamBuf += delta;
401
- const now = Date.now();
402
- if (now - this.streamPainted < 60) return; // about 16fps is plenty
403
- this.streamPainted = now;
404
- this.repaintStream();
405
- }
406
-
407
- /**
408
- * Repaint the partial reply.
409
- *
410
- * polish() runs on the partial text so bold, inline code and bullets are
411
- * already styled while it streams. Without it the text arrives raw and then
412
- * visibly re-renders at the end, which reads as a glitch.
413
- */
414
- repaintStream() {
415
- this.lines.length = this.streamAt;
416
- this.add('');
417
- this.add(polish(this.streamBuf));
418
- this.render(); // one frame, and never one without the reply in it
419
- }
420
-
421
- /**
422
- * Finish a streamed reply.
423
- *
424
- * `asLabel` says the text turned out to be narration ahead of a tool call
425
- * rather than an answer, in which case one short line folds down into the
426
- * status line it was always meant to be.
427
- */
428
- streamEnd({ asNarration = false } = {}) {
429
- if (this.streamAt === undefined) return '';
430
- const text = this.streamBuf;
431
- this.lines.length = this.streamAt;
432
- this.streamAt = undefined;
433
- this.streamBuf = '';
434
-
435
- if (asNarration && isLabel(text)) this.narrate(text);
436
- else if (text.trim()) this.assistant(text);
437
- else this.render();
438
- return text;
439
- }
440
-
441
- // -- thinking ------------------------------------------------------------
442
- // A reasoning model does all its working before it says anything. None of it
443
- // is printed: it is long, repetitive, and guesses drawn from it read worse
444
- // than silence. The spinner counts the seconds so the wait is visibly alive,
445
- // and the transcript gets one line afterwards saying how long it took.
446
-
447
- thinkingDelta() {
448
- if (this.thoughtSince === undefined) this.thoughtSince = Date.now();
449
- }
450
-
451
- thinkingEnd() {
452
- if (this.thoughtSince === undefined) return;
453
- const seconds = Math.round((Date.now() - this.thoughtSince) / 1000);
454
- if (seconds >= 2) this.push(dim(` ⋮ thought for ${seconds}s`));
455
- this.thoughtSince = undefined;
456
- }
457
-
458
- error(err, { debug = false } = {}) {
459
- const known = err && typeof err === 'object' && err.attempted;
460
- this.push('');
461
- if (known) {
462
- this.push(`${theme.error('✗')} ${chalk.white(`Failed while ${err.attempted}.`)}`);
463
- this.push(` ${err.failed}`);
464
- if (err.fix) this.push(` ${blue('→')} ${err.fix}`);
465
- if (err.kind) this.push(dim(` (${err.kind})`));
466
- } else {
467
- this.push(`${theme.error('✗')} ${chalk.white('Something broke inside ucode.')}`);
468
- this.push(` ${err?.message ?? String(err)}`);
469
- this.push(` ${blue('→')} That is a bug in ucode rather than in your project. Re-run with --debug.`);
470
- }
471
- if (debug) {
472
- const stack = (known && err.cause?.stack) || err?.stack;
473
- if (stack) this.push(dim(stack));
474
- }
475
- this.push('');
476
- }
477
-
478
- // -- header --------------------------------------------------------------
479
-
480
- setFacts(facts) {
481
- this.facts = { ...this.facts, ...facts };
482
- if (facts.model) this.model = facts.model;
483
- this.render();
484
- }
485
-
486
- /** Same shape as the plain UI's header(), so the loop needs no branch. */
487
- header({ cwd, model, used, limit, title: sessionTitle }) {
488
- this.setFacts({
489
- cwd,
490
- model,
491
- title: sessionTitle,
492
- percent: limit > 0 ? Math.min(100, Math.round((used / limit) * 100)) : 0,
493
- });
494
- }
495
-
496
- /**
497
- * How many rows the header box occupies.
498
- *
499
- * The frame has to be exactly as tall as the terminal or every row below the
500
- * shortfall is off by that much — including the one the caret is parked on.
501
- * So this is derived, never assumed.
502
- */
503
- headerHeight() {
504
- return this.width() >= WORDMARK_NEEDS ? BANNER.length + 2 : 5;
505
- }
506
-
507
- headerLines() {
508
- const width = this.width();
509
- const inner = width - 2; // between the borders
510
-
511
- if (width < WORDMARK_NEEDS) {
512
- // Too narrow for the wordmark: stack it rather than wrap it into noise.
513
- const rows = [
514
- ` ${blue.bold('U C O D E')} ${dim('terminal coding agent')}`,
515
- ` ${dim('dir'.padEnd(8))}${chalk.white(clip(shortenPath(this.facts.cwd ?? this.cwd, inner - 12), inner - 12))}`,
516
- ];
517
- return [boxTop(width), ...rows.map((r) => boxRow(r, width)), boxBottom(width)];
518
- }
519
-
520
- // Two spaces of padding, the wordmark, a gap, then the facts column.
521
- //
522
- // Only what you cannot work out by looking: where you are, and how to get
523
- // help. How full the window is belongs on the status row next to the model
524
- // it describes, and the session title is already the terminal's own window
525
- // title repeating either here is a second place to keep in sync for no
526
- // reader who needed it.
527
- const room = Math.max(8, inner - BANNER_WIDTH - 6);
528
- const facts = [
529
- ['dir', shortenPath(this.facts.cwd ?? this.cwd, room - 9)],
530
- ['keys', '/help · esc interrupts'],
531
- ['', ''],
532
- ['', ''],
533
- ['', ''],
534
- ['', 'made with ❤️ by om dixit'],
535
- ];
536
-
537
- const rows = BANNER.map((art, i) => {
538
- const [label, value] = facts[i] ?? ['', ''];
539
- const right = label
540
- ? `${dim(label.padEnd(9))}${chalk.white(clip(value, room - 9))}`
541
- : (value ? dim(value) : '');
542
- return ` ${blue(art)} ${right}`;
543
- });
544
-
545
- return [boxTop(width), ...rows.map((r) => boxRow(r, width)), boxBottom(width)];
546
- }
547
-
548
- // -- input box -----------------------------------------------------------
549
-
550
- /** The typed line, wrapped to the inside of a box `width` characters across. */
551
- inputLines(width = this.inner()) {
552
- const prefix = this.pendingPrompt ? `${this.pendingPrompt} ` : '› ';
553
- const full = prefix + this.buffer;
554
-
555
- const rows = [];
556
- for (let i = 0; i < full.length; i += width) rows.push(full.slice(i, i + width));
557
- if (rows.length === 0) rows.push(prefix);
558
-
559
- return { rows, prefix, width };
560
- }
561
-
562
- viewportHeight() {
563
- return Math.max(
564
- 3,
565
- this.rows - this.headerHeight() - CHROME_BELOW - this.inputLines().rows.length
566
- );
567
- }
568
-
569
- /**
570
- * The input box: what you are typing, and directly under it, inside the same
571
- * border, the three things worth knowing while you type.
572
- *
573
- * The status used to sit outside the box on the last row of the screen,
574
- * which made it a separate object floating under the input. Inside the
575
- * border it reads as part of the thing you are using — the box says "this is
576
- * where you work", and the row underneath says what you are working with.
577
- */
578
- inputBox(width = this.width()) {
579
- const { rows } = this.inputLines(width - 4);
580
- // Nothing typed yet: a quiet prompt where the text will go. The caret sits
581
- // on its first letter and typing replaces it.
582
- const empty = !this.buffer && !this.pendingPrompt;
583
- const painted = rows.map((row, i) =>
584
- i === 0
585
- ? boxRow(` ${blue('›')}${empty ? ` ${dim(PLACEHOLDER)}` : row.slice(1)}`, width, edge)
586
- : boxRow(` ${row}`, width, edge)
587
- );
588
- return [
589
- boxTop(width, edge),
590
- ...painted,
591
- // A blank row between the two. Sitting directly under the caret, the
592
- // status read as a second line of the thing being typed; one row of air
593
- // separates what you are writing from what you are writing it with.
594
- boxRow('', width, edge),
595
- boxRow(this.statusRow(width), width, edge),
596
- boxBottom(width, edge),
597
- ];
598
- }
599
-
600
- // -- status row ----------------------------------------------------------
601
-
602
- modeChip() {
603
- return this.mode === 'plan' ? `${sky('◇')} ${sky('Plan')}` : `${blue('◆')} ${blue('Build')}`;
604
- }
605
-
606
- /**
607
- * How full the context window is, as a bare number.
608
- *
609
- * It turns amber at 75% because that is where turns start being folded away
610
- * into a summary the one moment the number predicts something you would
611
- * want to know before it happens.
612
- */
613
- percentChip() {
614
- const percent = Math.round(this.facts.percent ?? 0);
615
- return percent >= 75 ? theme.warn(`${percent}%`) : dim(`${percent}%`);
616
- }
617
-
618
- /**
619
- * Which mode is live, which model is answering, and how full the window is.
620
- *
621
- * Nothing else earns a place. The provider name was there and was cut: it is
622
- * the same on every line of every session, so it was decoration that had to
623
- * be read past to reach the two things that do change.
624
- *
625
- * The middle is borrowed while something is running, for the spinner and the
626
- * way out of it, and handed straight back when it finishes.
627
- */
628
- statusRow(width = this.width()) {
629
- const inner = width - 2; // the space between the two borders
630
- const chip = this.modeChip();
631
- const left = ` ${chip} ${dim('·')} ${chalk.white(this.model || '—')}`;
632
- const right = `${this.percentChip()} `;
633
-
634
- // Where a click on the bottom row still counts as hitting the mode chip.
635
- this.chipTo = 2 + visLen(chip);
636
-
637
- const between = Math.max(1, inner - visLen(left) - visLen(right));
638
-
639
- let middle = '';
640
- if (this.flashText) {
641
- middle = dim(clip(this.flashText, between - 2));
642
- } else if (this.status.busy || this.activity) {
643
- // The whole turn, not just the current tool: the timer and step count
644
- // keep going through the gaps between calls, so a long build never
645
- // looks like it has stopped.
646
- const now = Date.now();
647
- const a = this.activity;
648
- const since = a?.start ?? this.status.since ?? now;
649
- const meta = [];
650
- if (a?.steps) meta.push({ text: `step ${a.steps}`, paint: stepPaint(now - a.movedAt < 900) });
651
- if (now - since >= 1000) meta.push({ text: formatDuration(now - since), keep: true });
652
- middle = fitActivity({
653
- glyph: spinnerGlyph(this.tick, now),
654
- label: this.status.busy ? this.status.text : 'working',
655
- meta,
656
- hint: 'esc to stop',
657
- paint: (s) => shimmer(s, now),
658
- }, between - 3);
659
- }
660
-
661
- // The percentage is pinned to the right border whatever is in the middle,
662
- // with a gap kept in front of it so a long spinner label cannot run into
663
- // the number and read as part of it.
664
- const tail = middle ? `${middle} ` : '';
665
- const pad = Math.max(1, inner - visLen(left) - visLen(tail) - visLen(right));
666
- return padVis(left + ' '.repeat(pad) + tail + right, inner);
667
- }
668
-
669
- /**
670
- * Repaint only the status row, leaving the caret where the user left it.
671
- *
672
- * It is the second row from the bottom now the box's own border is below
673
- * it — so the row is written with its borders rather than as a bare line.
674
- */
675
- paintStatus() {
676
- if (this.closed) return;
677
- // On the start screen the status row is mid-screen, not second from the
678
- // bottom, so the cheap single-row repaint would draw it in the wrong place.
679
- if (this.welcoming()) {
680
- this.render();
681
- return;
682
- }
683
- const [row, col] = this.caret();
684
- this.output.write(
685
- HIDE +
686
- at(this.rows - 1, 1) + CLEAR_LINE + boxRow(this.statusRow(), this.width(), edge) +
687
- at(row, col) + SHOW
688
- );
689
- }
690
-
691
- toggleMode() {
692
- this.mode = this.mode === 'plan' ? 'build' : 'plan';
693
- this.flash(this.mode === 'plan'
694
- ? 'plan mode — reads and researches, changes nothing'
695
- : 'build mode — free to edit files and run commands');
696
- this.onModeChange?.(this.mode);
697
- this.render();
698
- }
699
-
700
- /** A message on the status line that fades on its own. */
701
- flash(text) {
702
- this.flashText = text;
703
- clearTimeout(this.flashTimer);
704
- this.flashTimer = setTimeout(() => {
705
- this.flashText = null;
706
- this.paintStatus();
707
- }, 2500);
708
- this.flashTimer.unref?.();
709
- this.paintStatus();
710
- }
711
-
712
- // -- spinner -------------------------------------------------------------
713
-
714
- startSpinner(text = 'thinking') {
715
- // `since` is what makes a long think legible: the label may not change for
716
- // a minute, so the seconds beside it are the proof it is still alive.
717
- this.status = { busy: true, text: asLabel(text), frame: 0, since: Date.now() };
718
- this.startTimer();
719
- this.paintStatus();
720
- }
721
-
722
- updateSpinner(text) {
723
- if (!this.status.busy) return;
724
- this.status.text = asLabel(text);
725
- this.paintStatus();
726
- }
727
-
728
- stopSpinner() {
729
- if (!this.activity) this.stopTimer();
730
- if (this.status.busy) {
731
- this.status = { busy: false, text: '', frame: 0, since: 0 };
732
- this.paintStatus();
733
- }
734
- }
735
-
736
- // -- the turn in flight ----------------------------------------------------
737
-
738
- /** A turn begins: the timer and step count run until turnEnd(). */
739
- turnStart() {
740
- this.activity = { start: Date.now(), steps: 0, movedAt: 0 };
741
- this.startTimer();
742
- }
743
-
744
- /** One more model step in this turn. */
745
- step() {
746
- if (!this.activity) return;
747
- this.activity.steps++;
748
- this.activity.movedAt = Date.now();
749
- }
750
-
751
- /** The turn is over: leave "✓ Done in 6m 12s · 25 steps" under the answer. */
752
- turnEnd({ ok = true } = {}) {
753
- const a = this.activity;
754
- this.activity = null;
755
- if (!this.status.busy) this.stopTimer();
756
- if (a && ok && Date.now() - a.start >= 2000) this.push(` ${doneLine(Date.now() - a.start, a.steps)}`);
757
- this.paintStatus();
758
- }
759
-
760
- /** The animation clock: only the status row repaints, about twelve times a second. */
761
- startTimer() {
762
- if (this.spinTimer) return;
763
- this.spinTimer = setInterval(() => {
764
- this.tick++;
765
- this.paintStatus();
766
- }, FRAME_MS);
767
- this.spinTimer.unref?.();
768
- }
769
-
770
- stopTimer() {
771
- if (!this.spinTimer) return;
772
- clearInterval(this.spinTimer);
773
- this.spinTimer = null;
774
- }
775
-
776
- // -- input ---------------------------------------------------------------
777
-
778
- nextLine() {
779
- if (this.queue.length) return Promise.resolve(this.queue.shift());
780
- if (this.closed) return Promise.resolve(null);
781
- return new Promise((resolve) => this.waiters.push(resolve));
782
- }
783
-
784
- ask() {
785
- return this.nextLine();
786
- }
787
-
788
- submit(text) {
789
- const waiter = this.waiters.shift();
790
- if (waiter) waiter(text);
791
- else this.queue.push(text);
792
- }
793
-
794
- /** y/n, answered on the input line. */
795
- confirm({ action, detail, risk }) {
796
- this.push('');
797
- this.push(`${chalk.inverse(theme.warn(risk === 'command' ? ' shell ' : ' outside project '))} ${chalk.white(action)}`);
798
- for (const line of String(detail ?? '').split('\n')) {
799
- if (line) this.push(dim(` ${line}`));
800
- }
801
-
802
- this.pendingPrompt = 'go ahead? [y/N]';
803
- this.render();
804
-
805
- return this.nextLine().then((answer) => {
806
- this.pendingPrompt = null;
807
- // End of input counts as no. Never run something nobody approved.
808
- const yes = /^(y|yes)$/i.test(String(answer ?? '').trim());
809
- this.push(dim(yes ? ' approved' : ' declined'));
810
- this.push('');
811
- return yes;
812
- });
813
- }
814
-
815
- /**
816
- * A modal list: arrows move, Enter picks, Esc cancels.
817
- *
818
- * Only while this is open do the arrows stop scrolling the transcript. They
819
- * cannot be given up permanently, because under alternate scroll the mouse
820
- * wheel arrives as arrow keys.
821
- */
822
- pick(items, { active = 0, hint = 'enter to choose · esc to cancel', deletable = false } = {}) {
823
- this.picker = {
824
- items,
825
- index: Math.min(Math.max(0, active), Math.max(0, items.length - 1)),
826
- hint,
827
- // With deletable, `d` twice on a row resolves { delete: index }. Twice,
828
- // because a single stray keypress should never cost a conversation.
829
- deletable,
830
- armed: null,
831
- };
832
- this.render();
833
- return new Promise((resolve) => { this.pickerResolve = resolve; });
834
- }
835
-
836
- closePicker(value) {
837
- const resolve = this.pickerResolve;
838
- this.picker = null;
839
- this.pickerResolve = null;
840
- this.render();
841
- resolve?.(value);
842
- }
843
-
844
- /**
845
- * Rows for an open picker, windowed so a long list still fits.
846
- *
847
- * An item may carry a `sub` line a second, dimmer row underneath it. That
848
- * is what lets a list of saved conversations show what each one was actually
849
- * about instead of a column of near-identical titles.
850
- */
851
- pickerLines(height) {
852
- const { items, index, hint, armed } = this.picker;
853
- const room = Math.max(1, height - 2);
854
-
855
- // Rows per item, so the window can be sized in rows rather than in items.
856
- const rowsFor = (item) => (typeof item !== 'string' && item.sub ? 2 : 1);
857
- const perItem = items.map(rowsFor);
858
-
859
- // Walk outward from the selection until the window is full. Starting from
860
- // the selection guarantees it is on screen however long the list is.
861
- let first = index;
862
- let last = index;
863
- let used = perItem[index] ?? 1;
864
- while (used < room && (first > 0 || last < items.length - 1)) {
865
- if (first > 0 && used + perItem[first - 1] <= room) { first--; used += perItem[first]; }
866
- else if (last < items.length - 1 && used + perItem[last + 1] <= room) { last++; used += perItem[last]; }
867
- else break;
868
- }
869
-
870
- const out = [];
871
- for (let i = first; i <= last; i++) {
872
- const item = items[i];
873
- const body = typeof item === 'string' ? item : item.label;
874
- if (i === armed) out.push(`${theme.warn('')} ${theme.warn(bare(body))}`);
875
- else out.push(i === index ? `${blue('')} ${chalk.bold.white(body)}` : ` ${dim(body)}`);
876
- if (typeof item !== 'string' && item.sub) out.push(` ${item.sub}`);
877
- }
878
-
879
- out.push('');
880
- out.push(armed !== null && armed !== undefined
881
- ? theme.warn(' press d again to delete this conversation · any other key keeps it')
882
- : dim(` ${hint}`));
883
- return out;
884
- }
885
-
886
- /** A numbered list, answered on the input line. */
887
- async choose(prompt, items, { allowNone = true } = {}) {
888
- items.forEach((item, i) => this.push(` ${blue(String(i + 1).padStart(2))}. ${item}`));
889
- if (allowNone) this.push(dim(' 0. none — start fresh'));
890
- this.push('');
891
-
892
- this.pendingPrompt = prompt;
893
- this.render();
894
-
895
- const answer = await this.nextLine();
896
- this.pendingPrompt = null;
897
-
898
- const trimmed = String(answer ?? '').trim();
899
- if (trimmed === '' || trimmed === '0') return null;
900
-
901
- const index = Number(trimmed);
902
- if (!Number.isInteger(index) || index < 1 || index > items.length) {
903
- this.push(theme.warn(` "${trimmed}" is not one of 1-${items.length}.`));
904
- return null;
905
- }
906
- return index - 1;
907
- }
908
-
909
- // -- keyboard and mouse --------------------------------------------------
910
-
911
- /**
912
- * Scroll the transcript, clamped at both ends.
913
- *
914
- * When there is nothing above the fold, say so. Silence is indistinguishable
915
- * from broken input, and the difference matters: one means the conversation
916
- * simply fits, the other means the terminal is not forwarding keys at all.
917
- */
918
- scrollBy(delta) {
919
- const max = Math.max(0, this.lines.length - this.viewportHeight());
920
- if (max === 0) {
921
- this.flash('nothing above — it all fits on screen');
922
- return;
923
- }
924
- const before = this.scroll;
925
- this.scroll = Math.min(Math.max(0, this.scroll + delta), max);
926
- if (this.scroll === before && delta > 0) this.flash('already at the top');
927
- this.render();
928
- }
929
-
930
- onData(chunk) {
931
- // UCODE_DEBUG_KEYS=1 logs every byte the terminal sends to
932
- // ~/.ucode/keys.log. Whether mouse reporting works at all depends on the
933
- // terminal forwarding it; this is how to find out.
934
- if (process.env.UCODE_DEBUG_KEYS) {
935
- appendFile(path.join(homedir(), '.ucode', 'keys.log'), `${JSON.stringify(chunk)}\n`).catch(() => {});
936
- }
937
-
938
- // Pull mouse reports out of the chunk wherever they sit. Anchoring the
939
- // match to the whole chunk meant a wheel event arriving alongside any
940
- // other byte was silently treated as typing.
941
- let rest = '';
942
- let index = 0;
943
- // Two encodings: SGR (ESC [ < b ; x ; y M|m), and the legacy form
944
- // (ESC [ M then three bytes offset by 32) for terminals that ignore 1006.
945
- const mouse = /\x1b\[<(\d+);(\d+);(\d+)([Mm])|\x1b\[M([\s\S])([\s\S])([\s\S])/g;
946
- let match;
947
-
948
- while ((match = mouse.exec(chunk)) !== null) {
949
- rest += chunk.slice(index, match.index);
950
- index = match.index + match[0].length;
951
- if (match[1] !== undefined) {
952
- this.onMouse(Number(match[1]), Number(match[2]), Number(match[3]), match[4]);
953
- } else {
954
- this.onMouse(
955
- match[5].charCodeAt(0) - 32,
956
- match[6].charCodeAt(0) - 32,
957
- match[7].charCodeAt(0) - 32,
958
- 'M'
959
- );
960
- }
961
- }
962
- rest += chunk.slice(index);
963
-
964
- for (const key of splitKeys(rest)) this.onKey(key);
965
- }
966
-
967
- onMouse(button, col, row, press) {
968
- // Wheel reports set bit 6; bit 0 says which way.
969
- if (button >= 64) {
970
- this.scrollBy(button % 2 === 0 ? 3 : -3);
971
- return;
972
- }
973
- if (press !== 'M' || button !== 0) return;
974
- if (this.welcoming()) {
975
- const g = this.welcomeGeometry();
976
- const statusRow = g.boxTop + g.inputRows + 3; // 1-based
977
- if (row === statusRow && col > g.left + 1 && col <= g.left + this.chipTo) this.toggleMode();
978
- return;
979
- }
980
- // The mode chip, at the left of the bottom row.
981
- if (row === this.rows - 1 && col >= 2 && col <= this.chipTo) this.toggleMode();
982
- }
983
-
984
- onKey(key) {
985
- // An open picker owns the keyboard until it closes.
986
- if (this.picker) {
987
- const last = this.picker.items.length - 1;
988
- if (this.picker.deletable && (key === 'd' || key === 'D' || key === `${ESC}[3~`)) {
989
- if (this.picker.armed === this.picker.index) { this.closePicker({ delete: this.picker.index }); return; }
990
- this.picker.armed = this.picker.index;
991
- this.render();
992
- return;
993
- }
994
- this.picker.armed = null; // any other key takes the delete back
995
- if (key === `${ESC}[A`) { this.picker.index = Math.max(0, this.picker.index - 1); this.render(); return; }
996
- if (key === `${ESC}[B`) { this.picker.index = Math.min(last, this.picker.index + 1); this.render(); return; }
997
- if (key === '\r' || key === '\n') { this.closePicker(this.picker.index); return; }
998
- if (key === ESC || key === '\x03') { this.closePicker(null); return; }
999
- return;
1000
- }
1001
-
1002
- switch (key) {
1003
- case '\r':
1004
- case '\n': {
1005
- const text = this.buffer;
1006
- this.buffer = '';
1007
- this.cursor = 0;
1008
- this.historyIndex = -1;
1009
- if (text.trim()) {
1010
- this.history.unshift(text);
1011
- // Answers to a y/N or a numbered pick are not messages, so they are
1012
- // not echoed: the prompt reports its own outcome.
1013
- if (!this.pendingPrompt) this.userMessage(text);
1014
- }
1015
- this.render();
1016
- this.submit(text);
1017
- return;
1018
- }
1019
-
1020
- case '\x7f': // backspace
1021
- case '\b':
1022
- if (this.cursor > 0) {
1023
- this.buffer = this.buffer.slice(0, this.cursor - 1) + this.buffer.slice(this.cursor);
1024
- this.cursor--;
1025
- }
1026
- break;
1027
-
1028
- case '\x03': // ctrl+c
1029
- if (this.status.busy && this.onInterrupt) this.onInterrupt();
1030
- else { this.buffer = ''; this.cursor = 0; }
1031
- break;
1032
-
1033
- case '\x04': // ctrl+d
1034
- this.close();
1035
- return;
1036
-
1037
- case '\x02': // ctrl+b — swap plan and build
1038
- this.toggleMode();
1039
- return;
1040
-
1041
- case '\x15': // ctrl+u — clear the line
1042
- this.buffer = this.buffer.slice(this.cursor);
1043
- this.cursor = 0;
1044
- break;
1045
-
1046
- case ESC: // esc — stop the turn in flight
1047
- if (this.onInterrupt) this.onInterrupt();
1048
- return;
1049
-
1050
- case '\t': {
1051
- const hit = COMMANDS.find((c) => c.startsWith(this.buffer));
1052
- if (hit) { this.buffer = hit; this.cursor = hit.length; }
1053
- break;
1054
- }
1055
-
1056
- // With an empty line the arrows scroll the conversation; once there is
1057
- // something typed they walk history. Terminals often swallow PgUp and
1058
- // PgDn for their own scrollback, so this is the path that always works.
1059
- case `${ESC}[A`:
1060
- if (!this.buffer) { this.scrollBy(2); return; }
1061
- if (this.history.length) {
1062
- this.historyIndex = Math.min(this.historyIndex + 1, this.history.length - 1);
1063
- this.buffer = this.history[this.historyIndex] ?? '';
1064
- this.cursor = this.buffer.length;
1065
- }
1066
- break;
1067
-
1068
- case `${ESC}[B`:
1069
- if (!this.buffer) { this.scrollBy(-2); return; }
1070
- this.historyIndex = Math.max(this.historyIndex - 1, -1);
1071
- this.buffer = this.historyIndex === -1 ? '' : (this.history[this.historyIndex] ?? '');
1072
- this.cursor = this.buffer.length;
1073
- break;
1074
-
1075
- case `${ESC}[1;5A`: this.scrollBy(2); return; // ctrl+up
1076
- case `${ESC}[1;5B`: this.scrollBy(-2); return; // ctrl+down
1077
- case `${ESC}[5~`: this.scrollBy(this.viewportHeight()); return;
1078
- case `${ESC}[6~`: this.scrollBy(-this.viewportHeight()); return;
1079
-
1080
- case `${ESC}[H`: this.scrollBy(this.lines.length); return;
1081
- case `${ESC}[F`: this.scroll = 0; this.render(); return;
1082
-
1083
- case `${ESC}[C`: this.cursor = Math.min(this.cursor + 1, this.buffer.length); break;
1084
- case `${ESC}[D`: this.cursor = Math.max(this.cursor - 1, 0); break;
1085
-
1086
- default:
1087
- if (key >= ' ' && !key.startsWith(ESC)) {
1088
- this.buffer = this.buffer.slice(0, this.cursor) + key + this.buffer.slice(this.cursor);
1089
- this.cursor += key.length;
1090
- } else {
1091
- return;
1092
- }
1093
- }
1094
-
1095
- this.render();
1096
- }
1097
-
1098
- // -- painting ------------------------------------------------------------
1099
-
1100
- render() {
1101
- if (this.closed) return;
1102
- if (this.welcoming()) {
1103
- this.renderWelcome();
1104
- return;
1105
- }
1106
-
1107
- const width = this.width();
1108
- const height = this.viewportHeight();
1109
-
1110
- const end = Math.max(0, this.lines.length - this.scroll);
1111
- const start = Math.max(0, end - height);
1112
- const window = this.picker ? this.pickerLines(height) : this.lines.slice(start, end);
1113
- while (window.length < height) window.push('');
1114
-
1115
- const frame = [
1116
- ...this.headerLines(),
1117
- '',
1118
- ...window,
1119
- // Always one clear row between the last thing said and the box you type
1120
- // in. Without it the newest line of output sits against the border and
1121
- // reads as part of the input rather than as the answer above it.
1122
- '',
1123
- ...this.inputBox(),
1124
- ];
1125
-
1126
- // The cursor is hidden for the duration of the paint. Without this it is
1127
- // dragged through every line as the frame is written, which shows up as a
1128
- // dot flickering above the input box on every keystroke.
1129
- const out = [HIDE, HOME];
1130
- for (let i = 0; i < this.rows; i++) {
1131
- out.push(CLEAR_LINE + padVis(frame[i] ?? '', width) + (i === this.rows - 1 ? '' : '\n'));
1132
- }
1133
-
1134
- const [row, col] = this.caret();
1135
- out.push(at(row, col) + SHOW);
1136
- this.output.write(out.join(''));
1137
- }
1138
-
1139
- /**
1140
- * Where the typing caret belongs, 1-based.
1141
- *
1142
- * Column three is the first character inside the box: border, a space of
1143
- * padding, then the text.
1144
- */
1145
- caret() {
1146
- if (this.welcoming()) {
1147
- const g = this.welcomeGeometry();
1148
- const { rows, prefix, width } = this.inputLines(g.boxWidth - 4);
1149
- const index = prefix.length + this.cursor;
1150
- const row = Math.min(Math.floor(index / width), rows.length - 1);
1151
- // g.boxTop is 0-based and the typed lines start one below the border.
1152
- return [g.boxTop + 2 + row, g.left + 3 + (index % width)];
1153
- }
1154
-
1155
- const { rows, prefix, width } = this.inputLines();
1156
- const index = prefix.length + this.cursor;
1157
- const row = Math.min(Math.floor(index / width), rows.length - 1);
1158
- const col = 3 + (index % width);
1159
- // Counting up from the bottom: the box border is the last row, the status
1160
- // row is above it, then the blank row, then the typed lines.
1161
- const firstRow = this.rows - 2 - rows.length;
1162
- return [firstRow + row, col];
1163
- }
1164
-
1165
- // -- start screen ----------------------------------------------------------
1166
-
1167
- /**
1168
- * Nothing has been said yet, so there is nothing to scroll: the screen is the
1169
- * wordmark and the place to type, centred, and nothing else.
1170
- *
1171
- * It comes back after /clear and /new too, since those empty the transcript —
1172
- * a fresh conversation starts from the same quiet screen as a fresh launch.
1173
- */
1174
- welcoming() {
1175
- return this.lines.length === 0 && !this.picker;
1176
- }
1177
-
1178
- /** Where everything on the start screen goes, 0-based rows. */
1179
- welcomeGeometry() {
1180
- const cols = this.width();
1181
- const boxWidth = Math.max(30, Math.min(cols - 4, 84));
1182
- const left = Math.max(0, Math.floor((cols - boxWidth) / 2));
1183
- const big = cols >= BANNER_WIDTH + 4 && this.rows >= 18;
1184
- const art = big ? BANNER : ['u c o d e'];
1185
- const inputRows = this.inputLines(boxWidth - 4).rows.length;
1186
- const block = art.length + 2 + inputRows + 4; // wordmark, gap, box
1187
- // A touch above true centre reads as centred; exact centre looks low.
1188
- const top = Math.max(0, Math.floor((this.rows - block) / 2) - 1);
1189
- return { cols, boxWidth, left, big, art, inputRows, top, boxTop: top + art.length + 2 };
1190
- }
1191
-
1192
- renderWelcome() {
1193
- const g = this.welcomeGeometry();
1194
- const frame = new Array(this.rows).fill('');
1195
-
1196
- // The wordmark in two tones of the one blue, the way a name reads in two
1197
- // halves: the U quieter, CODE brighter.
1198
- g.art.forEach((line, i) => {
1199
- const pad = ' '.repeat(Math.max(0, Math.floor((g.cols - line.length) / 2)));
1200
- frame[g.top + i] = pad + (g.big
1201
- ? deep(line.slice(0, WORDMARK_SPLIT)) + sky(line.slice(WORDMARK_SPLIT))
1202
- : blue.bold(line));
1203
- });
1204
-
1205
- const indent = ' '.repeat(g.left);
1206
- this.inputBox(g.boxWidth).forEach((row, i) => {
1207
- frame[g.boxTop + i] = indent + row;
1208
- });
1209
-
1210
- // The version, in the corner, and nothing else on the screen.
1211
- if (VERSION) {
1212
- const tag = dim(this.facts.update ? `v${VERSION} · v${this.facts.update} installed, starts next time` : `v${VERSION}`);
1213
- frame[this.rows - 1] = ' '.repeat(Math.max(0, g.cols - visLen(tag) - 2)) + tag;
1214
- }
1215
-
1216
- const out = [HIDE, HOME];
1217
- for (let i = 0; i < this.rows; i++) {
1218
- out.push(CLEAR_LINE + padVis(frame[i], g.cols) + (i === this.rows - 1 ? '' : '\n'));
1219
- }
1220
- const [row, col] = this.caret();
1221
- out.push(at(row, col) + SHOW);
1222
- this.output.write(out.join(''));
1223
- }
1224
- }
1225
-
1226
- /**
1227
- * Split a raw stdin chunk into keys, keeping escape sequences whole.
1228
- *
1229
- * Application cursor key mode (DECCKM) makes a terminal send ESC O A for the
1230
- * up arrow rather than ESC [ A. Both are normalised to the bracket form here
1231
- * so the key handler only ever sees one of them.
1232
- */
1233
- export function splitKeys(chunk) {
1234
- const keys = [];
1235
- let i = 0;
1236
-
1237
- while (i < chunk.length) {
1238
- const c = chunk[i];
1239
- if (c !== ESC) { keys.push(c); i++; continue; }
1240
-
1241
- const rest = chunk.slice(i);
1242
- const csi = /^\x1b\[[0-9;?]*[A-Za-z~]/.exec(rest);
1243
- if (csi) { keys.push(csi[0]); i += csi[0].length; continue; }
1244
-
1245
- const ss3 = /^\x1bO([A-Za-z])/.exec(rest);
1246
- if (ss3) { keys.push(`${ESC}[${ss3[1]}`); i += ss3[0].length; continue; }
1247
-
1248
- keys.push(ESC);
1249
- i++;
1250
- }
1251
-
1252
- return keys;
1253
- }
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, BG_ON, BG_OFF, BG_WINDOW, BG_WINDOW_OFF, onBackground } 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',
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 MOUSE_ON = `${ESC}[?1007h${TRACK}`;
88
+ const MOUSE_OFF = `${UNTRACK}${ESC}[?1007l`;
89
+ const HIDE = `${ESC}[?25l`;
90
+ const SHOW = `${ESC}[?25h`;
91
+ const HOME = `${ESC}[H`;
92
+ const CLEAR_LINE = `${ESC}[K`;
93
+ const at = (row, col) => `${ESC}[${row};${col}H`;
94
+ const title = (t) => `${ESC}]0;${t}\x07`;
95
+
96
+ /**
97
+ * Fixed rows below the header: the gap under it, the gap above the input box,
98
+ * the input box's two borders, the blank row inside it, and the status row.
99
+ */
100
+ const CHROME_BELOW = 6;
101
+
102
+ /** The wordmark only earns its place with room for the facts column beside it. */
103
+ const WORDMARK_NEEDS = BANNER_WIDTH + 30;
104
+
105
+ /** Where the U ends and CODE begins in each row of the wordmark. */
106
+ const WORDMARK_SPLIT = 9;
107
+
108
+ /** What the empty input box says before anything is typed. */
109
+ const PLACEHOLDER = 'Ask anything…';
110
+
111
+ export class Screen {
112
+ constructor({ cwd, input = process.stdin, output = process.stdout } = {}) {
113
+ this.cwd = cwd;
114
+ this.input = input;
115
+ this.output = output;
116
+
117
+ this.lines = []; // the rendered transcript
118
+ this.scroll = 0; // rows scrolled up from the bottom
119
+ this.buffer = ''; // what is being typed
120
+ this.cursor = 0;
121
+ this.history = [];
122
+ this.historyIndex = -1;
123
+
124
+ this.status = { busy: false, text: '', frame: 0, since: 0 };
125
+ this.facts = {};
126
+ this.model = '';
127
+
128
+ this.waiters = [];
129
+ this.queue = [];
130
+ this.closed = false;
131
+
132
+ // 'build' may edit and run; 'plan' is read-only. Ctrl+B swaps them, and
133
+ // the chip is clickable wherever the terminal forwards clicks.
134
+ this.mode = 'build';
135
+ this.chipTo = 0;
136
+ this.onInterrupt = null;
137
+ this.onModeChange = null;
138
+ this.spinTimer = null;
139
+ this.activity = null; // the turn in flight: when it began, how many steps
140
+ this.tick = 0; // animation frames painted, for the spinner
141
+ this.pendingPrompt = null;
142
+
143
+ this.cols = output.columns || 80;
144
+ this.rows = output.rows || 24;
145
+ this.md = renderer(this.width());
146
+ }
147
+
148
+ // -- lifecycle -----------------------------------------------------------
149
+
150
+ async start() {
151
+ ensureColour(this.output);
152
+ this.output.write(ALT_ON + MOUSE_ON + HIDE + BG_WINDOW + BG_ON + `${ESC}[2J` + title(`ucode — ${path.basename(this.cwd)}`));
153
+ this.input.setRawMode?.(true);
154
+ this.input.resume();
155
+ this.input.setEncoding('utf8');
156
+ this.input.on('data', (chunk) => this.onData(chunk));
157
+
158
+ this.onResize = () => {
159
+ this.cols = this.output.columns || 80;
160
+ this.rows = this.output.rows || 24;
161
+ this.md = renderer(this.width());
162
+ this.render();
163
+ };
164
+ this.output.on('resize', this.onResize);
165
+
166
+ this.render();
167
+ }
168
+
169
+ stop() {
170
+ this.activity = null;
171
+ this.stopSpinner();
172
+ this.stopTimer();
173
+ this.output.off?.('resize', this.onResize);
174
+ this.input.setRawMode?.(false);
175
+ this.input.pause();
176
+ this.output.write(BG_OFF + BG_WINDOW_OFF + MOUSE_OFF + ALT_OFF + SHOW);
177
+ }
178
+
179
+ close() {
180
+ if (this.closed) return;
181
+ this.closed = true;
182
+ this.stop();
183
+ while (this.waiters.length) this.waiters.shift()(null);
184
+ }
185
+
186
+ width() {
187
+ return Math.max(30, this.cols);
188
+ }
189
+
190
+ /** Usable width inside a box: two borders and a space of padding each side. */
191
+ inner() {
192
+ return Math.max(8, this.width() - 4);
193
+ }
194
+
195
+ // -- transcript ----------------------------------------------------------
196
+
197
+ /**
198
+ * Append without painting.
199
+ *
200
+ * Anything replacing a region of the transcript has to build the whole
201
+ * region and then render once. Painting between the delete and the re-add
202
+ * puts a frame on screen with the text missing, and at streaming speed that
203
+ * reads as flicker.
204
+ */
205
+ add(text = '') {
206
+ const width = this.width();
207
+ for (const raw of String(text).split('\n')) {
208
+ if (visLen(raw) <= width) this.lines.push(raw);
209
+ else for (const wrapped of wrapAnsi(raw, width)) this.lines.push(wrapped);
210
+ }
211
+ this.scroll = 0; // new output snaps back to the bottom
212
+ }
213
+
214
+ push(text = '') {
215
+ this.add(text);
216
+ this.soon();
217
+ }
218
+
219
+ /**
220
+ * Collapse a burst of pushes into one frame.
221
+ *
222
+ * Printing a list one line at a time repaints the screen per line — a model
223
+ * list of fifty entries drew a hundred frames back to back, which is visible
224
+ * as a cascade. A microtask runs before any I/O, so everything pushed in one
225
+ * synchronous stretch becomes a single render, while a push after an await
226
+ * still paints immediately.
227
+ */
228
+ soon() {
229
+ if (this.queued) return;
230
+ this.queued = true;
231
+ queueMicrotask(() => {
232
+ this.queued = false;
233
+ this.render();
234
+ });
235
+ }
236
+
237
+ write(text = '') { this.push(text); }
238
+ blank() { this.push(''); }
239
+ note(text) { this.push(dim(` ${text}`)); }
240
+
241
+ clearScreen() {
242
+ this.lines = [];
243
+ this.scroll = 0;
244
+ this.render();
245
+ }
246
+
247
+ assistant(text) {
248
+ if (!text?.trim()) return;
249
+ this.add('');
250
+ this.add(render(this.md, text));
251
+ this.add('');
252
+ this.render();
253
+ }
254
+
255
+ /**
256
+ * Something the user said, in the conversation, in the same blue box as the
257
+ * input it was typed into.
258
+ *
259
+ * A long session is mostly the agent's output — tool calls, diffs, answers.
260
+ * Your own messages are the landmarks you scroll back looking for, so they
261
+ * get the frame: every one of them is findable at a glance, and the box
262
+ * matches the one below so it is plain where each came from.
263
+ */
264
+ userMessage(text) {
265
+ const width = this.width();
266
+ const room = Math.max(8, width - 6); // borders, padding, and the caret column
267
+
268
+ const rows = [];
269
+ for (const paragraph of String(text).replace(/\r/g, '').split('\n')) {
270
+ for (const line of wrapAnsi(paragraph, room)) rows.push(line);
271
+ }
272
+
273
+ this.add('');
274
+ this.add(boxTop(width, edge));
275
+ rows.forEach((row, i) => {
276
+ const lead = i === 0 ? blue('›') : ' ';
277
+ this.add(boxRow(` ${lead} ${chalk.white(row)}`, width, edge));
278
+ });
279
+ this.add(boxBottom(width, edge));
280
+ this.render();
281
+ }
282
+
283
+ /**
284
+ * A tool call, as it happens: "● Listing src".
285
+ *
286
+ * This lives in the transcript rather than only on the status line. The
287
+ * status line overwrites itself and is empty by the end of the turn, so work
288
+ * announced only there scrolls past unseen and the diff underneath ends up
289
+ * with nothing above it explaining where it came from.
290
+ */
291
+ toolCall(label) {
292
+ // U+25CF, not U+23FA: the latter carries emoji presentation, which Windows
293
+ // Terminal draws as a white circle on a blue tile.
294
+ this.push(`${blue('●')} ${asLabel(label)}`);
295
+ this.updateSpinner(label);
296
+ }
297
+
298
+ /** The checklist, when the model updates it. One line, wrapped if it must. */
299
+ plan(items) {
300
+ const line = planLine(items);
301
+ if (line) this.push(line);
302
+ }
303
+
304
+ toolResult(summary) {
305
+ this.push(dim(` └ ${summary}`));
306
+ }
307
+
308
+ toolFailed(summary) {
309
+ this.push(`${dim(' └ ')}${theme.error(summary)}`);
310
+ }
311
+
312
+ /**
313
+ * The change itself, under the result.
314
+ *
315
+ * A line-number gutter, then the sign and the code tinted right across the
316
+ * row. The numbers are the point: a diff you cannot navigate from is a
317
+ * picture of a change rather than a record of one.
318
+ */
319
+ diff(lines) {
320
+ const gutter = 6;
321
+ // Two spaces of indent, the gutter, one space, then the tint fills the
322
+ // rest. One column over and every row wraps, splitting the whole diff.
323
+ const room = Math.max(12, this.width() - gutter - 3);
324
+
325
+ for (const line of lines) {
326
+ // A file heading in a multi-file write.
327
+ if (line.startsWith('~')) {
328
+ this.add(` ${dim(' '.repeat(gutter))} ${sky(line.slice(1))}`);
329
+ continue;
330
+ }
331
+
332
+ const added = line.startsWith('+');
333
+ const rest = line.slice(1);
334
+ // Tools emit "<line>| <text>". A row with no number is the "12 more
335
+ // lines" note, which is not part of the change, so it stays dim.
336
+ const parsed = /^(\d+)\|\s?([\s\S]*)$/.exec(rest);
337
+ if (!parsed) {
338
+ this.add(` ${dim(' '.repeat(gutter))} ${dim(rest)}`);
339
+ continue;
340
+ }
341
+
342
+ const [, number, body] = parsed;
343
+ const tint = added ? ADDED : REMOVED;
344
+ this.add(
345
+ ` ${dim(number.padStart(gutter))} ` +
346
+ // Tabs would leave the tint ending short of the row, so they widen.
347
+ tint(padVis(clip(`${added ? '+' : '-'} ${body.replace(/\t/g, ' ')}`, room), room))
348
+ );
349
+ }
350
+ this.render(); // a sixteen-line diff is one frame, not sixteen
351
+ }
352
+
353
+ /** Captured output under a command, dimmed so it reads as evidence. */
354
+ commandOutput(lines) {
355
+ for (const line of lines) this.add(` ${dim(line)}`);
356
+ this.render();
357
+ }
358
+
359
+ /**
360
+ * A running command's output, live — on the status line and nowhere else.
361
+ *
362
+ * Only the newest line, gone as soon as the next arrives. Appending each one
363
+ * instead would mean a test run leaving sixty lines of "ok" in the
364
+ * conversation permanently, which is noise the moment it scrolls. What
365
+ * survives a command is decided when it ends: nothing if it worked, the tail
366
+ * if it did not.
367
+ */
368
+ progress(lines) {
369
+ const last = lines[lines.length - 1]?.trim();
370
+ if (last) this.updateSpinner(last);
371
+ }
372
+
373
+ /**
374
+ * The model's own account of the step it is taking, before it takes it.
375
+ *
376
+ * Not called status(): `this.status` holds the spinner state, and a method
377
+ * of the same name would be shadowed by it on every instance.
378
+ */
379
+ narrate(text) {
380
+ const line = asLabel(text);
381
+ if (!line) return;
382
+ this.push(dim(` ⋮ ${clip(line, this.width() - 6)}`));
383
+ this.updateSpinner(line);
384
+ }
385
+
386
+ // -- streaming -----------------------------------------------------------
387
+ // Deltas appear as plain text as they arrive, then get replaced in place by
388
+ // properly rendered markdown once the reply is complete.
389
+
390
+ streamBegin() {
391
+ this.stopSpinner();
392
+ this.streamAt = this.lines.length;
393
+ this.streamBuf = '';
394
+ this.streamPainted = 0;
395
+ }
396
+
397
+ streamDelta(delta) {
398
+ if (this.streamAt === undefined) this.streamBegin();
399
+ this.streamBuf += delta;
400
+ const now = Date.now();
401
+ if (now - this.streamPainted < 60) return; // about 16fps is plenty
402
+ this.streamPainted = now;
403
+ this.repaintStream();
404
+ }
405
+
406
+ /**
407
+ * Repaint the partial reply.
408
+ *
409
+ * polish() runs on the partial text so bold, inline code and bullets are
410
+ * already styled while it streams. Without it the text arrives raw and then
411
+ * visibly re-renders at the end, which reads as a glitch.
412
+ */
413
+ repaintStream() {
414
+ this.lines.length = this.streamAt;
415
+ this.add('');
416
+ this.add(polish(this.streamBuf));
417
+ this.render(); // one frame, and never one without the reply in it
418
+ }
419
+
420
+ /**
421
+ * Finish a streamed reply.
422
+ *
423
+ * `asLabel` says the text turned out to be narration ahead of a tool call
424
+ * rather than an answer, in which case one short line folds down into the
425
+ * status line it was always meant to be.
426
+ */
427
+ streamEnd({ asNarration = false } = {}) {
428
+ if (this.streamAt === undefined) return '';
429
+ const text = this.streamBuf;
430
+ this.lines.length = this.streamAt;
431
+ this.streamAt = undefined;
432
+ this.streamBuf = '';
433
+
434
+ if (asNarration && isLabel(text)) this.narrate(text);
435
+ else if (text.trim()) this.assistant(text);
436
+ else this.render();
437
+ return text;
438
+ }
439
+
440
+ // -- thinking ------------------------------------------------------------
441
+ // A reasoning model does all its working before it says anything. None of it
442
+ // is printed: it is long, repetitive, and guesses drawn from it read worse
443
+ // than silence. The spinner counts the seconds so the wait is visibly alive,
444
+ // and the transcript gets one line afterwards saying how long it took.
445
+
446
+ thinkingDelta() {
447
+ if (this.thoughtSince === undefined) this.thoughtSince = Date.now();
448
+ }
449
+
450
+ thinkingEnd() {
451
+ if (this.thoughtSince === undefined) return;
452
+ const seconds = Math.round((Date.now() - this.thoughtSince) / 1000);
453
+ if (seconds >= 2) this.push(dim(` ⋮ thought for ${seconds}s`));
454
+ this.thoughtSince = undefined;
455
+ }
456
+
457
+ error(err, { debug = false } = {}) {
458
+ const known = err && typeof err === 'object' && err.attempted;
459
+ this.push('');
460
+ if (known) {
461
+ this.push(`${theme.error('✗')} ${chalk.white(`Failed while ${err.attempted}.`)}`);
462
+ this.push(` ${err.failed}`);
463
+ if (err.fix) this.push(` ${blue('→')} ${err.fix}`);
464
+ if (err.kind) this.push(dim(` (${err.kind})`));
465
+ } else {
466
+ this.push(`${theme.error('✗')} ${chalk.white('Something broke inside ucode.')}`);
467
+ this.push(` ${err?.message ?? String(err)}`);
468
+ this.push(` ${blue('→')} That is a bug in ucode rather than in your project. Re-run with --debug.`);
469
+ }
470
+ if (debug) {
471
+ const stack = (known && err.cause?.stack) || err?.stack;
472
+ if (stack) this.push(dim(stack));
473
+ }
474
+ this.push('');
475
+ }
476
+
477
+ // -- header --------------------------------------------------------------
478
+
479
+ setFacts(facts) {
480
+ this.facts = { ...this.facts, ...facts };
481
+ if (facts.model) this.model = facts.model;
482
+ this.render();
483
+ }
484
+
485
+ /** Same shape as the plain UI's header(), so the loop needs no branch. */
486
+ header({ cwd, model, used, limit, title: sessionTitle }) {
487
+ this.setFacts({
488
+ cwd,
489
+ model,
490
+ title: sessionTitle,
491
+ percent: limit > 0 ? Math.min(100, Math.round((used / limit) * 100)) : 0,
492
+ });
493
+ }
494
+
495
+ /**
496
+ * How many rows the header box occupies.
497
+ *
498
+ * The frame has to be exactly as tall as the terminal or every row below the
499
+ * shortfall is off by that much including the one the caret is parked on.
500
+ * So this is derived, never assumed.
501
+ */
502
+ headerHeight() {
503
+ return this.width() >= WORDMARK_NEEDS ? BANNER.length + 2 : 5;
504
+ }
505
+
506
+ headerLines() {
507
+ const width = this.width();
508
+ const inner = width - 2; // between the borders
509
+
510
+ if (width < WORDMARK_NEEDS) {
511
+ // Too narrow for the wordmark: stack it rather than wrap it into noise.
512
+ const rows = [
513
+ ` ${blue.bold('U C O D E')} ${dim('terminal coding agent')}`,
514
+ ` ${dim('dir'.padEnd(8))}${chalk.white(clip(shortenPath(this.facts.cwd ?? this.cwd, inner - 12), inner - 12))}`,
515
+ ];
516
+ return [boxTop(width), ...rows.map((r) => boxRow(r, width)), boxBottom(width)];
517
+ }
518
+
519
+ // Two spaces of padding, the wordmark, a gap, then the facts column.
520
+ //
521
+ // Only what you cannot work out by looking: where you are, and how to get
522
+ // help. How full the window is belongs on the status row next to the model
523
+ // it describes, and the session title is already the terminal's own window
524
+ // title repeating either here is a second place to keep in sync for no
525
+ // reader who needed it.
526
+ const room = Math.max(8, inner - BANNER_WIDTH - 6);
527
+ const facts = [
528
+ ['dir', shortenPath(this.facts.cwd ?? this.cwd, room - 9)],
529
+ ['keys', '/help · esc interrupts'],
530
+ ['', ''],
531
+ ['', ''],
532
+ ['', ''],
533
+ ['', 'made with ❤️ by om dixit'],
534
+ ];
535
+
536
+ const rows = BANNER.map((art, i) => {
537
+ const [label, value] = facts[i] ?? ['', ''];
538
+ const right = label
539
+ ? `${dim(label.padEnd(9))}${chalk.white(clip(value, room - 9))}`
540
+ : (value ? dim(value) : '');
541
+ return ` ${blue(art)} ${right}`;
542
+ });
543
+
544
+ return [boxTop(width), ...rows.map((r) => boxRow(r, width)), boxBottom(width)];
545
+ }
546
+
547
+ // -- input box -----------------------------------------------------------
548
+
549
+ /** The typed line, wrapped to the inside of a box `width` characters across. */
550
+ inputLines(width = this.inner()) {
551
+ const prefix = this.pendingPrompt ? `${this.pendingPrompt} ` : '› ';
552
+ const full = prefix + this.buffer;
553
+
554
+ const rows = [];
555
+ for (let i = 0; i < full.length; i += width) rows.push(full.slice(i, i + width));
556
+ if (rows.length === 0) rows.push(prefix);
557
+
558
+ return { rows, prefix, width };
559
+ }
560
+
561
+ viewportHeight() {
562
+ return Math.max(
563
+ 3,
564
+ this.rows - this.headerHeight() - CHROME_BELOW - this.inputLines().rows.length
565
+ );
566
+ }
567
+
568
+ /**
569
+ * The input box: what you are typing, and directly under it, inside the same
570
+ * border, the three things worth knowing while you type.
571
+ *
572
+ * The status used to sit outside the box on the last row of the screen,
573
+ * which made it a separate object floating under the input. Inside the
574
+ * border it reads as part of the thing you are using — the box says "this is
575
+ * where you work", and the row underneath says what you are working with.
576
+ */
577
+ inputBox(width = this.width()) {
578
+ const { rows } = this.inputLines(width - 4);
579
+ // Nothing typed yet: a quiet prompt where the text will go. The caret sits
580
+ // on its first letter and typing replaces it.
581
+ const empty = !this.buffer && !this.pendingPrompt;
582
+ const painted = rows.map((row, i) =>
583
+ i === 0
584
+ ? boxRow(` ${blue('›')}${empty ? ` ${dim(PLACEHOLDER)}` : row.slice(1)}`, width, edge)
585
+ : boxRow(` ${row}`, width, edge)
586
+ );
587
+ return [
588
+ boxTop(width, edge),
589
+ ...painted,
590
+ // A blank row between the two. Sitting directly under the caret, the
591
+ // status read as a second line of the thing being typed; one row of air
592
+ // separates what you are writing from what you are writing it with.
593
+ boxRow('', width, edge),
594
+ boxRow(this.statusRow(width), width, edge),
595
+ boxBottom(width, edge),
596
+ ];
597
+ }
598
+
599
+ // -- status row ----------------------------------------------------------
600
+
601
+ modeChip() {
602
+ return this.mode === 'plan' ? `${sky('◇')} ${sky('Plan')}` : `${blue('◆')} ${blue('Build')}`;
603
+ }
604
+
605
+ /**
606
+ * How full the context window is, as a bare number.
607
+ *
608
+ * It turns amber at 75% because that is where turns start being folded away
609
+ * into a summary the one moment the number predicts something you would
610
+ * want to know before it happens.
611
+ */
612
+ percentChip() {
613
+ const percent = Math.round(this.facts.percent ?? 0);
614
+ return percent >= 75 ? theme.warn(`${percent}%`) : dim(`${percent}%`);
615
+ }
616
+
617
+ /**
618
+ * Which mode is live, which model is answering, and how full the window is.
619
+ *
620
+ * Nothing else earns a place. The provider name was there and was cut: it is
621
+ * the same on every line of every session, so it was decoration that had to
622
+ * be read past to reach the two things that do change.
623
+ *
624
+ * The middle is borrowed while something is running, for the spinner and the
625
+ * way out of it, and handed straight back when it finishes.
626
+ */
627
+ statusRow(width = this.width()) {
628
+ const inner = width - 2; // the space between the two borders
629
+ const chip = this.modeChip();
630
+ const left = ` ${chip} ${dim('·')} ${chalk.white(this.model || '—')}`;
631
+ const right = `${this.percentChip()} `;
632
+
633
+ // Where a click on the bottom row still counts as hitting the mode chip.
634
+ this.chipTo = 2 + visLen(chip);
635
+
636
+ const between = Math.max(1, inner - visLen(left) - visLen(right));
637
+
638
+ let middle = '';
639
+ if (this.flashText) {
640
+ middle = dim(clip(this.flashText, between - 2));
641
+ } else if (this.status.busy || this.activity) {
642
+ // The whole turn, not just the current tool: the timer and step count
643
+ // keep going through the gaps between calls, so a long build never
644
+ // looks like it has stopped.
645
+ const now = Date.now();
646
+ const a = this.activity;
647
+ const since = a?.start ?? this.status.since ?? now;
648
+ const meta = [];
649
+ if (a?.steps) meta.push({ text: `step ${a.steps}`, paint: stepPaint(now - a.movedAt < 900) });
650
+ if (now - since >= 1000) meta.push({ text: formatDuration(now - since), keep: true });
651
+ middle = fitActivity({
652
+ glyph: spinnerGlyph(this.tick, now),
653
+ label: this.status.busy ? this.status.text : 'working',
654
+ meta,
655
+ hint: 'esc to stop',
656
+ paint: (s) => shimmer(s, now),
657
+ }, between - 3);
658
+ }
659
+
660
+ // The percentage is pinned to the right border whatever is in the middle,
661
+ // with a gap kept in front of it so a long spinner label cannot run into
662
+ // the number and read as part of it.
663
+ const tail = middle ? `${middle} ` : '';
664
+ const pad = Math.max(1, inner - visLen(left) - visLen(tail) - visLen(right));
665
+ return padVis(left + ' '.repeat(pad) + tail + right, inner);
666
+ }
667
+
668
+ /**
669
+ * Repaint only the status row, leaving the caret where the user left it.
670
+ *
671
+ * It is the second row from the bottom now — the box's own border is below
672
+ * it so the row is written with its borders rather than as a bare line.
673
+ */
674
+ paintStatus() {
675
+ if (this.closed) return;
676
+ // On the start screen the status row is mid-screen, not second from the
677
+ // bottom, so the cheap single-row repaint would draw it in the wrong place.
678
+ if (this.welcoming()) {
679
+ this.render();
680
+ return;
681
+ }
682
+ const [row, col] = this.caret();
683
+ this.output.write(
684
+ HIDE +
685
+ at(this.rows - 1, 1) + CLEAR_LINE + boxRow(this.statusRow(), this.width(), edge) +
686
+ at(row, col) + SHOW
687
+ );
688
+ }
689
+
690
+ toggleMode() {
691
+ this.mode = this.mode === 'plan' ? 'build' : 'plan';
692
+ this.flash(this.mode === 'plan'
693
+ ? 'plan mode reads and researches, changes nothing'
694
+ : 'build mode — free to edit files and run commands');
695
+ this.onModeChange?.(this.mode);
696
+ this.render();
697
+ }
698
+
699
+ /** A message on the status line that fades on its own. */
700
+ flash(text) {
701
+ this.flashText = text;
702
+ clearTimeout(this.flashTimer);
703
+ this.flashTimer = setTimeout(() => {
704
+ this.flashText = null;
705
+ this.paintStatus();
706
+ }, 2500);
707
+ this.flashTimer.unref?.();
708
+ this.paintStatus();
709
+ }
710
+
711
+ // -- spinner -------------------------------------------------------------
712
+
713
+ startSpinner(text = 'thinking') {
714
+ // `since` is what makes a long think legible: the label may not change for
715
+ // a minute, so the seconds beside it are the proof it is still alive.
716
+ this.status = { busy: true, text: asLabel(text), frame: 0, since: Date.now() };
717
+ this.startTimer();
718
+ this.paintStatus();
719
+ }
720
+
721
+ updateSpinner(text) {
722
+ if (!this.status.busy) return;
723
+ this.status.text = asLabel(text);
724
+ this.paintStatus();
725
+ }
726
+
727
+ stopSpinner() {
728
+ if (!this.activity) this.stopTimer();
729
+ if (this.status.busy) {
730
+ this.status = { busy: false, text: '', frame: 0, since: 0 };
731
+ this.paintStatus();
732
+ }
733
+ }
734
+
735
+ // -- the turn in flight ----------------------------------------------------
736
+
737
+ /** A turn begins: the timer and step count run until turnEnd(). */
738
+ turnStart() {
739
+ this.activity = { start: Date.now(), steps: 0, movedAt: 0 };
740
+ this.startTimer();
741
+ }
742
+
743
+ /** One more model step in this turn. */
744
+ step() {
745
+ if (!this.activity) return;
746
+ this.activity.steps++;
747
+ this.activity.movedAt = Date.now();
748
+ }
749
+
750
+ /** The turn is over: leave "✓ Done in 6m 12s · 25 steps" under the answer. */
751
+ turnEnd({ ok = true } = {}) {
752
+ const a = this.activity;
753
+ this.activity = null;
754
+ if (!this.status.busy) this.stopTimer();
755
+ if (a && ok && Date.now() - a.start >= 2000) this.push(` ${doneLine(Date.now() - a.start, a.steps)}`);
756
+ this.paintStatus();
757
+ }
758
+
759
+ /** The animation clock: only the status row repaints, about twelve times a second. */
760
+ startTimer() {
761
+ if (this.spinTimer) return;
762
+ this.spinTimer = setInterval(() => {
763
+ this.tick++;
764
+ this.paintStatus();
765
+ }, FRAME_MS);
766
+ this.spinTimer.unref?.();
767
+ }
768
+
769
+ stopTimer() {
770
+ if (!this.spinTimer) return;
771
+ clearInterval(this.spinTimer);
772
+ this.spinTimer = null;
773
+ }
774
+
775
+ // -- input ---------------------------------------------------------------
776
+
777
+ nextLine() {
778
+ if (this.queue.length) return Promise.resolve(this.queue.shift());
779
+ if (this.closed) return Promise.resolve(null);
780
+ return new Promise((resolve) => this.waiters.push(resolve));
781
+ }
782
+
783
+ ask() {
784
+ return this.nextLine();
785
+ }
786
+
787
+ submit(text) {
788
+ const waiter = this.waiters.shift();
789
+ if (waiter) waiter(text);
790
+ else this.queue.push(text);
791
+ }
792
+
793
+ /** y/n, answered on the input line. */
794
+ confirm({ action, detail, risk }) {
795
+ this.push('');
796
+ this.push(`${chalk.inverse(theme.warn(risk === 'command' ? ' shell ' : ' outside project '))} ${chalk.white(action)}`);
797
+ for (const line of String(detail ?? '').split('\n')) {
798
+ if (line) this.push(dim(` ${line}`));
799
+ }
800
+
801
+ this.pendingPrompt = 'go ahead? [y/N]';
802
+ this.render();
803
+
804
+ return this.nextLine().then((answer) => {
805
+ this.pendingPrompt = null;
806
+ // End of input counts as no. Never run something nobody approved.
807
+ const yes = /^(y|yes)$/i.test(String(answer ?? '').trim());
808
+ this.push(dim(yes ? ' approved' : ' declined'));
809
+ this.push('');
810
+ return yes;
811
+ });
812
+ }
813
+
814
+ /**
815
+ * A modal list: arrows move, Enter picks, Esc cancels.
816
+ *
817
+ * Only while this is open do the arrows stop scrolling the transcript. They
818
+ * cannot be given up permanently, because under alternate scroll the mouse
819
+ * wheel arrives as arrow keys.
820
+ */
821
+ pick(items, { active = 0, hint = 'enter to choose · esc to cancel', deletable = false } = {}) {
822
+ this.picker = {
823
+ items,
824
+ index: Math.min(Math.max(0, active), Math.max(0, items.length - 1)),
825
+ hint,
826
+ // With deletable, `d` twice on a row resolves { delete: index }. Twice,
827
+ // because a single stray keypress should never cost a conversation.
828
+ deletable,
829
+ armed: null,
830
+ };
831
+ this.render();
832
+ return new Promise((resolve) => { this.pickerResolve = resolve; });
833
+ }
834
+
835
+ closePicker(value) {
836
+ const resolve = this.pickerResolve;
837
+ this.picker = null;
838
+ this.pickerResolve = null;
839
+ this.render();
840
+ resolve?.(value);
841
+ }
842
+
843
+ /**
844
+ * Rows for an open picker, windowed so a long list still fits.
845
+ *
846
+ * An item may carry a `sub` line — a second, dimmer row underneath it. That
847
+ * is what lets a list of saved conversations show what each one was actually
848
+ * about instead of a column of near-identical titles.
849
+ */
850
+ pickerLines(height) {
851
+ const { items, index, hint, armed } = this.picker;
852
+ const room = Math.max(1, height - 2);
853
+
854
+ // Rows per item, so the window can be sized in rows rather than in items.
855
+ const rowsFor = (item) => (typeof item !== 'string' && item.sub ? 2 : 1);
856
+ const perItem = items.map(rowsFor);
857
+
858
+ // Walk outward from the selection until the window is full. Starting from
859
+ // the selection guarantees it is on screen however long the list is.
860
+ let first = index;
861
+ let last = index;
862
+ let used = perItem[index] ?? 1;
863
+ while (used < room && (first > 0 || last < items.length - 1)) {
864
+ if (first > 0 && used + perItem[first - 1] <= room) { first--; used += perItem[first]; }
865
+ else if (last < items.length - 1 && used + perItem[last + 1] <= room) { last++; used += perItem[last]; }
866
+ else break;
867
+ }
868
+
869
+ const out = [];
870
+ for (let i = first; i <= last; i++) {
871
+ const item = items[i];
872
+ const body = typeof item === 'string' ? item : item.label;
873
+ if (i === armed) out.push(`${theme.warn('')} ${theme.warn(bare(body))}`);
874
+ else out.push(i === index ? `${blue('')} ${chalk.bold.white(body)}` : ` ${dim(body)}`);
875
+ if (typeof item !== 'string' && item.sub) out.push(` ${item.sub}`);
876
+ }
877
+
878
+ out.push('');
879
+ out.push(armed !== null && armed !== undefined
880
+ ? theme.warn(' press d again to delete this conversation · any other key keeps it')
881
+ : dim(` ${hint}`));
882
+ return out;
883
+ }
884
+
885
+ /** A numbered list, answered on the input line. */
886
+ async choose(prompt, items, { allowNone = true } = {}) {
887
+ items.forEach((item, i) => this.push(` ${blue(String(i + 1).padStart(2))}. ${item}`));
888
+ if (allowNone) this.push(dim(' 0. none start fresh'));
889
+ this.push('');
890
+
891
+ this.pendingPrompt = prompt;
892
+ this.render();
893
+
894
+ const answer = await this.nextLine();
895
+ this.pendingPrompt = null;
896
+
897
+ const trimmed = String(answer ?? '').trim();
898
+ if (trimmed === '' || trimmed === '0') return null;
899
+
900
+ const index = Number(trimmed);
901
+ if (!Number.isInteger(index) || index < 1 || index > items.length) {
902
+ this.push(theme.warn(` "${trimmed}" is not one of 1-${items.length}.`));
903
+ return null;
904
+ }
905
+ return index - 1;
906
+ }
907
+
908
+ // -- keyboard and mouse --------------------------------------------------
909
+
910
+ /**
911
+ * Scroll the transcript, clamped at both ends.
912
+ *
913
+ * When there is nothing above the fold, say so. Silence is indistinguishable
914
+ * from broken input, and the difference matters: one means the conversation
915
+ * simply fits, the other means the terminal is not forwarding keys at all.
916
+ */
917
+ scrollBy(delta) {
918
+ const max = Math.max(0, this.lines.length - this.viewportHeight());
919
+ if (max === 0) {
920
+ this.flash('nothing above — it all fits on screen');
921
+ return;
922
+ }
923
+ const before = this.scroll;
924
+ this.scroll = Math.min(Math.max(0, this.scroll + delta), max);
925
+ if (this.scroll === before && delta > 0) this.flash('already at the top');
926
+ this.render();
927
+ }
928
+
929
+ onData(chunk) {
930
+ // UCODE_DEBUG_KEYS=1 logs every byte the terminal sends to
931
+ // ~/.ucode/keys.log. Whether mouse reporting works at all depends on the
932
+ // terminal forwarding it; this is how to find out.
933
+ if (process.env.UCODE_DEBUG_KEYS) {
934
+ appendFile(path.join(homedir(), '.ucode', 'keys.log'), `${JSON.stringify(chunk)}\n`).catch(() => {});
935
+ }
936
+
937
+ // Pull mouse reports out of the chunk wherever they sit. Anchoring the
938
+ // match to the whole chunk meant a wheel event arriving alongside any
939
+ // other byte was silently treated as typing.
940
+ let rest = '';
941
+ let index = 0;
942
+ // Two encodings: SGR (ESC [ < b ; x ; y M|m), and the legacy form
943
+ // (ESC [ M then three bytes offset by 32) for terminals that ignore 1006.
944
+ const mouse = /\x1b\[<(\d+);(\d+);(\d+)([Mm])|\x1b\[M([\s\S])([\s\S])([\s\S])/g;
945
+ let match;
946
+
947
+ while ((match = mouse.exec(chunk)) !== null) {
948
+ rest += chunk.slice(index, match.index);
949
+ index = match.index + match[0].length;
950
+ if (match[1] !== undefined) {
951
+ this.onMouse(Number(match[1]), Number(match[2]), Number(match[3]), match[4]);
952
+ } else {
953
+ this.onMouse(
954
+ match[5].charCodeAt(0) - 32,
955
+ match[6].charCodeAt(0) - 32,
956
+ match[7].charCodeAt(0) - 32,
957
+ 'M'
958
+ );
959
+ }
960
+ }
961
+ rest += chunk.slice(index);
962
+
963
+ for (const key of splitKeys(rest)) this.onKey(key);
964
+ }
965
+
966
+ onMouse(button, col, row, press) {
967
+ // Wheel reports set bit 6; bit 0 says which way.
968
+ if (button >= 64) {
969
+ this.scrollBy(button % 2 === 0 ? 3 : -3);
970
+ return;
971
+ }
972
+ if (press !== 'M' || button !== 0) return;
973
+ if (this.welcoming()) {
974
+ const g = this.welcomeGeometry();
975
+ const statusRow = g.boxTop + g.inputRows + 3; // 1-based
976
+ if (row === statusRow && col > g.left + 1 && col <= g.left + this.chipTo) this.toggleMode();
977
+ return;
978
+ }
979
+ // The mode chip, at the left of the bottom row.
980
+ if (row === this.rows - 1 && col >= 2 && col <= this.chipTo) this.toggleMode();
981
+ }
982
+
983
+ onKey(key) {
984
+ // An open picker owns the keyboard until it closes.
985
+ if (this.picker) {
986
+ const last = this.picker.items.length - 1;
987
+ if (this.picker.deletable && (key === 'd' || key === 'D' || key === `${ESC}[3~`)) {
988
+ if (this.picker.armed === this.picker.index) { this.closePicker({ delete: this.picker.index }); return; }
989
+ this.picker.armed = this.picker.index;
990
+ this.render();
991
+ return;
992
+ }
993
+ this.picker.armed = null; // any other key takes the delete back
994
+ if (key === `${ESC}[A`) { this.picker.index = Math.max(0, this.picker.index - 1); this.render(); return; }
995
+ if (key === `${ESC}[B`) { this.picker.index = Math.min(last, this.picker.index + 1); this.render(); return; }
996
+ if (key === '\r' || key === '\n') { this.closePicker(this.picker.index); return; }
997
+ if (key === ESC || key === '\x03') { this.closePicker(null); return; }
998
+ return;
999
+ }
1000
+
1001
+ switch (key) {
1002
+ case '\r':
1003
+ case '\n': {
1004
+ const text = this.buffer;
1005
+ this.buffer = '';
1006
+ this.cursor = 0;
1007
+ this.historyIndex = -1;
1008
+ if (text.trim()) {
1009
+ this.history.unshift(text);
1010
+ // Answers to a y/N or a numbered pick are not messages, so they are
1011
+ // not echoed: the prompt reports its own outcome.
1012
+ if (!this.pendingPrompt) this.userMessage(text);
1013
+ }
1014
+ this.render();
1015
+ this.submit(text);
1016
+ return;
1017
+ }
1018
+
1019
+ case '\x7f': // backspace
1020
+ case '\b':
1021
+ if (this.cursor > 0) {
1022
+ this.buffer = this.buffer.slice(0, this.cursor - 1) + this.buffer.slice(this.cursor);
1023
+ this.cursor--;
1024
+ }
1025
+ break;
1026
+
1027
+ case '\x03': // ctrl+c
1028
+ if (this.status.busy && this.onInterrupt) this.onInterrupt();
1029
+ else { this.buffer = ''; this.cursor = 0; }
1030
+ break;
1031
+
1032
+ case '\x04': // ctrl+d
1033
+ this.close();
1034
+ return;
1035
+
1036
+ case '\x02': // ctrl+b — swap plan and build
1037
+ this.toggleMode();
1038
+ return;
1039
+
1040
+ case '\x15': // ctrl+u — clear the line
1041
+ this.buffer = this.buffer.slice(this.cursor);
1042
+ this.cursor = 0;
1043
+ break;
1044
+
1045
+ case ESC: // esc — stop the turn in flight
1046
+ if (this.onInterrupt) this.onInterrupt();
1047
+ return;
1048
+
1049
+ case '\t': {
1050
+ const hit = COMMANDS.find((c) => c.startsWith(this.buffer));
1051
+ if (hit) { this.buffer = hit; this.cursor = hit.length; }
1052
+ break;
1053
+ }
1054
+
1055
+ // With an empty line the arrows scroll the conversation; once there is
1056
+ // something typed they walk history. Terminals often swallow PgUp and
1057
+ // PgDn for their own scrollback, so this is the path that always works.
1058
+ case `${ESC}[A`:
1059
+ if (!this.buffer) { this.scrollBy(2); return; }
1060
+ if (this.history.length) {
1061
+ this.historyIndex = Math.min(this.historyIndex + 1, this.history.length - 1);
1062
+ this.buffer = this.history[this.historyIndex] ?? '';
1063
+ this.cursor = this.buffer.length;
1064
+ }
1065
+ break;
1066
+
1067
+ case `${ESC}[B`:
1068
+ if (!this.buffer) { this.scrollBy(-2); return; }
1069
+ this.historyIndex = Math.max(this.historyIndex - 1, -1);
1070
+ this.buffer = this.historyIndex === -1 ? '' : (this.history[this.historyIndex] ?? '');
1071
+ this.cursor = this.buffer.length;
1072
+ break;
1073
+
1074
+ case `${ESC}[1;5A`: this.scrollBy(2); return; // ctrl+up
1075
+ case `${ESC}[1;5B`: this.scrollBy(-2); return; // ctrl+down
1076
+ case `${ESC}[5~`: this.scrollBy(this.viewportHeight()); return;
1077
+ case `${ESC}[6~`: this.scrollBy(-this.viewportHeight()); return;
1078
+
1079
+ case `${ESC}[H`: this.scrollBy(this.lines.length); return;
1080
+ case `${ESC}[F`: this.scroll = 0; this.render(); return;
1081
+
1082
+ case `${ESC}[C`: this.cursor = Math.min(this.cursor + 1, this.buffer.length); break;
1083
+ case `${ESC}[D`: this.cursor = Math.max(this.cursor - 1, 0); break;
1084
+
1085
+ default:
1086
+ if (key >= ' ' && !key.startsWith(ESC)) {
1087
+ this.buffer = this.buffer.slice(0, this.cursor) + key + this.buffer.slice(this.cursor);
1088
+ this.cursor += key.length;
1089
+ } else {
1090
+ return;
1091
+ }
1092
+ }
1093
+
1094
+ this.render();
1095
+ }
1096
+
1097
+ // -- painting ------------------------------------------------------------
1098
+
1099
+ render() {
1100
+ if (this.closed) return;
1101
+ if (this.welcoming()) {
1102
+ this.renderWelcome();
1103
+ return;
1104
+ }
1105
+
1106
+ const width = this.width();
1107
+ const height = this.viewportHeight();
1108
+
1109
+ const end = Math.max(0, this.lines.length - this.scroll);
1110
+ const start = Math.max(0, end - height);
1111
+ const window = this.picker ? this.pickerLines(height) : this.lines.slice(start, end);
1112
+ while (window.length < height) window.push('');
1113
+
1114
+ const frame = [
1115
+ ...this.headerLines(),
1116
+ '',
1117
+ ...window,
1118
+ // Always one clear row between the last thing said and the box you type
1119
+ // in. Without it the newest line of output sits against the border and
1120
+ // reads as part of the input rather than as the answer above it.
1121
+ '',
1122
+ ...this.inputBox(),
1123
+ ];
1124
+
1125
+ // The cursor is hidden for the duration of the paint. Without this it is
1126
+ // dragged through every line as the frame is written, which shows up as a
1127
+ // dot flickering above the input box on every keystroke.
1128
+ const out = [HIDE, HOME];
1129
+ for (let i = 0; i < this.rows; i++) {
1130
+ out.push(BG_ON + CLEAR_LINE + onBackground(padVis(frame[i] ?? '', width)) + (i === this.rows - 1 ? '' : '\n'));
1131
+ }
1132
+
1133
+ const [row, col] = this.caret();
1134
+ out.push(at(row, col) + SHOW);
1135
+ this.output.write(out.join(''));
1136
+ }
1137
+
1138
+ /**
1139
+ * Where the typing caret belongs, 1-based.
1140
+ *
1141
+ * Column three is the first character inside the box: border, a space of
1142
+ * padding, then the text.
1143
+ */
1144
+ caret() {
1145
+ if (this.welcoming()) {
1146
+ const g = this.welcomeGeometry();
1147
+ const { rows, prefix, width } = this.inputLines(g.boxWidth - 4);
1148
+ const index = prefix.length + this.cursor;
1149
+ const row = Math.min(Math.floor(index / width), rows.length - 1);
1150
+ // g.boxTop is 0-based and the typed lines start one below the border.
1151
+ return [g.boxTop + 2 + row, g.left + 3 + (index % width)];
1152
+ }
1153
+
1154
+ const { rows, prefix, width } = this.inputLines();
1155
+ const index = prefix.length + this.cursor;
1156
+ const row = Math.min(Math.floor(index / width), rows.length - 1);
1157
+ const col = 3 + (index % width);
1158
+ // Counting up from the bottom: the box border is the last row, the status
1159
+ // row is above it, then the blank row, then the typed lines.
1160
+ const firstRow = this.rows - 2 - rows.length;
1161
+ return [firstRow + row, col];
1162
+ }
1163
+
1164
+ // -- start screen ----------------------------------------------------------
1165
+
1166
+ /**
1167
+ * Nothing has been said yet, so there is nothing to scroll: the screen is the
1168
+ * wordmark and the place to type, centred, and nothing else.
1169
+ *
1170
+ * It comes back after /clear and /new too, since those empty the transcript —
1171
+ * a fresh conversation starts from the same quiet screen as a fresh launch.
1172
+ */
1173
+ welcoming() {
1174
+ return this.lines.length === 0 && !this.picker;
1175
+ }
1176
+
1177
+ /** Where everything on the start screen goes, 0-based rows. */
1178
+ welcomeGeometry() {
1179
+ const cols = this.width();
1180
+ const boxWidth = Math.max(30, Math.min(cols - 4, 84));
1181
+ const left = Math.max(0, Math.floor((cols - boxWidth) / 2));
1182
+ const big = cols >= BANNER_WIDTH + 4 && this.rows >= 18;
1183
+ const art = big ? BANNER : ['u c o d e'];
1184
+ const inputRows = this.inputLines(boxWidth - 4).rows.length;
1185
+ const block = art.length + 2 + inputRows + 4; // wordmark, gap, box
1186
+ // A touch above true centre reads as centred; exact centre looks low.
1187
+ const top = Math.max(0, Math.floor((this.rows - block) / 2) - 1);
1188
+ return { cols, boxWidth, left, big, art, inputRows, top, boxTop: top + art.length + 2 };
1189
+ }
1190
+
1191
+ renderWelcome() {
1192
+ const g = this.welcomeGeometry();
1193
+ const frame = new Array(this.rows).fill('');
1194
+
1195
+ // The wordmark in two tones of the one blue, the way a name reads in two
1196
+ // halves: the U quieter, CODE brighter.
1197
+ g.art.forEach((line, i) => {
1198
+ const pad = ' '.repeat(Math.max(0, Math.floor((g.cols - line.length) / 2)));
1199
+ frame[g.top + i] = pad + (g.big
1200
+ ? deep(line.slice(0, WORDMARK_SPLIT)) + sky(line.slice(WORDMARK_SPLIT))
1201
+ : blue.bold(line));
1202
+ });
1203
+
1204
+ const indent = ' '.repeat(g.left);
1205
+ this.inputBox(g.boxWidth).forEach((row, i) => {
1206
+ frame[g.boxTop + i] = indent + row;
1207
+ });
1208
+
1209
+ // The version, in the corner, and nothing else on the screen.
1210
+ if (VERSION) {
1211
+ const tag = dim(this.facts.update ? `v${VERSION} · v${this.facts.update} installed, starts next time` : `v${VERSION}`);
1212
+ frame[this.rows - 1] = ' '.repeat(Math.max(0, g.cols - visLen(tag) - 2)) + tag;
1213
+ }
1214
+
1215
+ const out = [HIDE, HOME];
1216
+ for (let i = 0; i < this.rows; i++) {
1217
+ out.push(BG_ON + CLEAR_LINE + onBackground(padVis(frame[i], g.cols)) + (i === this.rows - 1 ? '' : '\n'));
1218
+ }
1219
+ const [row, col] = this.caret();
1220
+ out.push(at(row, col) + SHOW);
1221
+ this.output.write(out.join(''));
1222
+ }
1223
+ }
1224
+
1225
+ /**
1226
+ * Split a raw stdin chunk into keys, keeping escape sequences whole.
1227
+ *
1228
+ * Application cursor key mode (DECCKM) makes a terminal send ESC O A for the
1229
+ * up arrow rather than ESC [ A. Both are normalised to the bracket form here
1230
+ * so the key handler only ever sees one of them.
1231
+ */
1232
+ export function splitKeys(chunk) {
1233
+ const keys = [];
1234
+ let i = 0;
1235
+
1236
+ while (i < chunk.length) {
1237
+ const c = chunk[i];
1238
+ if (c !== ESC) { keys.push(c); i++; continue; }
1239
+
1240
+ const rest = chunk.slice(i);
1241
+ const csi = /^\x1b\[[0-9;?]*[A-Za-z~]/.exec(rest);
1242
+ if (csi) { keys.push(csi[0]); i += csi[0].length; continue; }
1243
+
1244
+ const ss3 = /^\x1bO([A-Za-z])/.exec(rest);
1245
+ if (ss3) { keys.push(`${ESC}[${ss3[1]}`); i += ss3[0].length; continue; }
1246
+
1247
+ keys.push(ESC);
1248
+ i++;
1249
+ }
1250
+
1251
+ return keys;
1252
+ }