@vincemakes/kiso-tui 0.1.30 → 0.1.32

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.
@@ -0,0 +1,779 @@
1
+ /**
2
+ * TUI v6 (ADR-0046) — THE one-compositor: every byte of the screen's
3
+ * stdout comes from this file's doRender. `body.ts` + `dock.ts` are
4
+ * RETIRED — this class implements BOTH façades (the `Body` mutations
5
+ * the CLI's consumeRun calls, and the `Dock` chrome API), so the CLI
6
+ * itself is untouched (zero diff outside the tui package + tests).
7
+ *
8
+ * The model:
9
+ * - cells (the CLI's mutation surface) → components → lines;
10
+ * - `lines[] + commitIndex` (the scrollback fork — the departure from
11
+ * pi): a line COMMITS (leaves the live region) via the real-LF
12
+ * scroll at the last row (`\x1b[1B\n` — CUP-free) when its cell is
13
+ * DONE and the region needs the room. Committed bytes are never
14
+ * re-emitted — the native scrollback gets them, reflow-safe, and
15
+ * the user's shell history is never touched (zero \x1b[3J, zero
16
+ * replay);
17
+ * - the live region (content + status + editor + footer + menu) is
18
+ * hard-capped at H−1 lines; overflow FORCE-commits the oldest live
19
+ * line regardless of done-ness — the one sharp edge this round
20
+ * introduces (asserted by the VT-emulator gate);
21
+ * - two crash invariants: ① every emitted line's visible width ≤ W
22
+ * (components fold; a violation THROWS with diagnostics — pi
23
+ * tui-main-screen.ts:447-473, no silent truncate); ② within the
24
+ * live region only RELATIVE cursor moves (vertical A/B, horizontal
25
+ * G/D) — CUP exists only in the full-redraw path (the first frame,
26
+ * the resize repaint);
27
+ * - the cursor DERIVES from the frame: the focus component embeds the
28
+ * APC marker in its rendered line; the compositor locates, strips,
29
+ * and relatively positions from the frame — no side-channel cursor
30
+ * bookkeeping that could desync from the picture;
31
+ * - zero timers: the spinner animation is a dirty flag through the
32
+ * scheduler — a one-shot setTimeout re-armed only while a running
33
+ * tool exists (the #14/#15 zero-output contract is structural).
34
+ *
35
+ * Layout at H rows: content rows 1..H−3, status H−2, editor (the slot)
36
+ * H−1, footer ╌ at H. Pipes / NO_COLOR: the passthrough branches below
37
+ * keep the v2a/v2b line-mode bytes byte-for-byte (the e2e guards them).
38
+ */
39
+ import { truncateDiff } from "./diff.js";
40
+ import { displayWidth } from "./editor.js";
41
+ import { Container, SPINNER, cellComponent, foldLine, footerLine, statusLine, visibleWidth, } from "./components.js";
42
+ import { escapeTerminal, foldResult, foldThinking, palette, renderTerminalGap, renderToolSummary } from "./render.js";
43
+ /** The cursor marker — an APC private sequence the focus component
44
+ * embeds at the edit position; the compositor strips it and moves
45
+ * relatively (it never reaches the terminal). */
46
+ export const CURSOR_MARKER = "\x1b_[kiso-cur]\x1b\\";
47
+ const FRAME_MS = 16; // state changes coalesce to ≥16ms frames
48
+ const SPINNER_MS = 200; // the spinner cadence — a ONE-SHOT re-armed on demand
49
+ const CHROME_ROWS = 3; // status + editor + footer — the cap's constant part
50
+ /** The one compositor — implements the Body façade AND the Dock chrome
51
+ * API (see the class comments on each method group). */
52
+ export class Body {
53
+ #opts;
54
+ #cells = [];
55
+ #lineCache = []; // the committed cells' rendered lines (immutable)
56
+ #committed = 0; // the count of leading cells fully committed
57
+ #committedLines = 0; // their total line count (incremental — O(1) per frame)
58
+ #active = false;
59
+ #docked = false;
60
+ #dirty = false;
61
+ #fullRedraw = false; // the first frame / resize — the CUP path
62
+ #lastLiveTop = 0; // the recorded live region top — the resize clear starts here
63
+ #lastLiveRows = 0; // the recorded live row count (incl. the chrome)
64
+ #lastH = 0;
65
+ #frameTimer = null;
66
+ #spinnerTimer = null;
67
+ #spinnerI = 0;
68
+ #lastThinking = null;
69
+ #lastTool = null;
70
+ #pendingCalls = new Map();
71
+ #pipeBuf = ""; // the passthrough's thinking buffer
72
+ #toolCells = new Map(); // callId → cell index (parallel tools)
73
+ #write;
74
+ #resizeHandler = null;
75
+ // the chrome state (the Dock façade)
76
+ #status = "";
77
+ #tail = "";
78
+ #question = null;
79
+ #inputState = () => ({ line: "", cursor: 0 });
80
+ #inputPrompt = "";
81
+ #menuState = null;
82
+ constructor(opts) {
83
+ this.#opts = opts;
84
+ this.#write = opts.write ?? ((s) => process.stdout.write(s));
85
+ this.#active = opts.active();
86
+ // v6: the single writer — the compositor IS the dock; the CLI's
87
+ // onDock callback (which used to re-pin the dock after a scroll)
88
+ // is retired with the split.
89
+ compositorRef = this;
90
+ // the Dock façade's bindings may arrive BEFORE this construction
91
+ // (the CLI binds the editor state in makeLineInput, then constructs
92
+ // the Body) — the buffered bindings apply here, or the input row
93
+ // would never render the typed line.
94
+ if (dockBindings !== null) {
95
+ this.#inputState = dockBindings.state;
96
+ this.#inputPrompt = dockBindings.prompt;
97
+ this.#menuState = dockBindings.menu;
98
+ dockBindings = null;
99
+ }
100
+ }
101
+ /** Live re-check — a TTY whose size lands after construction flips in. */
102
+ #isActive() {
103
+ this.#active = this.#opts.active();
104
+ return this.#active;
105
+ }
106
+ // ---- the Body façade: mutations (the ONLY way the CLI touches state) ----
107
+ userLine(text) {
108
+ if (!this.#isActive()) {
109
+ this.#closeOpenThinking();
110
+ this.#closeOpenText();
111
+ const p = palette();
112
+ this.#write(`${p.bold}you> ${escapeTerminal(text)}${p.reset}\n`);
113
+ return;
114
+ }
115
+ this.#closeOpenThinking();
116
+ this.#closeOpenText();
117
+ this.#cells.push({ kind: "user", text, done: true });
118
+ this.#mark();
119
+ }
120
+ thinkingAppend(text) {
121
+ if (!this.#isActive()) {
122
+ this.#pipeBuf += text; // buffered; the fold prints at the block's end
123
+ return;
124
+ }
125
+ const last = this.#cells[this.#cells.length - 1];
126
+ if (last !== undefined && last.kind === "thinking" && !last.done) {
127
+ last.text += text;
128
+ }
129
+ else {
130
+ this.#cells.push({ kind: "thinking", text, done: false });
131
+ }
132
+ this.#mark();
133
+ }
134
+ thinkingEnd() {
135
+ const last = this.#cells[this.#cells.length - 1];
136
+ if (last !== undefined && last.kind === "thinking" && !last.done) {
137
+ last.done = true;
138
+ this.#lastThinking = last.text;
139
+ if (!this.#isActive())
140
+ this.#write(foldThinking(last.text));
141
+ this.#mark();
142
+ }
143
+ }
144
+ toolStart(name, callId, input) {
145
+ const summary = JSON.stringify(input).slice(0, 60);
146
+ this.#pendingCalls.set(callId, { name, input, result: { content: "", isError: false } });
147
+ if (!this.#isActive()) {
148
+ this.#closeOpenThinking();
149
+ this.#closeOpenText();
150
+ this.#write(`→ ${escapeTerminal(name)}(${escapeTerminal(JSON.stringify(input).slice(0, 200))})\n`);
151
+ return;
152
+ }
153
+ this.#toolCells.set(callId, this.#cells.length);
154
+ this.#cells.push({ kind: "tool", name, input: summary, state: "pending", isError: false, resultText: "", diff: null, added: 0, removed: 0, startedAt: null, doneAt: null, done: false });
155
+ this.#mark();
156
+ }
157
+ toolApproval(callId, diff) {
158
+ if (!this.#isActive())
159
+ return;
160
+ const cell = this.#toolCell(callId);
161
+ if (cell !== null && cell.kind === "tool" && !cell.done) {
162
+ cell.state = "approval";
163
+ cell.diff = diff === null ? null : truncateDiff(diff.lines);
164
+ cell.added = diff?.added ?? 0;
165
+ cell.removed = diff?.removed ?? 0;
166
+ }
167
+ this.#mark();
168
+ }
169
+ toolRunning(callId) {
170
+ if (!this.#isActive()) {
171
+ const p = palette();
172
+ this.#write(`${p.dim} running…${p.reset}\n`);
173
+ return;
174
+ }
175
+ const cell = this.#toolCell(callId);
176
+ if (cell !== null && cell.kind === "tool" && !cell.done) {
177
+ cell.state = "running";
178
+ cell.startedAt = Date.now();
179
+ this.#armSpinner();
180
+ }
181
+ this.#mark();
182
+ }
183
+ toolSucceeded(callId) {
184
+ if (!this.#isActive())
185
+ this.#write(" ok\n");
186
+ }
187
+ toolFailed(callId, error) {
188
+ if (!this.#isActive()) {
189
+ const p = palette();
190
+ this.#write(`${p.red} failed: ${escapeTerminal(error.slice(0, 160))}${p.reset}\n`);
191
+ }
192
+ }
193
+ toolResult(callId, result) {
194
+ const call = this.#pendingCalls.get(callId);
195
+ if (call !== undefined) {
196
+ call.result = result;
197
+ this.#lastTool = { name: call.name, input: call.input, result };
198
+ this.#pendingCalls.delete(callId);
199
+ }
200
+ if (!this.#isActive()) {
201
+ const p = palette();
202
+ this.#write(`${renderToolSummary(call?.name ?? "?", call?.input ?? {}, result)}\n` +
203
+ `${p.dim}${result.isError ? p.red : p.dim} [result${result.isError ? " ✗" : ""}] ${foldResult(result.content)}${p.reset}\n`);
204
+ return;
205
+ }
206
+ const cell = this.#toolCell(callId);
207
+ this.#toolCells.delete(callId);
208
+ if (cell !== null && cell.kind === "tool" && !cell.done) {
209
+ cell.state = "done";
210
+ cell.isError = result.isError;
211
+ cell.resultText = result.content;
212
+ cell.doneAt = Date.now();
213
+ cell.done = true;
214
+ }
215
+ this.#mark();
216
+ }
217
+ textAppend(text) {
218
+ if (!this.#isActive()) {
219
+ this.#closeOpenThinking();
220
+ this.#closeOpenText();
221
+ this.#write(escapeTerminal(text));
222
+ return;
223
+ }
224
+ const last = this.#cells[this.#cells.length - 1];
225
+ if (last !== undefined && last.kind === "text" && !last.done) {
226
+ last.text += text;
227
+ }
228
+ else {
229
+ this.#closeOpenThinking();
230
+ this.#closeOpenText();
231
+ this.#cells.push({ kind: "text", text, done: false });
232
+ }
233
+ this.#mark();
234
+ }
235
+ textEnd() {
236
+ if (!this.#isActive()) {
237
+ this.#write("\n");
238
+ return;
239
+ }
240
+ const last = this.#cells[this.#cells.length - 1];
241
+ if (last !== undefined && last.kind === "text" && !last.done)
242
+ last.done = true;
243
+ this.#mark();
244
+ }
245
+ terminal(label, statusLineText) {
246
+ if (!this.#isActive()) {
247
+ this.#closeOpenThinking();
248
+ this.#closeOpenText();
249
+ this.#write(label + renderTerminalGap(statusLineText));
250
+ return;
251
+ }
252
+ this.#closeOpenThinking();
253
+ this.#closeOpenText();
254
+ this.#cells.push({ kind: "terminal", label: label.trim(), line: statusLineText, done: true });
255
+ this.#mark();
256
+ }
257
+ notice(text) {
258
+ if (!this.#isActive()) {
259
+ this.#closeOpenThinking();
260
+ this.#closeOpenText();
261
+ this.#write(`${text}\n`);
262
+ return;
263
+ }
264
+ this.#closeOpenThinking();
265
+ this.#closeOpenText();
266
+ this.#cells.push({ kind: "notice", text, done: true });
267
+ this.#mark();
268
+ }
269
+ checklist(header, items) {
270
+ if (!this.#isActive()) {
271
+ this.#closeOpenThinking();
272
+ this.#closeOpenText();
273
+ const p = palette();
274
+ this.#write(`${p.bold}▞${p.reset} ${escapeTerminal(header)}\n`);
275
+ const glyphOf = (status) => (status === "pending" ? "□" : status === "active" ? "▖" : "▣");
276
+ for (const item of items)
277
+ this.#write(` ${glyphOf(item.status)} ${escapeTerminal(item.text)}\n`);
278
+ return;
279
+ }
280
+ this.#closeOpenThinking();
281
+ this.#closeOpenText();
282
+ this.#cells.push({ kind: "checklist", header, items, done: true });
283
+ this.#mark();
284
+ }
285
+ raw(lines) {
286
+ if (!this.#isActive()) {
287
+ this.#closeOpenThinking();
288
+ this.#closeOpenText();
289
+ for (const line of lines)
290
+ this.#write(`${line}\n`);
291
+ return;
292
+ }
293
+ this.#closeOpenThinking();
294
+ this.#closeOpenText();
295
+ this.#cells.push({ kind: "raw", lines, done: true });
296
+ this.#mark();
297
+ }
298
+ /** The last COMPLETE thinking block, for /think. */
299
+ lastThinking() {
300
+ return this.#lastThinking;
301
+ }
302
+ /** The last completed tool call, for /last. */
303
+ lastTool() {
304
+ return this.#lastTool;
305
+ }
306
+ // ---- the Dock façade (the CLI's chrome API — same shape as the old dock) ----
307
+ /** Docked = the chrome is live (a color TTY with a real size). */
308
+ get active() {
309
+ return this.#docked && this.#isActive();
310
+ }
311
+ enter() {
312
+ const rows = process.stdout.rows ?? 0;
313
+ if (process.stdout.isTTY !== true || palette().bold === "" || rows < 4)
314
+ return;
315
+ this.#docked = true;
316
+ this.#resizeHandler = () => this.onResize();
317
+ process.stdout.on("resize", this.#resizeHandler);
318
+ this.#fullRedraw = true;
319
+ this.#dirty = true;
320
+ this.render(); // the FIRST frame — the full-redraw path, no pre-clear
321
+ }
322
+ /** Teardown — CSI r (the "no broken terminal" contract byte), the
323
+ * chrome rows cleared, the cursor home at the input line. */
324
+ exit() {
325
+ if (!this.#docked)
326
+ return;
327
+ this.#docked = false;
328
+ if (this.#resizeHandler !== null) {
329
+ process.stdout.off("resize", this.#resizeHandler);
330
+ this.#resizeHandler = null;
331
+ }
332
+ const H = this.#lastH > 0 ? this.#lastH : process.stdout.rows ?? 24;
333
+ const out = [];
334
+ out.push("\x1b[r");
335
+ for (let row = H - 2; row <= H; row += 1) {
336
+ out.push(`\x1b[${row};1H\x1b[0K`); // clear the three chrome rows
337
+ }
338
+ out.push(`\x1b[${Math.max(1, H - 1)};1H`);
339
+ this.#write(out.join(""));
340
+ }
341
+ /** SIGWINCH: clear the OLD live area (recorded geometry, ED only —
342
+ * zero LF, zero \x1b[3J — the shell history untouched), then the
343
+ * full-redraw path at the NEW geometry (O(height), zero replay).
344
+ * The clear starts at the ON-SCREEN live top (the bottom-anchored
345
+ * live region's first row) — NEVER at the formula's committed-count
346
+ * top: external writes (the CLI's console.error CRLF) can shift the
347
+ * committed content down, and a formula-top ED0 would clear it. */
348
+ onResize() {
349
+ if (!this.#isActive())
350
+ return;
351
+ const H = this.#opts.height();
352
+ const liveRows = this.#lastLiveRows > 0 ? this.#lastLiveRows : 3;
353
+ const from = Math.max(1, (this.#lastH > 0 ? this.#lastH : H) - liveRows + 1);
354
+ this.#write(`\x1b[${Math.min(from, Math.max(1, H))};1H\x1b[0J`);
355
+ this.#fullRedraw = true;
356
+ this.#dirty = true;
357
+ this.render(); // the immediate redraw at the NEW geometry
358
+ }
359
+ setStatus(text) {
360
+ this.#status = text;
361
+ this.redraw();
362
+ }
363
+ setTail(tail) {
364
+ this.#tail = tail;
365
+ this.redraw();
366
+ }
367
+ showQuestion(question) {
368
+ this.#question = question;
369
+ this.redraw();
370
+ }
371
+ clearQuestion() {
372
+ this.#question = null;
373
+ this.redraw();
374
+ }
375
+ /** Bind the CURRENT input line's state — the focus component reads it. */
376
+ bindInput(state, prompt) {
377
+ this.#inputState = state;
378
+ this.#inputPrompt = prompt;
379
+ }
380
+ /** Bind the editor's slash-command menu state — the MenuSelect slot
381
+ * occupant (the menu replaces the editor's view while open). */
382
+ bindMenu(state) {
383
+ this.#menuState = state;
384
+ }
385
+ /** The input line's edit column — the old dock's API. v6: the CURSOR
386
+ * derives from the frame's marker; this is the same value computed
387
+ * from the bound input state (the CLI's BodyOptions.editCol callback
388
+ * reads it — the marker math never desyncs by construction). */
389
+ editCol() {
390
+ const inp = this.#inputState();
391
+ return displayWidth(this.#inputPrompt.replace(/\x1b\[[0-9;]*m/g, "")) + inp.cursor + 1;
392
+ }
393
+ /** The old dock's redraw — the editor's onRender target: mark + the
394
+ * scheduler (16ms coalescing — the old sync draw coalesces the same). */
395
+ redraw() {
396
+ if (!this.#isActive())
397
+ return;
398
+ this.#dirty = true;
399
+ this.#scheduleFrame();
400
+ }
401
+ // ---- the scheduler (event-driven; zero heartbeat timers) ----
402
+ #mark() {
403
+ if (!this.#isActive())
404
+ return;
405
+ this.#dirty = true;
406
+ this.#scheduleFrame();
407
+ }
408
+ #scheduleFrame() {
409
+ if (this.#frameTimer !== null)
410
+ return;
411
+ this.#frameTimer = setTimeout(() => {
412
+ this.#frameTimer = null;
413
+ if (this.#dirty) {
414
+ this.#dirty = false;
415
+ this.render();
416
+ }
417
+ }, FRAME_MS);
418
+ this.#frameTimer.unref();
419
+ }
420
+ /** The spinner: a ONE-SHOT re-armed ONLY while a running tool exists —
421
+ * no tool → no timer → zero bytes (the #14/#15 contract, structural). */
422
+ #armSpinner() {
423
+ if (this.#spinnerTimer !== null)
424
+ return;
425
+ this.#spinnerTimer = setTimeout(() => {
426
+ this.#spinnerTimer = null;
427
+ if (this.#cells.some((c) => c.kind === "tool" && c.state === "running" && !c.done)) {
428
+ this.#spinnerI = (this.#spinnerI + 1) % SPINNER.length;
429
+ this.#dirty = true;
430
+ this.#scheduleFrame();
431
+ this.#armSpinner();
432
+ }
433
+ }, SPINNER_MS);
434
+ this.#spinnerTimer.unref();
435
+ }
436
+ /** Teardown — flush a pending frame, stop the timers. */
437
+ close() {
438
+ if (this.#frameTimer !== null) {
439
+ clearTimeout(this.#frameTimer);
440
+ this.#frameTimer = null;
441
+ }
442
+ if (this.#spinnerTimer !== null) {
443
+ clearTimeout(this.#spinnerTimer);
444
+ this.#spinnerTimer = null;
445
+ }
446
+ if (this.#dirty)
447
+ this.render();
448
+ }
449
+ // ---- the one writer ----
450
+ /** The live region's scalar — the unit tests assert the cap directly
451
+ * (the e2e gate pins the screen consequence). */
452
+ liveCount() {
453
+ const live = this.#cells.slice(this.#committed);
454
+ const ctx = { spinnerI: this.#spinnerI, now: Date.now() };
455
+ const W = this.#opts.width();
456
+ let lines = 0;
457
+ for (const cell of live)
458
+ lines += cellComponent(cell).render(W, ctx).length;
459
+ return lines + CHROME_ROWS + this.#menuRows(W).length;
460
+ }
461
+ /** The lines committed THIS frame — the writes land in the frame's
462
+ * committed section (the rows just above the live region). */
463
+ #committedLinesThisFrame = [];
464
+ render() {
465
+ if (!this.#isActive())
466
+ return;
467
+ const H = this.#opts.height();
468
+ const W = this.#opts.width();
469
+ if (H < 4)
470
+ return;
471
+ this.#lastH = H;
472
+ const ctx = { spinnerI: this.#spinnerI, now: Date.now() };
473
+ // 1. the natural commits — the leading DONE cells freeze: their
474
+ // lines leave the live region, the scrolls + the committed
475
+ // writes below place them (the #17 "freeze as a real line",
476
+ // short sessions included — the frame coalescing keeps a
477
+ // cell's first frame its freeze frame, so the frozen bytes
478
+ // emit exactly once).
479
+ this.#committedLinesThisFrame = [];
480
+ while (this.#committed < this.#cells.length && this.#cells[this.#committed].done) {
481
+ this.#commitCell(this.#committed, W, ctx);
482
+ }
483
+ // 2. the live lines — the unfinished cells (the tail) + the chrome.
484
+ const menuRows = this.#menuRows(W);
485
+ const chromeRows = CHROME_ROWS + menuRows.length;
486
+ let liveLines = [];
487
+ for (const cell of this.#cells.slice(this.#committed)) {
488
+ liveLines.push(...cellComponent(cell).render(W, ctx));
489
+ }
490
+ // 3. the FORCE commits — the live region's hard cap H−1: overflow
491
+ // commits the oldest live cell UNCONDITIONALLY (the one sharp
492
+ // edge — the cap scalar is asserted by the gates).
493
+ while (liveLines.length + chromeRows > H - 1 && this.#committed < this.#cells.length) {
494
+ this.#commitCell(this.#committed, W, ctx);
495
+ liveLines = [];
496
+ for (const cell of this.#cells.slice(this.#committed)) {
497
+ liveLines.push(...cellComponent(cell).render(W, ctx));
498
+ }
499
+ }
500
+ // 4. the geometry — the live region's first row:
501
+ // liveTop = min(totalCommitted, H - liveRows) + 1 — the screen
502
+ // shows the bottom H rows; the live region anchors to the bottom.
503
+ const liveRowsTotal = liveLines.length + chromeRows;
504
+ const liveTop = Math.min(this.#committedLines, H - liveRowsTotal) + 1;
505
+ // 5. the frame bytes.
506
+ const out = [];
507
+ out.push("\x1b[?2026h"); // synchronized output ON (DEC 2026)
508
+ if (this.#fullRedraw) {
509
+ this.#drawFull(out, W, H, liveTop, liveLines, menuRows, ctx);
510
+ this.#fullRedraw = false;
511
+ }
512
+ else {
513
+ this.#drawSteady(out, W, H, liveTop, liveLines, menuRows, ctx);
514
+ }
515
+ out.push("\x1b[?2026l");
516
+ this.#write(out.join(""));
517
+ this.#lastLiveTop = liveTop;
518
+ this.#lastLiveRows = liveRowsTotal;
519
+ }
520
+ /** Commit the cell at index i: render + cache its lines (immutable —
521
+ * the force-committed form freezes at the current render), advance
522
+ * the bookkeeping — and collect the lines for this frame's writes.
523
+ * Pure accounting + the write list; the BYTES emit in the frame. */
524
+ #commitCell(i, W, ctx) {
525
+ const cell = this.#cells[i];
526
+ const lines = cellComponent(cell).render(W, ctx);
527
+ this.#lineCache[i] = lines;
528
+ this.#committed += 1;
529
+ this.#committedLines += lines.length;
530
+ this.#committedLinesThisFrame.push(...lines);
531
+ }
532
+ /** The slot occupant's extra rows — the slash-command menu (above the
533
+ * status, in the rhythm gap + the content's spare rows — the old
534
+ * menu's position, slot-shaped). */
535
+ #menuRows(W) {
536
+ const menu = this.#menuState?.();
537
+ if (menu === null || menu === undefined || menu.items.length === 0)
538
+ return [];
539
+ const p = palette();
540
+ const rows = [];
541
+ for (let i = 0; i < menu.items.length; i += 1) {
542
+ const item = menu.items[i];
543
+ const text = i === menu.selected
544
+ ? `${p.bold}▸ ${item.name}${p.reset} ${item.desc}`
545
+ : `${p.dim} ${item.name} ${item.desc}${p.reset}`;
546
+ rows.push(...foldLine(text, W));
547
+ }
548
+ return rows;
549
+ }
550
+ /** The focus component's input row — the marker embedded at the
551
+ * cursor's display column WITHIN THE ROW (the brick/question lead
552
+ * included), the question/editor/menu variants. The compositor
553
+ * strips the marker and moves LEFT by the trailing width — the
554
+ * cursor derives from the frame, never from side-channel math. */
555
+ #inputRow(W, _ctx) {
556
+ const st = this.#inputState();
557
+ let row;
558
+ if (this.#question !== null) {
559
+ // the ApprovalPrompt occupant — the question IS the prompt (the
560
+ // slot swap; the brick returns when the question clears)
561
+ row = `${this.#question}${st.line}`;
562
+ }
563
+ else {
564
+ row = `${this.#inputPrompt}${st.line}`;
565
+ }
566
+ // the lead (the prompt / the question) width — the marker's row
567
+ // column = leadW + the line cursor (the dockState cursor counts
568
+ // within the line only)
569
+ const leadW = visibleWidth(row.slice(0, row.length - st.line.length));
570
+ // embed the marker at the cursor's display column
571
+ let markerLine = "";
572
+ {
573
+ let w = 0;
574
+ let inserted = false;
575
+ let i = 0;
576
+ while (i < row.length) {
577
+ if (row[i] === "\x1b") {
578
+ const m = /^\x1b\[[0-9;]*m/.exec(row.slice(i));
579
+ if (m !== null) {
580
+ markerLine += m[0];
581
+ i += m[0].length;
582
+ continue;
583
+ }
584
+ }
585
+ if (!inserted && w >= leadW + st.cursor) {
586
+ markerLine += CURSOR_MARKER;
587
+ inserted = true;
588
+ }
589
+ const cw = displayWidth(row[i]);
590
+ markerLine += row[i];
591
+ w += cw;
592
+ i += 1;
593
+ }
594
+ if (!inserted) {
595
+ markerLine += CURSOR_MARKER;
596
+ }
597
+ }
598
+ const stripped = markerLine.replace(CURSOR_MARKER, "");
599
+ const afterW = visibleWidth(markerLine.slice(markerLine.indexOf(CURSOR_MARKER) + CURSOR_MARKER.length));
600
+ return { stripped, afterW };
601
+ }
602
+ /** The full-redraw path (the first frame, the resize repaint) — CUP
603
+ * allowed here; zero LF; zero \x1b[3J; zero replay. The committed
604
+ * lines (this frame's) write at [liveTop−N .. liveTop−1]. */
605
+ #drawFull(out, W, H, liveTop, liveLines, menuRows, ctx) {
606
+ const committed = this.#committedLinesThisFrame;
607
+ let r = Math.max(1, liveTop - committed.length);
608
+ for (const line of committed) {
609
+ out.push(`\x1b[${r};1H\x1b[0K${this.#checked(line, W)}`);
610
+ r += 1;
611
+ }
612
+ for (const line of liveLines) {
613
+ out.push(`\x1b[${r};1H\x1b[0K${this.#checked(line, W)}`);
614
+ r += 1;
615
+ }
616
+ const menuTop = H - 2 - menuRows.length;
617
+ for (let i = 0; i < menuRows.length; i += 1) {
618
+ out.push(`\x1b[${menuTop + i};1H\x1b[0K${this.#checked(menuRows[i], W)}`);
619
+ }
620
+ out.push(`\x1b[${H - 2};1H\x1b[0K${this.#checked(statusLine(this.#status, this.#tail, this.#question !== null, W), W)}`);
621
+ const editor = this.#inputRow(W, ctx);
622
+ out.push(`\x1b[${H - 1};1H\x1b[0K${this.#checked(editor.stripped, W)}`);
623
+ out.push(`\x1b[${H};1H\x1b[0K${footerLine(W)}`);
624
+ // the cursor: up one (the editor row) + left to the marker column
625
+ out.push("\x1b[1A");
626
+ if (editor.afterW > 0)
627
+ out.push(`\x1b[${editor.afterW}D`);
628
+ }
629
+ /** The steady-state frame — RELATIVE moves only (invariant ②); the
630
+ * commits scroll via the CUP-free real LF at the last row, and the
631
+ * committed lines write in the march's top section (rows
632
+ * [liveTop−N .. liveTop−1] — the frozen area's bottom). */
633
+ #drawSteady(out, W, H, liveTop, liveLines, menuRows, ctx) {
634
+ const editor = this.#inputRow(W, ctx); // derived from the frame — the marker
635
+ const committed = this.#committedLinesThisFrame;
636
+ // the commits' scrolls — from the anchor (H−1) down to H, then LF
637
+ if (committed.length > 0) {
638
+ out.push("\x1b[1B");
639
+ for (let i = 0; i < committed.length; i += 1)
640
+ out.push("\n");
641
+ }
642
+ else {
643
+ out.push("\x1b[1B"); // to the footer row
644
+ }
645
+ // the bottom-up repaint, from the last row up
646
+ out.push(`\x1b[1G\x1b[0K${footerLine(W)}`); // H
647
+ out.push(`\x1b[1A\x1b[1G\x1b[0K${this.#checked(editor.stripped, W)}`); // H−1 — the editor
648
+ out.push(`\x1b[1A\x1b[1G\x1b[0K${this.#checked(statusLine(this.#status, this.#tail, this.#question !== null, W), W)}`); // H−2
649
+ for (let i = menuRows.length - 1; i >= 0; i -= 1) {
650
+ out.push(`\x1b[1A\x1b[1G\x1b[0K${this.#checked(menuRows[i], W)}`);
651
+ }
652
+ for (let i = liveLines.length - 1; i >= 0; i -= 1) {
653
+ out.push(`\x1b[1A\x1b[1G\x1b[0K${this.#checked(liveLines[i], W)}`);
654
+ }
655
+ // the FROZEN area — CUP (absolute rows; this is the FREEZE path —
656
+ // the old code's frozen writes were CUP too. The live region above
657
+ // keeps the relative-only rule; the frozen rows are computed from
658
+ // the current geometry, so external writes (the CLI's console.error
659
+ // CRLF) cannot misplace them the way a relative march could).
660
+ // 1. the GAP rows (between the live content and the chrome) — EL'd
661
+ // so the old content there cannot ghost.
662
+ for (let r = liveTop + liveLines.length; r <= H - 3; r += 1) {
663
+ out.push(`\x1b[${r};1H\x1b[0K`);
664
+ }
665
+ // 2. the STALE rows above the committed section — the scrolled old
666
+ // live copies (a live-drawn cell's pre-commit position): EL.
667
+ const staleFrom = Math.max(1, this.#lastLiveTop - committed.length);
668
+ for (let r = staleFrom; r < liveTop - committed.length; r += 1) {
669
+ out.push(`\x1b[${r};1H\x1b[0K`);
670
+ }
671
+ // 3. the committed lines at [liveTop−N .. liveTop−1] — the rows
672
+ // CLAMP at 1: a super-tall force-commit's early lines have no
673
+ // on-screen row (they would need a negative CUP — terminal
674
+ // undefined behavior); their content stays in the scrollback.
675
+ for (let i = 0; i < committed.length; i += 1) {
676
+ out.push(`\x1b[${Math.max(1, liveTop - committed.length + i)};1H\x1b[0K${this.#checked(committed[i], W)}`);
677
+ }
678
+ // the cursor: down to the anchor (H−1) + left to the marker column —
679
+ // the down-distance from the LAST written row (the committed bottom,
680
+ // the stale bottom, or the gap bottom when nothing else wrote).
681
+ const lastRow = committed.length > 0 ? liveTop - 1 : staleFrom < liveTop ? liveTop - 1 : H - 3;
682
+ const down = H - 1 - lastRow;
683
+ if (down > 0)
684
+ out.push(`\x1b[${down}B`);
685
+ if (editor.afterW > 0)
686
+ out.push(`\x1b[${editor.afterW}D`);
687
+ }
688
+ /** Invariant ①: every emitted line fits the width — a violation is a
689
+ * CRASH with the diagnostic, never a silent truncate. */
690
+ #checked(line, W) {
691
+ const w = visibleWidth(line);
692
+ if (w > W) {
693
+ throw new Error(`kiso-tui invariant ① violated: a line of visible width ${w} > ${W} was about to be emitted — ${JSON.stringify(line.slice(0, 80))}`);
694
+ }
695
+ return line;
696
+ }
697
+ #toolCell(callId) {
698
+ const i = this.#toolCells.get(callId);
699
+ return i === undefined ? null : (this.#cells[i] ?? null);
700
+ }
701
+ /** Close an open TEXT cell when a new cell starts (see v2d — the
702
+ * runtime emits no text_end; the next cell is the close signal). */
703
+ #closeOpenText() {
704
+ const last = this.#cells[this.#cells.length - 1];
705
+ if (last !== undefined && last.kind === "text" && !last.done)
706
+ last.done = true;
707
+ }
708
+ /** Close an open thinking cell when a new cell starts. */
709
+ #closeOpenThinking() {
710
+ if (!this.#isActive() && this.#pipeBuf !== "") {
711
+ this.#lastThinking = this.#pipeBuf;
712
+ this.#write(foldThinking(this.#pipeBuf));
713
+ this.#pipeBuf = "";
714
+ return;
715
+ }
716
+ const last = this.#cells[this.#cells.length - 1];
717
+ if (last !== undefined && last.kind === "thinking" && !last.done) {
718
+ last.done = true;
719
+ this.#lastThinking = last.text;
720
+ }
721
+ }
722
+ }
723
+ /** The Dock — the CLI's module-scope singleton façade. Every method
724
+ * delegates to the one compositor (registered at construction); the
725
+ * input/menu bindings, which the CLI performs BEFORE the Body exists,
726
+ * are buffered and applied by the Body's constructor. */
727
+ export class Dock {
728
+ #menuState = null;
729
+ get active() {
730
+ return compositorRef !== null && compositorRef.active;
731
+ }
732
+ enter() {
733
+ compositorRef?.enter();
734
+ }
735
+ exit() {
736
+ compositorRef?.exit();
737
+ }
738
+ onResize() {
739
+ compositorRef?.onResize();
740
+ }
741
+ setStatus(text) {
742
+ compositorRef?.setStatus(text);
743
+ }
744
+ setTail(tail) {
745
+ compositorRef?.setTail(tail);
746
+ }
747
+ showQuestion(question) {
748
+ compositorRef?.showQuestion(question);
749
+ }
750
+ clearQuestion() {
751
+ compositorRef?.clearQuestion();
752
+ }
753
+ bindInput(state, prompt) {
754
+ if (compositorRef === null) {
755
+ // the Body is constructed AFTER the CLI's makeLineInput — buffer
756
+ // the binding; the Body's constructor applies it
757
+ dockBindings = { state, prompt, menu: this.#menuState };
758
+ return;
759
+ }
760
+ compositorRef.bindInput(state, prompt);
761
+ }
762
+ bindMenu(state) {
763
+ this.#menuState = state;
764
+ if (compositorRef === null)
765
+ return;
766
+ compositorRef.bindMenu(state);
767
+ }
768
+ editCol() {
769
+ return compositorRef?.editCol() ?? 1;
770
+ }
771
+ redraw() {
772
+ compositorRef?.redraw();
773
+ }
774
+ }
775
+ /** The one-compositor registry — the Dock façade routes to it. */
776
+ let compositorRef = null;
777
+ /** The Dock's pre-compositor bindings — the CLI binds the editor state
778
+ * before the Body exists; the Body's constructor consumes them. */
779
+ let dockBindings = null;