@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.
- package/dist/components.d.ts +116 -0
- package/dist/components.js +333 -0
- package/dist/compositor.d.ts +165 -0
- package/dist/compositor.js +779 -0
- package/dist/index.d.ts +5 -4
- package/dist/index.js +5 -4
- package/package.json +1 -1
|
@@ -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;
|