@vincemakes/kiso-tui 0.1.37 → 0.1.39
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 +87 -4
- package/dist/components.js +240 -48
- package/dist/compositor.d.ts +41 -2
- package/dist/compositor.js +374 -26
- package/dist/editor.d.ts +1 -0
- package/dist/editor.js +16 -1
- package/dist/render.d.ts +14 -3
- package/dist/render.js +35 -1
- 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, bodySpacing, cellComponent, foldLine,
|
|
46
|
-
import { bannerLines, 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 task block only
|
|
56
|
+
* redraws when the items actually changed (the task extension's
|
|
57
|
+
* idempotent shape — an unchanged replace is a no-op, no frame). */
|
|
58
|
+
function sameTask(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,6 +81,19 @@ 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)
|
|
@@ -119,7 +139,10 @@ export class Body {
|
|
|
119
139
|
}
|
|
120
140
|
this.#closeOpenThinking();
|
|
121
141
|
this.#closeOpenText();
|
|
122
|
-
|
|
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 });
|
|
123
146
|
this.#mark();
|
|
124
147
|
}
|
|
125
148
|
thinkingAppend(text) {
|
|
@@ -132,7 +155,7 @@ export class Body {
|
|
|
132
155
|
last.text += text;
|
|
133
156
|
}
|
|
134
157
|
else {
|
|
135
|
-
this.#cells.push({ kind: "thinking", text, done: false });
|
|
158
|
+
this.#cells.push({ kind: "thinking", text, done: false, turn: this.#turns.length - 1 });
|
|
136
159
|
}
|
|
137
160
|
this.#mark();
|
|
138
161
|
}
|
|
@@ -166,7 +189,19 @@ export class Body {
|
|
|
166
189
|
}
|
|
167
190
|
}
|
|
168
191
|
this.#toolCells.set(callId, this.#cells.length);
|
|
169
|
-
this.#cells.push({ kind: "tool", name, input: summary, childRoles, 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
|
+
}
|
|
170
205
|
this.#mark();
|
|
171
206
|
}
|
|
172
207
|
toolApproval(callId, diff) {
|
|
@@ -213,8 +248,11 @@ export class Body {
|
|
|
213
248
|
this.#pendingCalls.delete(callId);
|
|
214
249
|
}
|
|
215
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).
|
|
216
254
|
const p = palette();
|
|
217
|
-
this.#write(`${renderToolSummary(call?.name ?? "?", call?.input ?? {}, result)}\n` +
|
|
255
|
+
this.#write(`${renderToolSummary(call?.name ?? "?", call?.input ?? {}, result, result.reason ?? null)}\n` +
|
|
218
256
|
`${p.dim}${result.isError ? p.red : p.dim} [result${result.isError ? " ✗" : ""}] ${foldResult(result.content)}${p.reset}\n`);
|
|
219
257
|
return;
|
|
220
258
|
}
|
|
@@ -224,6 +262,7 @@ export class Body {
|
|
|
224
262
|
cell.state = "done";
|
|
225
263
|
cell.isError = result.isError;
|
|
226
264
|
cell.resultText = result.content;
|
|
265
|
+
cell.reason = result.reason ?? null;
|
|
227
266
|
cell.doneAt = Date.now();
|
|
228
267
|
cell.done = true;
|
|
229
268
|
}
|
|
@@ -236,6 +275,12 @@ export class Body {
|
|
|
236
275
|
this.#write(escapeTerminal(text));
|
|
237
276
|
return;
|
|
238
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;
|
|
239
284
|
const last = this.#cells[this.#cells.length - 1];
|
|
240
285
|
if (last !== undefined && last.kind === "text" && !last.done) {
|
|
241
286
|
last.text += text;
|
|
@@ -257,6 +302,51 @@ export class Body {
|
|
|
257
302
|
last.done = true;
|
|
258
303
|
this.#mark();
|
|
259
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 task block settles HERE — the ONE recap
|
|
322
|
+
// block for the turn ("`task 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
|
+
}
|
|
260
350
|
terminal(label, statusLineText) {
|
|
261
351
|
if (!this.#isActive()) {
|
|
262
352
|
this.#closeOpenThinking();
|
|
@@ -281,6 +371,17 @@ export class Body {
|
|
|
281
371
|
this.#cells.push({ kind: "notice", text, done: true });
|
|
282
372
|
this.#mark();
|
|
283
373
|
}
|
|
374
|
+
/** W20 — the task 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 task 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). */
|
|
284
385
|
checklist(header, items) {
|
|
285
386
|
if (!this.#isActive()) {
|
|
286
387
|
this.#closeOpenThinking();
|
|
@@ -294,7 +395,25 @@ export class Body {
|
|
|
294
395
|
}
|
|
295
396
|
this.#closeOpenThinking();
|
|
296
397
|
this.#closeOpenText();
|
|
297
|
-
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 (!sameTask(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
|
+
});
|
|
298
417
|
this.#mark();
|
|
299
418
|
}
|
|
300
419
|
/** The startup banner (W1): a LIVE cell — the tier re-derives per
|
|
@@ -342,6 +461,77 @@ export class Body {
|
|
|
342
461
|
lastTool() {
|
|
343
462
|
return this.#lastTool;
|
|
344
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 task 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
|
+
}
|
|
345
535
|
// ---- the Dock façade (the CLI's chrome API — same shape as the old dock) ----
|
|
346
536
|
/** Docked = the chrome is live (a color TTY with a real size). */
|
|
347
537
|
get active() {
|
|
@@ -431,7 +621,8 @@ export class Body {
|
|
|
431
621
|
* reads it — the marker math never desyncs by construction). */
|
|
432
622
|
editCol() {
|
|
433
623
|
const inp = this.#inputState();
|
|
434
|
-
|
|
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;
|
|
435
626
|
}
|
|
436
627
|
/** The old dock's redraw — the editor's onRender target: mark + the
|
|
437
628
|
* scheduler (16ms coalescing — the old sync draw coalesces the same). */
|
|
@@ -546,7 +737,13 @@ export class Body {
|
|
|
546
737
|
// emit exactly once).
|
|
547
738
|
this.#committedAtFrameStart = this.#committed;
|
|
548
739
|
this.#committedLinesThisFrame = [];
|
|
549
|
-
|
|
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)) {
|
|
550
747
|
this.#commitCell(this.#committed, W, ctx);
|
|
551
748
|
}
|
|
552
749
|
// 2. the live lines — the unfinished cells (the tail) + the chrome.
|
|
@@ -606,13 +803,138 @@ export class Body {
|
|
|
606
803
|
* this cell's commit — the cache stays raw, the placed rows count. */
|
|
607
804
|
#commitCell(i, W, ctx) {
|
|
608
805
|
const cell = this.#cells[i];
|
|
609
|
-
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);
|
|
610
816
|
this.#lineCache[i] = lines;
|
|
611
817
|
const placed = bodySpacing(i > 0 ? this.#lineCache[i - 1] : null, lines);
|
|
612
818
|
this.#committed += 1;
|
|
613
819
|
this.#committedLines += placed.length;
|
|
614
820
|
this.#committedLinesThisFrame.push(...placed);
|
|
615
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);
|
|
937
|
+
}
|
|
616
938
|
/** The slot occupant's extra rows — the slash-command menu (above the
|
|
617
939
|
* status, in the rhythm gap + the content's spare rows — the old
|
|
618
940
|
* menu's position, slot-shaped). */
|
|
@@ -635,7 +957,14 @@ export class Body {
|
|
|
635
957
|
* cursor's display column WITHIN THE ROW (the brick/question lead
|
|
636
958
|
* included), the question/editor/menu variants. The compositor
|
|
637
959
|
* strips the marker and moves LEFT by the trailing width — the
|
|
638
|
-
* 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. */
|
|
639
968
|
#inputRow(W, _ctx) {
|
|
640
969
|
const st = this.#inputState();
|
|
641
970
|
let row;
|
|
@@ -653,8 +982,8 @@ export class Body {
|
|
|
653
982
|
const leadW = visibleWidth(row.slice(0, row.length - st.line.length));
|
|
654
983
|
// embed the marker at the cursor's display column
|
|
655
984
|
let markerLine = "";
|
|
985
|
+
let w = 0;
|
|
656
986
|
{
|
|
657
|
-
let w = 0;
|
|
658
987
|
let inserted = false;
|
|
659
988
|
let i = 0;
|
|
660
989
|
while (i < row.length) {
|
|
@@ -671,6 +1000,8 @@ export class Body {
|
|
|
671
1000
|
inserted = true;
|
|
672
1001
|
}
|
|
673
1002
|
const cw = displayWidth(row[i]);
|
|
1003
|
+
if (w + cw > W - 4)
|
|
1004
|
+
break; // the cap — the two walls' columns
|
|
674
1005
|
markerLine += row[i];
|
|
675
1006
|
w += cw;
|
|
676
1007
|
i += 1;
|
|
@@ -679,8 +1010,24 @@ export class Body {
|
|
|
679
1010
|
markerLine += CURSOR_MARKER;
|
|
680
1011
|
}
|
|
681
1012
|
}
|
|
682
|
-
const
|
|
683
|
-
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;
|
|
684
1031
|
return { stripped, afterW };
|
|
685
1032
|
}
|
|
686
1033
|
/** The full-redraw path (the first frame, the resize repaint) — CUP
|
|
@@ -727,12 +1074,12 @@ export class Body {
|
|
|
727
1074
|
for (let i = 0; i < menuRows.length; i += 1) {
|
|
728
1075
|
out.push(`\x1b[${menuTop + i};1H\x1b[0K${this.#checked(menuRows[i], W)}`);
|
|
729
1076
|
}
|
|
730
|
-
// V6-3: the design §03 chrome —
|
|
731
|
-
//
|
|
732
|
-
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)}`);
|
|
733
1080
|
const editor = this.#inputRow(W, ctx);
|
|
734
1081
|
out.push(`\x1b[${H - 2};1H\x1b[0K${this.#checked(editor.stripped, W)}`);
|
|
735
|
-
out.push(`\x1b[${H - 1};1H\x1b[0K${
|
|
1082
|
+
out.push(`\x1b[${H - 1};1H\x1b[0K${boxBottom(W)}`);
|
|
736
1083
|
out.push(`\x1b[${H};1H\x1b[0K${this.#checked(statusLine(this.#status, this.#tail, this.#question !== null, W, this.#statusHint ?? undefined), W)}`);
|
|
737
1084
|
// the cursor: up two (the input row at H−2) + left to the marker
|
|
738
1085
|
out.push("\x1b[2A");
|
|
@@ -756,12 +1103,13 @@ export class Body {
|
|
|
756
1103
|
out.push("\x1b[2B");
|
|
757
1104
|
for (let i = 0; i < committed.length; i += 1)
|
|
758
1105
|
out.push("\n");
|
|
759
|
-
// the bottom-up repaint, from the last row up — V6-3: the
|
|
760
|
-
// §03 chrome: status (H),
|
|
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)
|
|
761
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
|
|
762
|
-
out.push(`\x1b[1A\x1b[1G\x1b[0K${
|
|
1110
|
+
out.push(`\x1b[1A\x1b[1G\x1b[0K${boxBottom(W)}`); // H−1 — the box bottom
|
|
763
1111
|
out.push(`\x1b[1A\x1b[1G\x1b[0K${this.#checked(editor.stripped, W)}`); // H−2 — the input
|
|
764
|
-
out.push(`\x1b[1A\x1b[1G\x1b[0K${
|
|
1112
|
+
out.push(`\x1b[1A\x1b[1G\x1b[0K${boxTop(W)}`); // H−3 — the box top
|
|
765
1113
|
for (let i = menuRows.length - 1; i >= 0; i -= 1) {
|
|
766
1114
|
out.push(`\x1b[1A\x1b[1G\x1b[0K${this.#checked(menuRows[i], W)}`);
|
|
767
1115
|
}
|
|
@@ -804,7 +1152,7 @@ export class Body {
|
|
|
804
1152
|
// the down-distance from the LAST written row, in byte order: the
|
|
805
1153
|
// committed band's bottom, then the stale ELs' bottom, then the gap
|
|
806
1154
|
// ELs' bottom, then the live lines' bottom, then the menu's top
|
|
807
|
-
// (its last marched row), else the chrome's
|
|
1155
|
+
// (its last marched row), else the chrome's box top.
|
|
808
1156
|
const lastRow = committed.length > 0
|
|
809
1157
|
? Math.max(1, liveTop - 1)
|
|
810
1158
|
: staleFrom < liveTop
|
package/dist/editor.d.ts
CHANGED
|
@@ -41,6 +41,7 @@ export declare class Editor {
|
|
|
41
41
|
onSigint(cb: () => void): void;
|
|
42
42
|
onEot(cb: () => void): void;
|
|
43
43
|
onEscape(cb: () => void): void;
|
|
44
|
+
onExpand(cb: () => void): void;
|
|
44
45
|
/** The whole buffer as text (the CLI's line()/clearLine()). */
|
|
45
46
|
line(): string;
|
|
46
47
|
clearLine(): void;
|
package/dist/editor.js
CHANGED
|
@@ -56,6 +56,11 @@ export class Editor {
|
|
|
56
56
|
// (dispatch) coexist; a listener removes itself via an unarmed guard
|
|
57
57
|
// (the compact's handler no-ops after its abort has fired).
|
|
58
58
|
#escapeCbs = [];
|
|
59
|
+
// W15: the expand-key list (ctrl+r) — the CLI's dispatch decides the
|
|
60
|
+
// target (a live cell toggles in place; a committed cell appends the
|
|
61
|
+
// expanded block). Mirrors the escape list: multiple listeners can
|
|
62
|
+
// coexist; the editor never interprets the key itself.
|
|
63
|
+
#expandCbs = [];
|
|
59
64
|
#onRender;
|
|
60
65
|
#menuOpen = false; // v3 §04: the slash-command menu
|
|
61
66
|
#menuSel = 0;
|
|
@@ -95,6 +100,9 @@ export class Editor {
|
|
|
95
100
|
onEscape(cb) {
|
|
96
101
|
this.#escapeCbs.push(cb);
|
|
97
102
|
}
|
|
103
|
+
onExpand(cb) {
|
|
104
|
+
this.#expandCbs.push(cb);
|
|
105
|
+
}
|
|
98
106
|
/** The whole buffer as text (the CLI's line()/clearLine()). */
|
|
99
107
|
line() {
|
|
100
108
|
return String.fromCodePoint(...this.#chars);
|
|
@@ -272,6 +280,13 @@ export class Editor {
|
|
|
272
280
|
}
|
|
273
281
|
i += 1;
|
|
274
282
|
}
|
|
283
|
+
else if (c === "\x12") {
|
|
284
|
+
// W15: the expand key (ctrl+r) — rides the chain like a
|
|
285
|
+
// command, the editor just forwards it.
|
|
286
|
+
for (const cb of [...this.#expandCbs])
|
|
287
|
+
cb();
|
|
288
|
+
i += 1;
|
|
289
|
+
}
|
|
275
290
|
else if (c !== undefined && c < " ") {
|
|
276
291
|
i += 1; // other control — ignored
|
|
277
292
|
}
|
|
@@ -455,7 +470,7 @@ export class Editor {
|
|
|
455
470
|
// ---- width-based horizontal scroll ----
|
|
456
471
|
#reflow() {
|
|
457
472
|
const W = (process.stdout.columns ?? 0) || 80; // degenerate 0 falls back to 80
|
|
458
|
-
const maxW = Math.max(1, W - PROMPT_WIDTH -
|
|
473
|
+
const maxW = Math.max(1, W - PROMPT_WIDTH - 4); // W6: the box's walls (2+2) — the visible line fits the box's inner width; the "…" rides inside
|
|
459
474
|
const curCol = widthOf(this.#chars.slice(0, this.#cursor));
|
|
460
475
|
const scrolledW = widthOf(this.#chars.slice(0, this.#scroll));
|
|
461
476
|
if (curCol < scrolledW) {
|
package/dist/render.d.ts
CHANGED
|
@@ -11,8 +11,9 @@
|
|
|
11
11
|
* v2a — the palette, centralized (no hard-coded codes elsewhere); v5
|
|
12
12
|
* (TUI v5 #16e, the v4.1 design): the decorative blue (38;5;75) is
|
|
13
13
|
* RETIRED — the identity accents (the you> prompt, the banner tagline,
|
|
14
|
-
* ✓ marks, slash-command names, the
|
|
15
|
-
*
|
|
14
|
+
* ✓ marks, slash-command names, the input brick) are bright-white BOLD
|
|
15
|
+
* (SGR 1); the user message is the SGR-7 chip (the 2026-08-09 ruling
|
|
16
|
+
* retired the ▍ rail); `code` is the content semantic tint for
|
|
16
17
|
* inline code spans in assistant text (256-color 110 — the cube color
|
|
17
18
|
* nearest the design's #8fb4d8); red for errors, dim for metadata,
|
|
18
19
|
* green for the diff additions. NO_COLOR set, or a non-TTY output →
|
|
@@ -177,7 +178,12 @@ export declare function renderEvent(ev: RenderInput, prevThinking?: boolean, res
|
|
|
177
178
|
export declare function renderToolSummary(name: string, input: Record<string, unknown>, result: {
|
|
178
179
|
content: string;
|
|
179
180
|
isError: boolean;
|
|
180
|
-
}): string;
|
|
181
|
+
}, reason?: string | null): string;
|
|
182
|
+
/** W15 — the expand header's target: the tool call's subject (the path
|
|
183
|
+
* for the *_file tools, the command for shell) — the same extraction
|
|
184
|
+
* the summary detail uses, WITHOUT the counts (the header names what
|
|
185
|
+
* was expanded, not its size). */
|
|
186
|
+
export declare function toolTarget(name: string, input: Record<string, unknown>): string;
|
|
181
187
|
/** k-units for the status line: 12345 → 12.3k, 800 → 800, null → ?. */
|
|
182
188
|
export declare function kUnit(value: number | null): string;
|
|
183
189
|
/** B area: usage data gathered from the run's usage events. */
|
|
@@ -242,6 +248,11 @@ export interface RecapStats {
|
|
|
242
248
|
readonly edits: number;
|
|
243
249
|
readonly usage: RunUsage;
|
|
244
250
|
readonly ctxLeftPct: number | null;
|
|
251
|
+
/** W19 — the mode the turn ran under. Under "plan" the recap becomes
|
|
252
|
+
* the way-forward row (the claimed shape): a plan turn's currency is
|
|
253
|
+
* the plan, not the tool count — the timing and tool-count parts
|
|
254
|
+
* drop, and the two /mode hints replace them. */
|
|
255
|
+
readonly mode?: string;
|
|
245
256
|
}
|
|
246
257
|
export declare function renderRecap(s: RecapStats): string;
|
|
247
258
|
/** One-line summary of a session, for `kiso sessions`. */
|