@vincemakes/kiso-tui 0.1.30 → 0.1.33

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