@vincemakes/kiso-tui 0.1.36 → 0.1.38
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 +113 -7
- package/dist/components.js +471 -66
- package/dist/compositor.d.ts +58 -5
- package/dist/compositor.js +475 -58
- package/dist/editor.d.ts +3 -6
- package/dist/editor.js +27 -54
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/render.d.ts +46 -5
- package/dist/render.js +150 -30
- package/dist/width.d.ts +15 -0
- package/dist/width.js +59 -0
- package/package.json +1 -1
package/dist/compositor.js
CHANGED
|
@@ -35,22 +35,29 @@
|
|
|
35
35
|
* scheduler — a one-shot setTimeout re-armed only while a running
|
|
36
36
|
* tool exists (the #14/#15 zero-output contract is structural).
|
|
37
37
|
*
|
|
38
|
-
* Layout at H rows (V6-3 — the design §03 chrome):
|
|
39
|
-
* 1..H−4,
|
|
38
|
+
* Layout at H rows (V6-3 — the design §03 chrome; W6 — the box):
|
|
39
|
+
* content rows 1..H−4, box top H−3, editor (the slot) H−2, box
|
|
40
|
+
* bottom H−1, status H.
|
|
40
41
|
* Pipes / NO_COLOR: the passthrough branches below keep the v2a/v2b
|
|
41
42
|
* line-mode bytes byte-for-byte (the e2e guards them).
|
|
42
43
|
*/
|
|
43
44
|
import { truncateDiff } from "./diff.js";
|
|
44
45
|
import { displayWidth } from "./editor.js";
|
|
45
|
-
import { Container, SPINNER, cellComponent, foldLine,
|
|
46
|
-
import { escapeTerminal, foldResult, foldThinking, palette, renderTerminalGap, renderToolSummary } from "./render.js";
|
|
46
|
+
import { Container, ROLLUP_NOUN, SPINNER, bodySpacing, boxBottom, boxTop, cellComponent, foldLine, statusLine, turnFold, visibleWidth, } from "./components.js";
|
|
47
|
+
import { bannerLines, escapeTerminal, foldResult, foldThinking, palette, renderTerminalGap, renderToolSummary, toolTarget } from "./render.js";
|
|
47
48
|
/** The cursor marker — an APC private sequence the focus component
|
|
48
49
|
* embeds at the edit position; the compositor strips it and moves
|
|
49
50
|
* relatively (it never reaches the terminal). */
|
|
50
51
|
export const CURSOR_MARKER = "\x1b_[kiso-cur]\x1b\\";
|
|
51
52
|
const FRAME_MS = 16; // state changes coalesce to ≥16ms frames
|
|
52
53
|
const SPINNER_MS = 200; // the spinner cadence — a ONE-SHOT re-armed on demand
|
|
53
|
-
const CHROME_ROWS = 4; //
|
|
54
|
+
const CHROME_ROWS = 4; // box top + input + box bottom + status — the design §03 chrome (V6-3; the box is W6)
|
|
55
|
+
/** W20 — the whole-table-replace comparison: the live todo block only
|
|
56
|
+
* redraws when the items actually changed (the todo extension's
|
|
57
|
+
* idempotent shape — an unchanged replace is a no-op, no frame). */
|
|
58
|
+
function sameTodo(a, b) {
|
|
59
|
+
return a.length === b.length && a.every((x, i) => x.text === b[i].text && x.status === b[i].status);
|
|
60
|
+
}
|
|
54
61
|
/** The one compositor — implements the Body façade AND the Dock chrome
|
|
55
62
|
* API (see the class comments on each method group). */
|
|
56
63
|
export class Body {
|
|
@@ -74,10 +81,24 @@ export class Body {
|
|
|
74
81
|
#pendingCalls = new Map();
|
|
75
82
|
#pipeBuf = ""; // the passthrough's thinking buffer
|
|
76
83
|
#toolCells = new Map(); // callId → cell index (parallel tools)
|
|
84
|
+
// W15: the collapsed (cut) tool cells — committed cells whose last
|
|
85
|
+
// rendered row carried the "ctrl+r" affordance; the expand key's
|
|
86
|
+
// cycling pointer walks this list from the newest back.
|
|
87
|
+
#collapsed = [];
|
|
88
|
+
#expandPtr = 0;
|
|
89
|
+
// W14: the turn records — one per userLine, the fold-hold's state
|
|
90
|
+
// machine (ended / hasText / folded) plus the folded-turn line's
|
|
91
|
+
// counts (accumulated at toolStart). The cells carry the record's
|
|
92
|
+
// index as their turn boundary.
|
|
93
|
+
#turns = [];
|
|
94
|
+
// W13: the rolled-up run heads — the commit-time scan's verdict:
|
|
95
|
+
// the head's group summary renders, the members render [].
|
|
96
|
+
#rolledHeads = new Set();
|
|
77
97
|
#write;
|
|
78
98
|
#resizeHandler = null;
|
|
79
99
|
// the chrome state (the Dock façade)
|
|
80
100
|
#status = "";
|
|
101
|
+
#statusHint = null;
|
|
81
102
|
#tail = "";
|
|
82
103
|
#question = null;
|
|
83
104
|
#inputState = () => ({ line: "", cursor: 0 });
|
|
@@ -118,7 +139,10 @@ export class Body {
|
|
|
118
139
|
}
|
|
119
140
|
this.#closeOpenThinking();
|
|
120
141
|
this.#closeOpenText();
|
|
121
|
-
|
|
142
|
+
// W14: the turn boundary — the record the fold-hold's release
|
|
143
|
+
// state machine reads; the cell carries the record's index.
|
|
144
|
+
this.#turns.push({ ended: false, hasText: false, thoughtSeconds: 0, reads: 0, edits: 0, others: new Map(), folded: false });
|
|
145
|
+
this.#cells.push({ kind: "user", text, done: true, turn: this.#turns.length - 1 });
|
|
122
146
|
this.#mark();
|
|
123
147
|
}
|
|
124
148
|
thinkingAppend(text) {
|
|
@@ -131,7 +155,7 @@ export class Body {
|
|
|
131
155
|
last.text += text;
|
|
132
156
|
}
|
|
133
157
|
else {
|
|
134
|
-
this.#cells.push({ kind: "thinking", text, done: false });
|
|
158
|
+
this.#cells.push({ kind: "thinking", text, done: false, turn: this.#turns.length - 1 });
|
|
135
159
|
}
|
|
136
160
|
this.#mark();
|
|
137
161
|
}
|
|
@@ -154,8 +178,30 @@ export class Body {
|
|
|
154
178
|
this.#write(`→ ${escapeTerminal(name)}(${escapeTerminal(JSON.stringify(input).slice(0, 200))})\n`);
|
|
155
179
|
return;
|
|
156
180
|
}
|
|
181
|
+
// W12: the cell carries the delegate's child roles from the FULL
|
|
182
|
+
// input — the display summary is sliced at 60 chars (unparseable);
|
|
183
|
+
// the roles are the only running-state data the parent holds (there
|
|
184
|
+
// is no live channel to a running child session).
|
|
185
|
+
const childRoles = [];
|
|
186
|
+
for (const t of Array.isArray(input.tasks) ? input.tasks : []) {
|
|
187
|
+
if (typeof t === "object" && t !== null && typeof t.role === "string") {
|
|
188
|
+
childRoles.push(t.role);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
157
191
|
this.#toolCells.set(callId, this.#cells.length);
|
|
158
|
-
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 });
|
|
192
|
+
this.#cells.push({ kind: "tool", name, input: summary, inputFull: JSON.stringify(input, null, 2), childRoles, state: "pending", isError: false, resultText: "", diff: null, added: 0, removed: 0, startedAt: null, doneAt: null, done: false, expanded: false, turn: this.#turns.length - 1, rolled: null, reason: null });
|
|
193
|
+
// W14: the turn record's counts — the folded-turn line's terms
|
|
194
|
+
// (reads = read_file, edits = edit_file, the rest in first-call
|
|
195
|
+
// order). The CLI's recap counts the same way (edit_file).
|
|
196
|
+
const turn = this.#turns[this.#turns.length - 1];
|
|
197
|
+
if (turn !== undefined) {
|
|
198
|
+
if (name === "read_file")
|
|
199
|
+
turn.reads += 1;
|
|
200
|
+
else if (name === "edit_file")
|
|
201
|
+
turn.edits += 1;
|
|
202
|
+
else
|
|
203
|
+
turn.others.set(name, (turn.others.get(name) ?? 0) + 1);
|
|
204
|
+
}
|
|
159
205
|
this.#mark();
|
|
160
206
|
}
|
|
161
207
|
toolApproval(callId, diff) {
|
|
@@ -202,8 +248,11 @@ export class Body {
|
|
|
202
248
|
this.#pendingCalls.delete(callId);
|
|
203
249
|
}
|
|
204
250
|
if (!this.#isActive()) {
|
|
251
|
+
// W19: the pipe path renders the SAME pinned deny row (the
|
|
252
|
+
// reason in the W4 parentheses idiom), byte-clean — plus the
|
|
253
|
+
// folded [result ✗] body below (never hide information).
|
|
205
254
|
const p = palette();
|
|
206
|
-
this.#write(`${renderToolSummary(call?.name ?? "?", call?.input ?? {}, result)}\n` +
|
|
255
|
+
this.#write(`${renderToolSummary(call?.name ?? "?", call?.input ?? {}, result, result.reason ?? null)}\n` +
|
|
207
256
|
`${p.dim}${result.isError ? p.red : p.dim} [result${result.isError ? " ✗" : ""}] ${foldResult(result.content)}${p.reset}\n`);
|
|
208
257
|
return;
|
|
209
258
|
}
|
|
@@ -213,6 +262,7 @@ export class Body {
|
|
|
213
262
|
cell.state = "done";
|
|
214
263
|
cell.isError = result.isError;
|
|
215
264
|
cell.resultText = result.content;
|
|
265
|
+
cell.reason = result.reason ?? null;
|
|
216
266
|
cell.doneAt = Date.now();
|
|
217
267
|
cell.done = true;
|
|
218
268
|
}
|
|
@@ -225,6 +275,12 @@ export class Body {
|
|
|
225
275
|
this.#write(escapeTerminal(text));
|
|
226
276
|
return;
|
|
227
277
|
}
|
|
278
|
+
// W14: the text's arrival RELEASES the fold-hold — the turn now
|
|
279
|
+
// has text, its held cells commit individually (with the W13
|
|
280
|
+
// rollups; the fold is only for the QUIET turn).
|
|
281
|
+
const turn = this.#turns[this.#turns.length - 1];
|
|
282
|
+
if (turn !== undefined)
|
|
283
|
+
turn.hasText = true;
|
|
228
284
|
const last = this.#cells[this.#cells.length - 1];
|
|
229
285
|
if (last !== undefined && last.kind === "text" && !last.done) {
|
|
230
286
|
last.text += text;
|
|
@@ -246,6 +302,51 @@ export class Body {
|
|
|
246
302
|
last.done = true;
|
|
247
303
|
this.#mark();
|
|
248
304
|
}
|
|
305
|
+
/** W14 — the turn boundary's END: the CLI calls this at the run's
|
|
306
|
+
* terminal event, once per run, BEFORE the recap (so the fold line
|
|
307
|
+
* commits before the recap in the cell order). `thoughtSeconds` is
|
|
308
|
+
* the CLI's wall-clocked thinking window. The QUIET turn (ended, no
|
|
309
|
+
* text) releases its held cells as the ONE fold line; a turn with
|
|
310
|
+
* text releases them as individual commits (the W13 rollups). The
|
|
311
|
+
* release is LAZY — the held cells commit at the next frame, when
|
|
312
|
+
* the fold/rollup decision runs. */
|
|
313
|
+
endTurn(thoughtSeconds) {
|
|
314
|
+
if (!this.#isActive())
|
|
315
|
+
return;
|
|
316
|
+
const turn = this.#turns[this.#turns.length - 1];
|
|
317
|
+
if (turn === undefined || turn.ended)
|
|
318
|
+
return;
|
|
319
|
+
turn.ended = true;
|
|
320
|
+
turn.thoughtSeconds = thoughtSeconds;
|
|
321
|
+
// W20: the turn's live todo block settles HERE — the ONE recap
|
|
322
|
+
// block for the turn (`todo done · N items · <duration>`, the
|
|
323
|
+
// duration clocked compositor-side from the block's first call —
|
|
324
|
+
// the CLI stays unchanged). A turn that never touched the list has
|
|
325
|
+
// no live block — nothing settles. Newest-first: the live block is
|
|
326
|
+
// the newest cell of its turn.
|
|
327
|
+
for (let i = this.#cells.length - 1; i >= 0; i -= 1) {
|
|
328
|
+
const c = this.#cells[i];
|
|
329
|
+
if (c.kind === "checklist" && !c.done) {
|
|
330
|
+
c.done = true;
|
|
331
|
+
c.durationSeconds = Math.max(0, Math.round((Date.now() - c.startedAt) / 1000));
|
|
332
|
+
break;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
// the QUIET turn: an open thinking cell closes at the boundary —
|
|
336
|
+
// its natural closer is the text's arrival (never comes here — the
|
|
337
|
+
// text-less turn), so without this the fold could never commit AT
|
|
338
|
+
// it (the commit loop only takes done cells — the fold would stall
|
|
339
|
+
// forever behind the live thinking).
|
|
340
|
+
for (let i = this.#cells.length - 1; i >= 0; i -= 1) {
|
|
341
|
+
const c = this.#cells[i];
|
|
342
|
+
if (c.kind === "thinking" && !c.done) {
|
|
343
|
+
c.done = true;
|
|
344
|
+
this.#lastThinking = c.text;
|
|
345
|
+
break;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
this.#mark();
|
|
349
|
+
}
|
|
249
350
|
terminal(label, statusLineText) {
|
|
250
351
|
if (!this.#isActive()) {
|
|
251
352
|
this.#closeOpenThinking();
|
|
@@ -270,6 +371,17 @@ export class Body {
|
|
|
270
371
|
this.#cells.push({ kind: "notice", text, done: true });
|
|
271
372
|
this.#mark();
|
|
272
373
|
}
|
|
374
|
+
/** W20 — the todo checklist as STATE, not events: the FIRST call of a
|
|
375
|
+
* turn creates the ONE live block (done:false — the commit loop only
|
|
376
|
+
* takes done cells, so it stays in the live region); later calls of
|
|
377
|
+
* the SAME turn MUTATE that block in place — same position, same
|
|
378
|
+
* height, zero committed rows (the W8 fixed-window rule generalised
|
|
379
|
+
* to state). An unchanged whole-table replace (the todo extension's
|
|
380
|
+
* idempotent shape) is a no-op — no mark, no frame. The block commits
|
|
381
|
+
* ONCE at the turn's end (endTurn); the next turn's first call starts
|
|
382
|
+
* a fresh block — one settled block per turn that touched the list,
|
|
383
|
+
* never one per update. The pipe path stays per-call (byte-linear —
|
|
384
|
+
* every write is final; there is no in-place redraw in a pipe). */
|
|
273
385
|
checklist(header, items) {
|
|
274
386
|
if (!this.#isActive()) {
|
|
275
387
|
this.#closeOpenThinking();
|
|
@@ -283,7 +395,49 @@ export class Body {
|
|
|
283
395
|
}
|
|
284
396
|
this.#closeOpenThinking();
|
|
285
397
|
this.#closeOpenText();
|
|
286
|
-
this.#
|
|
398
|
+
const turn = this.#turns.length - 1;
|
|
399
|
+
const last = this.#cells[this.#cells.length - 1];
|
|
400
|
+
if (last !== undefined && last.kind === "checklist" && !last.done && last.turn === turn) {
|
|
401
|
+
if (!sameTodo(last.items, items)) {
|
|
402
|
+
Object.assign(last, { header, items });
|
|
403
|
+
this.#mark();
|
|
404
|
+
}
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
this.#cells.push({
|
|
408
|
+
kind: "checklist",
|
|
409
|
+
header,
|
|
410
|
+
items,
|
|
411
|
+
done: false,
|
|
412
|
+
expanded: false,
|
|
413
|
+
startedAt: Date.now(),
|
|
414
|
+
durationSeconds: 0,
|
|
415
|
+
turn,
|
|
416
|
+
});
|
|
417
|
+
this.#mark();
|
|
418
|
+
}
|
|
419
|
+
/** The startup banner (W1): a LIVE cell — the tier re-derives per
|
|
420
|
+
* frame (bannerLines with the CURRENT W and H), so a resize re-tiers
|
|
421
|
+
* the art instead of re-folding frozen rows (a window below 40 cols
|
|
422
|
+
* never paints the logo). W5: the resume metas ride the cell — the
|
|
423
|
+
* list re-gates with the tier (BIG only) and re-times with the
|
|
424
|
+
* frame. The inactive path keeps the historical bytes (no resume —
|
|
425
|
+
* the pipe contract). */
|
|
426
|
+
banner(version, extensionsText, resume = []) {
|
|
427
|
+
if (!this.#isActive()) {
|
|
428
|
+
this.#closeOpenThinking();
|
|
429
|
+
this.#closeOpenText();
|
|
430
|
+
const W = this.#opts.width() || 80; // a 0-size pty falls back
|
|
431
|
+
const H = this.#opts.height();
|
|
432
|
+
const p = palette();
|
|
433
|
+
for (const r of bannerLines(W, H, version, extensionsText))
|
|
434
|
+
this.#write(`${p.dim}${r}${p.reset}\n`);
|
|
435
|
+
this.#write("\n");
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
this.#closeOpenThinking();
|
|
439
|
+
this.#closeOpenText();
|
|
440
|
+
this.#cells.push({ kind: "banner", version, extensionsText, resume, done: true });
|
|
287
441
|
this.#mark();
|
|
288
442
|
}
|
|
289
443
|
raw(lines) {
|
|
@@ -307,6 +461,77 @@ export class Body {
|
|
|
307
461
|
lastTool() {
|
|
308
462
|
return this.#lastTool;
|
|
309
463
|
}
|
|
464
|
+
/** W15 — the expand key's target (ctrl+r). A cell still in the LIVE
|
|
465
|
+
* region (the newest live tool) TOGGLES in place — the compositor
|
|
466
|
+
* owns those rows and redraws them (the body flips to the full
|
|
467
|
+
* form, no cap). A committed cell can never toggle — history is
|
|
468
|
+
* never rewritten (ADR-0046) — so the key APPENDS a fresh expanded
|
|
469
|
+
* block at the bottom instead, the /last idiom aimed at a chosen
|
|
470
|
+
* cell: the pointer cycles the collapsed history, newest first, and
|
|
471
|
+
* the header names the target ("N turns back" — the user cells
|
|
472
|
+
* after it), so every press tells the user what they got. */
|
|
473
|
+
expandNext() {
|
|
474
|
+
for (let i = this.#cells.length - 1; i >= this.#committed; i -= 1) {
|
|
475
|
+
const cell = this.#cells[i];
|
|
476
|
+
if (cell.kind === "tool" && cell.state !== "pending") {
|
|
477
|
+
cell.expanded = !cell.expanded;
|
|
478
|
+
this.#mark();
|
|
479
|
+
return { kind: "toggled" };
|
|
480
|
+
}
|
|
481
|
+
// W20: the LIVE todo block toggles in place too — the capped
|
|
482
|
+
// form flips to the full list (the "done-collapse expands
|
|
483
|
+
// under ctrl+r" claim). The SETTLED block is already full —
|
|
484
|
+
// no toggle, and its rows carry no affordance, so it never
|
|
485
|
+
// joins #collapsed (the committed /last append is moot).
|
|
486
|
+
if (cell.kind === "checklist" && !cell.done) {
|
|
487
|
+
cell.expanded = !cell.expanded;
|
|
488
|
+
this.#mark();
|
|
489
|
+
return { kind: "toggled" };
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
if (this.#collapsed.length === 0)
|
|
493
|
+
return { kind: "none" };
|
|
494
|
+
const idx = this.#collapsed[this.#expandPtr % this.#collapsed.length];
|
|
495
|
+
this.#expandPtr += 1;
|
|
496
|
+
const cell = this.#cells[idx];
|
|
497
|
+
if (cell.kind !== "tool")
|
|
498
|
+
return { kind: "none" };
|
|
499
|
+
if (cell.rolled !== null) {
|
|
500
|
+
// W13: a rolled-up head expands to the FULL per-call children —
|
|
501
|
+
// the rollup showed the first 3 + the overflow; the expand shows
|
|
502
|
+
// every target, one └ row each (the /last idiom — the children
|
|
503
|
+
// land as NEW content, history is never rewritten, ADR-0046).
|
|
504
|
+
const turnsBack = this.#cells.slice(idx + 1).filter((c) => c.kind === "user").length;
|
|
505
|
+
const p = palette();
|
|
506
|
+
const noun = ROLLUP_NOUN[cell.name] ?? "calls";
|
|
507
|
+
const header = `${p.bold}▞${p.reset} expanded · ${escapeTerminal(`${cell.name.replace("_file", "")} ${cell.rolled.count} ${noun}`)} · ${turnsBack} ${turnsBack === 1 ? "turn" : "turns"} back`;
|
|
508
|
+
return {
|
|
509
|
+
kind: "appended",
|
|
510
|
+
lines: [header, ...cell.rolled.targets.map((t) => ` ${p.dim}└ ${escapeTerminal(t)}${p.reset}`)],
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
let input = {};
|
|
514
|
+
try {
|
|
515
|
+
input = JSON.parse(cell.inputFull);
|
|
516
|
+
}
|
|
517
|
+
catch {
|
|
518
|
+
// the full JSON is always parseable (it was stringified at
|
|
519
|
+
// toolStart) — the empty fallback never fires
|
|
520
|
+
}
|
|
521
|
+
const turnsBack = this.#cells.slice(idx + 1).filter((c) => c.kind === "user").length;
|
|
522
|
+
const p = palette();
|
|
523
|
+
const header = `${p.bold}▞${p.reset} expanded · ${escapeTerminal(`${cell.name.replace("_file", "")} ${toolTarget(cell.name, input)}`)} · ${turnsBack} ${turnsBack === 1 ? "turn" : "turns"} back`;
|
|
524
|
+
return {
|
|
525
|
+
kind: "appended",
|
|
526
|
+
lines: [
|
|
527
|
+
header,
|
|
528
|
+
`--- ${cell.name} input ---`,
|
|
529
|
+
cell.inputFull,
|
|
530
|
+
`--- ${cell.name} output${cell.isError ? " (error)" : ""} ---`,
|
|
531
|
+
cell.resultText,
|
|
532
|
+
],
|
|
533
|
+
};
|
|
534
|
+
}
|
|
310
535
|
// ---- the Dock façade (the CLI's chrome API — same shape as the old dock) ----
|
|
311
536
|
/** Docked = the chrome is live (a color TTY with a real size). */
|
|
312
537
|
get active() {
|
|
@@ -360,8 +585,12 @@ export class Body {
|
|
|
360
585
|
this.#dirty = true;
|
|
361
586
|
this.render(); // the immediate redraw at the NEW geometry
|
|
362
587
|
}
|
|
363
|
-
|
|
588
|
+
/** W18: the status row's right-aligned hint is part of the status
|
|
589
|
+
* state — the compacting row passes "esc to cancel" (the affordance
|
|
590
|
+
* must survive repaints). */
|
|
591
|
+
setStatus(text, hint = null) {
|
|
364
592
|
this.#status = text;
|
|
593
|
+
this.#statusHint = hint;
|
|
365
594
|
this.redraw();
|
|
366
595
|
}
|
|
367
596
|
setTail(tail) {
|
|
@@ -392,7 +621,8 @@ export class Body {
|
|
|
392
621
|
* reads it — the marker math never desyncs by construction). */
|
|
393
622
|
editCol() {
|
|
394
623
|
const inp = this.#inputState();
|
|
395
|
-
|
|
624
|
+
// W6: the box's left wall (2 cols) prefixes the prompt
|
|
625
|
+
return 2 + displayWidth(this.#inputPrompt.replace(/\x1b\[[0-9;]*m/g, "")) + inp.cursor + 1;
|
|
396
626
|
}
|
|
397
627
|
/** The old dock's redraw — the editor's onRender target: mark + the
|
|
398
628
|
* scheduler (16ms coalescing — the old sync draw coalesces the same). */
|
|
@@ -452,19 +682,29 @@ export class Body {
|
|
|
452
682
|
}
|
|
453
683
|
// ---- the one writer ----
|
|
454
684
|
/** The live region's scalar — the unit tests assert the cap directly
|
|
455
|
-
* (the e2e gate pins the screen consequence).
|
|
685
|
+
* (the e2e gate pins the screen consequence). W11: the formula's
|
|
686
|
+
* blanks are join artifacts — the count includes them (they are real
|
|
687
|
+
* screen rows), threaded against the previous sibling's OWN rows. */
|
|
456
688
|
liveCount() {
|
|
457
689
|
const live = this.#cells.slice(this.#committed);
|
|
458
|
-
const ctx = { spinnerI: this.#spinnerI, now: Date.now() };
|
|
690
|
+
const ctx = { spinnerI: this.#spinnerI, now: Date.now(), height: this.#opts.height() };
|
|
459
691
|
const W = this.#opts.width();
|
|
460
692
|
let lines = 0;
|
|
461
|
-
|
|
462
|
-
|
|
693
|
+
let prev = this.#committed > 0 ? this.#lineCache[this.#committed - 1] : null;
|
|
694
|
+
for (const cell of live) {
|
|
695
|
+
const rows = cellComponent(cell).render(W, ctx);
|
|
696
|
+
lines += bodySpacing(prev, rows).length;
|
|
697
|
+
prev = rows;
|
|
698
|
+
}
|
|
463
699
|
return lines + CHROME_ROWS + this.#menuRows(W).length;
|
|
464
700
|
}
|
|
465
701
|
/** The lines committed THIS frame — the writes land in the frame's
|
|
466
702
|
* committed section (the rows just above the live region). */
|
|
467
703
|
#committedLinesThisFrame = [];
|
|
704
|
+
// the CELL count when the frame began — the drawFull frozen bound's
|
|
705
|
+
// unit (the cells committed BEFORE this frame; a force-commit frame's
|
|
706
|
+
// placed LINES outnumber its cells)
|
|
707
|
+
#committedAtFrameStart = 0;
|
|
468
708
|
render() {
|
|
469
709
|
if (!this.#isActive())
|
|
470
710
|
return;
|
|
@@ -473,7 +713,7 @@ export class Body {
|
|
|
473
713
|
if (H < 4)
|
|
474
714
|
return;
|
|
475
715
|
this.#lastH = H;
|
|
476
|
-
const ctx = { spinnerI: this.#spinnerI, now: Date.now() };
|
|
716
|
+
const ctx = { spinnerI: this.#spinnerI, now: Date.now(), height: H };
|
|
477
717
|
// V6-1 (the screen-state == frame-state rule): the resize's first
|
|
478
718
|
// frame — the terminal's reflow re-wrapped the committed content at
|
|
479
719
|
// the NEW width, so the cached folds are stale. Re-fold the
|
|
@@ -485,8 +725,8 @@ export class Body {
|
|
|
485
725
|
for (let i = 0; i < this.#committed; i += 1) {
|
|
486
726
|
const cell = this.#cells[i];
|
|
487
727
|
const lines = cellComponent(cell).render(W, ctx);
|
|
488
|
-
this.#lineCache[i] = lines;
|
|
489
|
-
this.#committedLines += lines.length;
|
|
728
|
+
this.#lineCache[i] = lines; // the cell's OWN rows — the cache stays raw
|
|
729
|
+
this.#committedLines += bodySpacing(i > 0 ? this.#lineCache[i - 1] : null, lines).length;
|
|
490
730
|
}
|
|
491
731
|
}
|
|
492
732
|
// 1. the natural commits — the leading DONE cells freeze: their
|
|
@@ -495,16 +735,30 @@ export class Body {
|
|
|
495
735
|
// short sessions included — the frame coalescing keeps a
|
|
496
736
|
// cell's first frame its freeze frame, so the frozen bytes
|
|
497
737
|
// emit exactly once).
|
|
738
|
+
this.#committedAtFrameStart = this.#committed;
|
|
498
739
|
this.#committedLinesThisFrame = [];
|
|
499
|
-
|
|
740
|
+
// W14: the natural loop HONORS the fold-hold — a thinking/tool
|
|
741
|
+
// cell of the OPEN quiet turn (no text yet) does not commit: its
|
|
742
|
+
// committed form is decided at the release (the turn's text →
|
|
743
|
+
// individual commits with the W13 rollups; the turn's end → the
|
|
744
|
+
// fold). The FORCE-commit path below bypasses the hold — the
|
|
745
|
+
// screen never sticks, the rollup degrades to individuals.
|
|
746
|
+
while (this.#committed < this.#cells.length && this.#cells[this.#committed].done && !this.#held(this.#committed)) {
|
|
500
747
|
this.#commitCell(this.#committed, W, ctx);
|
|
501
748
|
}
|
|
502
749
|
// 2. the live lines — the unfinished cells (the tail) + the chrome.
|
|
750
|
+
// W11: the formula's blank above the first live cell hangs off
|
|
751
|
+
// the last COMMITTED sibling (the join spans the boundary).
|
|
503
752
|
const menuRows = this.#menuRows(W);
|
|
504
753
|
const chromeRows = CHROME_ROWS + menuRows.length;
|
|
505
754
|
let liveLines = [];
|
|
506
|
-
|
|
507
|
-
|
|
755
|
+
{
|
|
756
|
+
let prev = this.#committed > 0 ? this.#lineCache[this.#committed - 1] : null;
|
|
757
|
+
for (const cell of this.#cells.slice(this.#committed)) {
|
|
758
|
+
const rows = cellComponent(cell).render(W, ctx);
|
|
759
|
+
liveLines.push(...bodySpacing(prev, rows));
|
|
760
|
+
prev = rows;
|
|
761
|
+
}
|
|
508
762
|
}
|
|
509
763
|
// 3. the FORCE commits — the live region's hard cap H−1: overflow
|
|
510
764
|
// commits the oldest live cell UNCONDITIONALLY (the one sharp
|
|
@@ -512,8 +766,13 @@ export class Body {
|
|
|
512
766
|
while (liveLines.length > H - 4 && this.#committed < this.#cells.length) { // V6-3: the content cap H−4
|
|
513
767
|
this.#commitCell(this.#committed, W, ctx);
|
|
514
768
|
liveLines = [];
|
|
515
|
-
|
|
516
|
-
|
|
769
|
+
{
|
|
770
|
+
let prev = this.#committed > 0 ? this.#lineCache[this.#committed - 1] : null;
|
|
771
|
+
for (const cell of this.#cells.slice(this.#committed)) {
|
|
772
|
+
const rows = cellComponent(cell).render(W, ctx);
|
|
773
|
+
liveLines.push(...bodySpacing(prev, rows));
|
|
774
|
+
prev = rows;
|
|
775
|
+
}
|
|
517
776
|
}
|
|
518
777
|
}
|
|
519
778
|
// 4. the geometry — the live region's first row:
|
|
@@ -539,14 +798,142 @@ export class Body {
|
|
|
539
798
|
/** Commit the cell at index i: render + cache its lines (immutable —
|
|
540
799
|
* the force-committed form freezes at the current render), advance
|
|
541
800
|
* the bookkeeping — and collect the lines for this frame's writes.
|
|
542
|
-
* Pure accounting + the write list; the BYTES emit in the frame.
|
|
801
|
+
* Pure accounting + the write list; the BYTES emit in the frame.
|
|
802
|
+
* W11: the formula's blank above the cell (when one belongs) rides
|
|
803
|
+
* this cell's commit — the cache stays raw, the placed rows count. */
|
|
543
804
|
#commitCell(i, W, ctx) {
|
|
544
805
|
const cell = this.#cells[i];
|
|
545
|
-
const lines =
|
|
806
|
+
const lines = this.#foldOrRollup(cell, i, W, ctx);
|
|
807
|
+
// W15: a tool cell whose last committed row carried the "ctrl+r"
|
|
808
|
+
// affordance (the renderer cut "└ … ctrl+r") joins the expand
|
|
809
|
+
// history — the detection is the renderer's OWN output, so the
|
|
810
|
+
// read's "/last"-only cut note never lands here.
|
|
811
|
+
// unshift: the cells commit oldest-first, so the NEWEST cut lands
|
|
812
|
+
// at the front — the expand pointer's "newest back" walk starts
|
|
813
|
+
// where the user's last key press would aim.
|
|
814
|
+
if (cell.kind === "tool" && /└ .*ctrl\+r/.test(lines[lines.length - 1] ?? ""))
|
|
815
|
+
this.#collapsed.unshift(i);
|
|
546
816
|
this.#lineCache[i] = lines;
|
|
817
|
+
const placed = bodySpacing(i > 0 ? this.#lineCache[i - 1] : null, lines);
|
|
547
818
|
this.#committed += 1;
|
|
548
|
-
this.#committedLines +=
|
|
549
|
-
this.#committedLinesThisFrame.push(...
|
|
819
|
+
this.#committedLines += placed.length;
|
|
820
|
+
this.#committedLinesThisFrame.push(...placed);
|
|
821
|
+
}
|
|
822
|
+
/** W14 — the fold-hold: a thinking/tool cell of the OPEN quiet turn
|
|
823
|
+
* (no text yet) does not commit — its committed form is decided at
|
|
824
|
+
* the release. The cell's OWN turn must be the CURRENT one (a cell
|
|
825
|
+
* of a released turn commits normally). The force-commit path never
|
|
826
|
+
* consults this — the screen's hard cap wins over the hold. */
|
|
827
|
+
#held(i) {
|
|
828
|
+
const cell = this.#cells[i];
|
|
829
|
+
if (cell.kind !== "thinking" && cell.kind !== "tool")
|
|
830
|
+
return false;
|
|
831
|
+
const turn = cell.turn >= 0 ? this.#turns[cell.turn] : undefined;
|
|
832
|
+
if (turn === undefined || turn !== this.#turns[this.#turns.length - 1])
|
|
833
|
+
return false;
|
|
834
|
+
return !turn.ended && !turn.hasText;
|
|
835
|
+
}
|
|
836
|
+
/** W14/W13 — the release-time decision at a commit, BEFORE the cell's
|
|
837
|
+
* own render: the folded-turn fold first (a QUIET turn — ended, no
|
|
838
|
+
* text — becomes the ONE fold line; the rest of its thinking/tool
|
|
839
|
+
* cells render [] after the fold), then the W13 rollup (a text
|
|
840
|
+
* turn's N > 2 same-tool run: the HEAD renders the group summary,
|
|
841
|
+
* the members render [] — the scan is the work order's "group key",
|
|
842
|
+
* derived at commit time, never pre-stored). */
|
|
843
|
+
#foldOrRollup(cell, i, W, ctx) {
|
|
844
|
+
if (cell.kind === "thinking" || cell.kind === "tool") {
|
|
845
|
+
const turn = cell.turn >= 0 ? this.#turns[cell.turn] : undefined;
|
|
846
|
+
if (turn !== undefined && turn.ended && !turn.hasText) {
|
|
847
|
+
if (!turn.folded) {
|
|
848
|
+
turn.folded = true;
|
|
849
|
+
return turnFold({
|
|
850
|
+
thoughtSeconds: turn.thoughtSeconds,
|
|
851
|
+
reads: turn.reads,
|
|
852
|
+
edits: turn.edits,
|
|
853
|
+
others: [...turn.others],
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
return [];
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
if (cell.kind !== "tool" || ROLLUP_NOUN[cell.name] === undefined)
|
|
860
|
+
return cellComponent(cell).render(W, ctx);
|
|
861
|
+
// the maximal same-name run around i — forward/backward scans over
|
|
862
|
+
// the cells. The turn-less noise cells (the permission raws, the ⚠
|
|
863
|
+
// notices) are TRANSPARENT: the streaming execution (loop.ts launch)
|
|
864
|
+
// interleaves them BETWEEN the calls of one burst, so the run must
|
|
865
|
+
// see through them. It never crosses a user/text/thinking cell —
|
|
866
|
+
// those separate turns and contexts.
|
|
867
|
+
let s = i;
|
|
868
|
+
let head = i;
|
|
869
|
+
while (s > 0) {
|
|
870
|
+
const prev = this.#cells[s - 1];
|
|
871
|
+
if (prev.kind === "raw" || prev.kind === "notice") {
|
|
872
|
+
s -= 1;
|
|
873
|
+
continue;
|
|
874
|
+
}
|
|
875
|
+
if (prev.kind !== "tool" || prev.name !== cell.name)
|
|
876
|
+
break;
|
|
877
|
+
s -= 1;
|
|
878
|
+
head = s; // a same-name tool precedes — it is the group's head
|
|
879
|
+
}
|
|
880
|
+
let e = i;
|
|
881
|
+
while (e + 1 < this.#cells.length) {
|
|
882
|
+
const next = this.#cells[e + 1];
|
|
883
|
+
if (next.kind === "raw" || next.kind === "notice") {
|
|
884
|
+
e += 1;
|
|
885
|
+
continue;
|
|
886
|
+
}
|
|
887
|
+
if (next.kind !== "tool" || next.name !== cell.name)
|
|
888
|
+
break;
|
|
889
|
+
e += 1;
|
|
890
|
+
}
|
|
891
|
+
// the run counts the TOOL cells only — the span's raws are noise.
|
|
892
|
+
const members = this.#cells.slice(s, e + 1).filter((c) => c.kind === "tool");
|
|
893
|
+
if (members.length <= 2)
|
|
894
|
+
return cellComponent(cell).render(W, ctx);
|
|
895
|
+
if (head === i) {
|
|
896
|
+
// the HEAD — the rollup only when EVERY member is done (at the
|
|
897
|
+
// text's release they are — the natural loop commits the run in
|
|
898
|
+
// one frame; the force-commit's early commits degrade to the
|
|
899
|
+
// individual rows, the members render normally after).
|
|
900
|
+
if (!members.every((c) => c.done))
|
|
901
|
+
return cellComponent(cell).render(W, ctx);
|
|
902
|
+
this.#rolledHeads.add(head);
|
|
903
|
+
let total = 0;
|
|
904
|
+
const targets = [];
|
|
905
|
+
for (const m of members) {
|
|
906
|
+
// the lines count, excluding the tool's OWN truncation note
|
|
907
|
+
// (read_file's "… N more lines") — the per-cell meta's rule
|
|
908
|
+
const noteAt = m.resultText.lastIndexOf("\n… ");
|
|
909
|
+
const shown = noteAt >= 0 ? m.resultText.slice(0, noteAt) : m.resultText;
|
|
910
|
+
const parts = shown.split("\n");
|
|
911
|
+
total += parts[parts.length - 1] === "" ? parts.length - 1 : parts.length;
|
|
912
|
+
let input = {};
|
|
913
|
+
try {
|
|
914
|
+
input = JSON.parse(m.inputFull);
|
|
915
|
+
}
|
|
916
|
+
catch {
|
|
917
|
+
// the full JSON is always parseable (stringified at
|
|
918
|
+
// toolStart) — the empty fallback never fires
|
|
919
|
+
}
|
|
920
|
+
const target = toolTarget(m.name, input);
|
|
921
|
+
targets.push(target.split("/").pop() ?? target);
|
|
922
|
+
}
|
|
923
|
+
const first = members[0];
|
|
924
|
+
const last = members[members.length - 1];
|
|
925
|
+
const elapsed = first.startedAt !== null && last.doneAt !== null ? ((last.doneAt - first.startedAt) / 1000).toFixed(1) : "?";
|
|
926
|
+
cell.rolled = { count: members.length, lines: total, elapsed, targets };
|
|
927
|
+
return cellComponent(cell).render(W, ctx);
|
|
928
|
+
}
|
|
929
|
+
// a MEMBER of an already-rolled run → [] (its rows live in the
|
|
930
|
+
// head's summary). A member of a run whose head committed
|
|
931
|
+
// INDIVIDUALLY (the force-commit's degradation) renders normally —
|
|
932
|
+
// the head is not in #rolledHeads, the run never rolls after the
|
|
933
|
+
// head's individual commit.
|
|
934
|
+
if (this.#rolledHeads.has(head))
|
|
935
|
+
return [];
|
|
936
|
+
return cellComponent(cell).render(W, ctx);
|
|
550
937
|
}
|
|
551
938
|
/** The slot occupant's extra rows — the slash-command menu (above the
|
|
552
939
|
* status, in the rhythm gap + the content's spare rows — the old
|
|
@@ -570,7 +957,14 @@ export class Body {
|
|
|
570
957
|
* cursor's display column WITHIN THE ROW (the brick/question lead
|
|
571
958
|
* included), the question/editor/menu variants. The compositor
|
|
572
959
|
* strips the marker and moves LEFT by the trailing width — the
|
|
573
|
-
* cursor derives from the frame, never from side-channel math.
|
|
960
|
+
* cursor derives from the frame, never from side-channel math.
|
|
961
|
+
*
|
|
962
|
+
* W6: the row lives INSIDE the box — the walls are a prefix/suffix
|
|
963
|
+
* width only, composed AFTER the marker embed (the marker math is
|
|
964
|
+
* untouched; the marker's row column = the wall + the lead + the
|
|
965
|
+
* cursor). The content caps at W−4 (the walls' columns) and the
|
|
966
|
+
* pad completes the row to EXACTLY W — invariant ① throws on
|
|
967
|
+
* overflow, so the box row is built full-width, never truncated. */
|
|
574
968
|
#inputRow(W, _ctx) {
|
|
575
969
|
const st = this.#inputState();
|
|
576
970
|
let row;
|
|
@@ -588,8 +982,8 @@ export class Body {
|
|
|
588
982
|
const leadW = visibleWidth(row.slice(0, row.length - st.line.length));
|
|
589
983
|
// embed the marker at the cursor's display column
|
|
590
984
|
let markerLine = "";
|
|
985
|
+
let w = 0;
|
|
591
986
|
{
|
|
592
|
-
let w = 0;
|
|
593
987
|
let inserted = false;
|
|
594
988
|
let i = 0;
|
|
595
989
|
while (i < row.length) {
|
|
@@ -606,6 +1000,8 @@ export class Body {
|
|
|
606
1000
|
inserted = true;
|
|
607
1001
|
}
|
|
608
1002
|
const cw = displayWidth(row[i]);
|
|
1003
|
+
if (w + cw > W - 4)
|
|
1004
|
+
break; // the cap — the two walls' columns
|
|
609
1005
|
markerLine += row[i];
|
|
610
1006
|
w += cw;
|
|
611
1007
|
i += 1;
|
|
@@ -614,8 +1010,24 @@ export class Body {
|
|
|
614
1010
|
markerLine += CURSOR_MARKER;
|
|
615
1011
|
}
|
|
616
1012
|
}
|
|
617
|
-
const
|
|
618
|
-
const
|
|
1013
|
+
const stripped0 = markerLine.replace(CURSOR_MARKER, "");
|
|
1014
|
+
const tailW = visibleWidth(markerLine.slice(markerLine.indexOf(CURSOR_MARKER) + CURSOR_MARKER.length));
|
|
1015
|
+
if (W < 4) {
|
|
1016
|
+
// the degenerate screen: the box cannot hold its walls — the
|
|
1017
|
+
// bare row (the pre-W6 bytes; the fold probe's pass-through
|
|
1018
|
+
// line still crashes invariant ① downstream, as before)
|
|
1019
|
+
return { stripped: stripped0, afterW: tailW };
|
|
1020
|
+
}
|
|
1021
|
+
// the pad completes the row to W — the content stopped at W−4,
|
|
1022
|
+
// so the pad is ≥ 1
|
|
1023
|
+
const padW = W - 3 - w;
|
|
1024
|
+
const stripped = `\x1b[2m│ \x1b[0m${stripped0}\x1b[2m${" ".repeat(padW)}│\x1b[0m`;
|
|
1025
|
+
// the LEFT move = the width AFTER the cursor-rest cell — the
|
|
1026
|
+
// full-width row parks the terminal cursor at the LAST cell (not
|
|
1027
|
+
// one past the end), so the rest cell IS the first tail cell and
|
|
1028
|
+
// the move = the tail + the pad (the right wall rides inside the
|
|
1029
|
+
// pad's tail)
|
|
1030
|
+
const afterW = tailW + padW;
|
|
619
1031
|
return { stripped, afterW };
|
|
620
1032
|
}
|
|
621
1033
|
/** The full-redraw path (the first frame, the resize repaint) — CUP
|
|
@@ -633,20 +1045,24 @@ export class Body {
|
|
|
633
1045
|
const committed = this.#committedLinesThisFrame;
|
|
634
1046
|
// 0. the FROZEN rows — the re-folded committed content (re-flowed
|
|
635
1047
|
// at the new width by the terminal): re-painted at [1..frozen],
|
|
636
|
-
// so the reflow's shifted copies can never ghost.
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
1048
|
+
// so the reflow's shifted copies can never ghost. W11: the
|
|
1049
|
+
// formula's blanks between the cells are re-inserted here — the
|
|
1050
|
+
// cache holds each cell's OWN rows; a missing blank would paint
|
|
1051
|
+
// every row below it one row too high (the V6-1 idempotence
|
|
1052
|
+
// rule: this draw must reproduce the screen, row by row).
|
|
1053
|
+
// The bound is the CELL count at the frame's start — the force
|
|
1054
|
+
// commit's placed LINES can exceed the cell count, and the old
|
|
1055
|
+
// lines-bound went negative, skipping every previously-committed
|
|
1056
|
+
// cell (the V6-1 frozen-loop finding — the banner vanished).
|
|
1057
|
+
const frozen = [];
|
|
1058
|
+
for (let i = 0; i < this.#committedAtFrameStart; i += 1) {
|
|
1059
|
+
frozen.push(...bodySpacing(i > 0 ? this.#lineCache[i - 1] : null, this.#lineCache[i]));
|
|
642
1060
|
}
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
// 2. the live lines.
|
|
649
|
-
for (const line of liveLines) {
|
|
1061
|
+
let r = 1;
|
|
1062
|
+
// one march — the frozen, the committed (this frame's), the live —
|
|
1063
|
+
// in write order, r monotone (three byte-identical loops merged for
|
|
1064
|
+
// the gate; the exact write sequence preserved)
|
|
1065
|
+
for (const line of [...frozen, ...committed, ...liveLines]) {
|
|
650
1066
|
out.push(`\x1b[${r};1H\x1b[0K${this.#checked(line, W)}`);
|
|
651
1067
|
r += 1;
|
|
652
1068
|
}
|
|
@@ -658,13 +1074,13 @@ export class Body {
|
|
|
658
1074
|
for (let i = 0; i < menuRows.length; i += 1) {
|
|
659
1075
|
out.push(`\x1b[${menuTop + i};1H\x1b[0K${this.#checked(menuRows[i], W)}`);
|
|
660
1076
|
}
|
|
661
|
-
// V6-3: the design §03 chrome —
|
|
662
|
-
//
|
|
663
|
-
out.push(`\x1b[${H - 3};1H\x1b[0K${
|
|
1077
|
+
// V6-3 + W6: the design §03 chrome — box top (H−3), input
|
|
1078
|
+
// (H−2), box bottom (H−1), status (H) — the box's four rows.
|
|
1079
|
+
out.push(`\x1b[${H - 3};1H\x1b[0K${boxTop(W)}`);
|
|
664
1080
|
const editor = this.#inputRow(W, ctx);
|
|
665
1081
|
out.push(`\x1b[${H - 2};1H\x1b[0K${this.#checked(editor.stripped, W)}`);
|
|
666
|
-
out.push(`\x1b[${H - 1};1H\x1b[0K${
|
|
667
|
-
out.push(`\x1b[${H};1H\x1b[0K${this.#checked(statusLine(this.#status, this.#tail, this.#question !== null, W), W)}`);
|
|
1082
|
+
out.push(`\x1b[${H - 1};1H\x1b[0K${boxBottom(W)}`);
|
|
1083
|
+
out.push(`\x1b[${H};1H\x1b[0K${this.#checked(statusLine(this.#status, this.#tail, this.#question !== null, W, this.#statusHint ?? undefined), W)}`);
|
|
668
1084
|
// the cursor: up two (the input row at H−2) + left to the marker
|
|
669
1085
|
out.push("\x1b[2A");
|
|
670
1086
|
if (editor.afterW > 0)
|
|
@@ -687,12 +1103,13 @@ export class Body {
|
|
|
687
1103
|
out.push("\x1b[2B");
|
|
688
1104
|
for (let i = 0; i < committed.length; i += 1)
|
|
689
1105
|
out.push("\n");
|
|
690
|
-
// the bottom-up repaint, from the last row up — V6-3: the
|
|
691
|
-
// §03 chrome: status (H),
|
|
692
|
-
|
|
693
|
-
out.push(`\x1b[
|
|
1106
|
+
// the bottom-up repaint, from the last row up — V6-3 + W6: the
|
|
1107
|
+
// design §03 chrome: status (H), box bottom (H−1), input (H−2),
|
|
1108
|
+
// box top (H−3)
|
|
1109
|
+
out.push(`\x1b[1G\x1b[0K${this.#checked(statusLine(this.#status, this.#tail, this.#question !== null, W, this.#statusHint ?? undefined), W)}`); // H — the status
|
|
1110
|
+
out.push(`\x1b[1A\x1b[1G\x1b[0K${boxBottom(W)}`); // H−1 — the box bottom
|
|
694
1111
|
out.push(`\x1b[1A\x1b[1G\x1b[0K${this.#checked(editor.stripped, W)}`); // H−2 — the input
|
|
695
|
-
out.push(`\x1b[1A\x1b[1G\x1b[0K${
|
|
1112
|
+
out.push(`\x1b[1A\x1b[1G\x1b[0K${boxTop(W)}`); // H−3 — the box top
|
|
696
1113
|
for (let i = menuRows.length - 1; i >= 0; i -= 1) {
|
|
697
1114
|
out.push(`\x1b[1A\x1b[1G\x1b[0K${this.#checked(menuRows[i], W)}`);
|
|
698
1115
|
}
|
|
@@ -735,7 +1152,7 @@ export class Body {
|
|
|
735
1152
|
// the down-distance from the LAST written row, in byte order: the
|
|
736
1153
|
// committed band's bottom, then the stale ELs' bottom, then the gap
|
|
737
1154
|
// ELs' bottom, then the live lines' bottom, then the menu's top
|
|
738
|
-
// (its last marched row), else the chrome's
|
|
1155
|
+
// (its last marched row), else the chrome's box top.
|
|
739
1156
|
const lastRow = committed.length > 0
|
|
740
1157
|
? Math.max(1, liveTop - 1)
|
|
741
1158
|
: staleFrom < liveTop
|
|
@@ -806,8 +1223,8 @@ export class Dock {
|
|
|
806
1223
|
onResize() {
|
|
807
1224
|
compositorRef?.onResize();
|
|
808
1225
|
}
|
|
809
|
-
setStatus(text) {
|
|
810
|
-
compositorRef?.setStatus(text);
|
|
1226
|
+
setStatus(text, hint) {
|
|
1227
|
+
compositorRef?.setStatus(text, hint ?? null);
|
|
811
1228
|
}
|
|
812
1229
|
setTail(tail) {
|
|
813
1230
|
compositorRef?.setTail(tail);
|