@vincemakes/kiso-tui 0.1.29 → 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/body.d.ts +11 -6
- package/dist/body.js +83 -47
- 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/dock.js +9 -3
- package/dist/editor.js +3 -3
- package/dist/index.d.ts +5 -4
- package/dist/index.js +5 -4
- package/dist/render.d.ts +9 -9
- package/dist/render.js +11 -11
- package/package.json +1 -1
package/dist/body.d.ts
CHANGED
|
@@ -93,12 +93,17 @@ export declare class Body {
|
|
|
93
93
|
/** Teardown — flush a pending frame, stop the timers. */
|
|
94
94
|
close(): void;
|
|
95
95
|
/**
|
|
96
|
-
*
|
|
97
|
-
* rows
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
96
|
+
* #17 (P1): a resize reflows the terminal's buffer — the old chrome
|
|
97
|
+
* rows SURVIVE the reflow at their shifted positions (the recorded
|
|
98
|
+
* separator wall + the tail ghost — the #16a assumption that the
|
|
99
|
+
* reflow erases them was wrong). The handler: (1) clear the old tail +
|
|
100
|
+
* dock area with the OLD geometry (one ED from the last-drawn tail
|
|
101
|
+
* top; EL/ED only, zero LF — the #16 storm gate's invariants hold);
|
|
102
|
+
* (2) redraw immediately at the NEW geometry (the tail, the cursor
|
|
103
|
+
* home, the dock — via the normal render). The frozen content is
|
|
104
|
+
* strictly ABOVE the old tail top — the clear never touches it (the
|
|
105
|
+
* frozen bytes stay emitted exactly once). Consecutive resizes are
|
|
106
|
+
* idempotent: the clear covers an already-clear area.
|
|
102
107
|
*/
|
|
103
108
|
onResize(): void;
|
|
104
109
|
/** The last COMPLETE thinking block, for /think. */
|
package/dist/body.js
CHANGED
|
@@ -36,8 +36,9 @@ export class Body {
|
|
|
36
36
|
#opts;
|
|
37
37
|
#cells = [];
|
|
38
38
|
#nextFrozen = 0; // index of the first not-yet-printed cell
|
|
39
|
-
#
|
|
40
|
-
#oldTailTop = 0; // the tail
|
|
39
|
+
#height = 0; // the last DRAWN height — the resize handler clears with the OLD geometry
|
|
40
|
+
#oldTailTop = 0; // the last-drawn tail top — the clear pass + the resize handler's clear
|
|
41
|
+
#oldTailHeight = 0; // the last-drawn tail's height — the clear covers EXACTLY it (never the frozen area)
|
|
41
42
|
#frameTimer = null;
|
|
42
43
|
#heartbeat = null;
|
|
43
44
|
#dirty = false;
|
|
@@ -53,14 +54,13 @@ export class Body {
|
|
|
53
54
|
this.#opts = opts;
|
|
54
55
|
this.#write = opts.write ?? ((s) => process.stdout.write(s));
|
|
55
56
|
this.#active = opts.active();
|
|
57
|
+
this.#height = opts.height();
|
|
56
58
|
if (this.#isActive()) {
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
// CUP rows — the overwrite garbage after a drag (the #16
|
|
63
|
-
// defect). The dock redraws its own chrome on the same event.
|
|
59
|
+
// #17 (P1): the resize handler — clear the OLD tail + dock area
|
|
60
|
+
// (old geometry, EL/ED only — no LF), then redraw at the new
|
|
61
|
+
// geometry. The #16a assumption is retired: the terminal's
|
|
62
|
+
// reflow does NOT erase the old chrome rows (the recorded
|
|
63
|
+
// separator wall + the tail ghost prove it) — the clear does.
|
|
64
64
|
this.#resizeHandler = () => this.onResize();
|
|
65
65
|
process.stdout.on("resize", this.#resizeHandler);
|
|
66
66
|
this.#heartbeat = setInterval(() => {
|
|
@@ -104,23 +104,31 @@ export class Body {
|
|
|
104
104
|
this.render();
|
|
105
105
|
}
|
|
106
106
|
/**
|
|
107
|
-
*
|
|
108
|
-
* rows
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
107
|
+
* #17 (P1): a resize reflows the terminal's buffer — the old chrome
|
|
108
|
+
* rows SURVIVE the reflow at their shifted positions (the recorded
|
|
109
|
+
* separator wall + the tail ghost — the #16a assumption that the
|
|
110
|
+
* reflow erases them was wrong). The handler: (1) clear the old tail +
|
|
111
|
+
* dock area with the OLD geometry (one ED from the last-drawn tail
|
|
112
|
+
* top; EL/ED only, zero LF — the #16 storm gate's invariants hold);
|
|
113
|
+
* (2) redraw immediately at the NEW geometry (the tail, the cursor
|
|
114
|
+
* home, the dock — via the normal render). The frozen content is
|
|
115
|
+
* strictly ABOVE the old tail top — the clear never touches it (the
|
|
116
|
+
* frozen bytes stay emitted exactly once). Consecutive resizes are
|
|
117
|
+
* idempotent: the clear covers an already-clear area.
|
|
113
118
|
*/
|
|
114
119
|
onResize() {
|
|
115
120
|
if (!this.#isActive())
|
|
116
121
|
return;
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
|
|
122
|
+
const from = this.#oldTailTop > 0 ? this.#oldTailTop : Math.max(1, this.#height - 3);
|
|
123
|
+
const H = this.#opts.height(); // the NEW height — the clamp for a shrunk screen
|
|
124
|
+
const out = [];
|
|
125
|
+
// The cursor home + the ED land with the NEW geometry — rows beyond
|
|
126
|
+
// the old screen are already gone; rows below the old tail top are
|
|
127
|
+
// exactly the tail + the dock areas (the old tail, the old chrome).
|
|
128
|
+
out.push(`\x1b[${Math.min(from, Math.max(1, H))};1H\x1b[0J`);
|
|
129
|
+
this.#write(out.join(""));
|
|
130
|
+
this.#dirty = true; // the immediate redraw at the NEW geometry
|
|
131
|
+
this.render();
|
|
124
132
|
}
|
|
125
133
|
/** The last COMPLETE thinking block, for /think. */
|
|
126
134
|
lastThinking() {
|
|
@@ -364,45 +372,63 @@ export class Body {
|
|
|
364
372
|
const W = this.#opts.width();
|
|
365
373
|
if (H < 4)
|
|
366
374
|
return;
|
|
375
|
+
this.#height = H;
|
|
367
376
|
const out = [];
|
|
368
|
-
// #13 (P1), v2d-B: NO DECSTBM —
|
|
369
|
-
// the screen's last row
|
|
370
|
-
// NATIVE scrollback
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
//
|
|
375
|
-
//
|
|
377
|
+
// #13 (P1), v2d-B: NO DECSTBM — a frozen line scrolls with a REAL
|
|
378
|
+
// LF at the screen's last row (\x1b[H;1H\n — the top line leaves
|
|
379
|
+
// into the NATIVE scrollback) and lands at the body's bottom row,
|
|
380
|
+
// just above the active tail. The dock is redrawn after.
|
|
381
|
+
// #17 (P1): the pre-fill phase is GONE — EVERY frozen line takes
|
|
382
|
+
// this REAL-LF path, short sessions included. The old pre-fill drew
|
|
383
|
+
// at absolute CUP rows with NO real LF: the terminal's resize
|
|
384
|
+
// reflow treated those rows as soft lines — the recorded fold/body
|
|
385
|
+
// MERGE (the /think suffix lost), the tail ghost, the separator
|
|
386
|
+
// wall. A real-LF line reflows as ONE logical line, never merged
|
|
387
|
+
// with a neighbor; the only soft lines left are the active tail +
|
|
388
|
+
// the dock (small, redrawn every frame / cleared by onResize).
|
|
376
389
|
// The tail (the remaining ACTIVE cells) and its geometry — computed
|
|
377
390
|
// FIRST from the final nextFrozen, so the frozen cells are NOT in it
|
|
378
391
|
// (a stale tail would re-draw them — the double-render).
|
|
379
392
|
let nextFrozen = this.#nextFrozen;
|
|
380
393
|
while (nextFrozen < this.#cells.length && this.#cells[nextFrozen].done)
|
|
381
394
|
nextFrozen += 1;
|
|
395
|
+
// #17: the tail holds ONLY the unfinished cells — slice(nextFrozen)
|
|
396
|
+
// also captures the DONE cells that follow (an approval verdict
|
|
397
|
+
// freezing behind a live text+tool), inflating tailHeight so the
|
|
398
|
+
// tail's top rises INTO the frozen area and its clear pass wipes
|
|
399
|
+
// the freshly-frozen lines (the fold's /think suffix — recorded).
|
|
382
400
|
const tail = this.#cells.slice(nextFrozen);
|
|
383
|
-
|
|
401
|
+
// #17: the tail HEIGHT counts only the unfinished cells — slice()
|
|
402
|
+
// also captures the DONE cells that follow (an approval verdict
|
|
403
|
+
// freezing behind a live text+tool); counting them inflates the
|
|
404
|
+
// height so the tail's top rises INTO the frozen area and its
|
|
405
|
+
// clear pass wipes the freshly-frozen lines (the fold's /think
|
|
406
|
+
// suffix — recorded). The tail array itself keeps the old shape.
|
|
407
|
+
const tailHeight = tail.reduce((n, c) => n + (c.done ? 0 : this.#cellHeight(c, W)), 0);
|
|
384
408
|
const tailTop = Math.max(1, H - 3 - tailHeight); // v3 §03: 4 dock rows below
|
|
385
409
|
const writeRow = Math.max(1, tailTop - 1); // the frozen area's bottom row
|
|
386
410
|
let scrolled = 0;
|
|
387
411
|
for (let i = this.#nextFrozen; i < nextFrozen; i += 1) {
|
|
388
412
|
for (const line of this.#cellLines(this.#cells[i], W)) {
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
}
|
|
393
|
-
else {
|
|
394
|
-
out.push(`\x1b[${H};1H\n`); // the REAL LF — the whole screen scrolls
|
|
395
|
-
out.push(`\x1b[${writeRow};1H\x1b[0K${line}`);
|
|
396
|
-
scrolled += 1;
|
|
397
|
-
}
|
|
413
|
+
out.push(`\x1b[${H};1H\n`); // the REAL LF — the whole screen scrolls
|
|
414
|
+
out.push(`\x1b[${writeRow};1H\x1b[0K${line}`);
|
|
415
|
+
scrolled += 1;
|
|
398
416
|
}
|
|
399
417
|
this.#nextFrozen += 1;
|
|
400
418
|
}
|
|
401
|
-
// 2. the active tail — clear its old area (shifted up by the
|
|
402
|
-
// scrolls) and the current area, draw the cells at the body's
|
|
419
|
+
// 2. the active tail — clear EXACTLY its old area (shifted up by the
|
|
420
|
+
// freeze scrolls) and the current area, draw the cells at the body's
|
|
421
|
+
// bottom. #17: the old code cleared clearFrom..H-4 unconditionally —
|
|
422
|
+
// harmless when the frozen lines sat at the TOP (pre-fill), but with
|
|
423
|
+
// the real-LF commits the frozen lines land just ABOVE the tail, so
|
|
424
|
+
// an over-wide clear wipes the freshly-frozen cells (the recorded
|
|
425
|
+
// fold/response vanishing).
|
|
403
426
|
out.push("\x1b[?2026h");
|
|
427
|
+
const oldBottom = this.#oldTailHeight > 0 ? this.#oldTailTop + this.#oldTailHeight - 1 - scrolled : -1;
|
|
428
|
+
const newBottom = tailHeight > 0 ? tailTop + tailHeight - 1 : -1;
|
|
404
429
|
const clearFrom = Math.min(this.#oldTailTop === 0 ? tailTop : this.#oldTailTop - scrolled, tailTop);
|
|
405
|
-
|
|
430
|
+
const clearTo = Math.max(oldBottom, newBottom);
|
|
431
|
+
for (let row = clearFrom; row <= Math.min(clearTo, H - 4); row += 1) {
|
|
406
432
|
out.push(`\x1b[${row};1H\x1b[0K`);
|
|
407
433
|
}
|
|
408
434
|
let row = tailTop;
|
|
@@ -412,6 +438,8 @@ export class Body {
|
|
|
412
438
|
row += 1;
|
|
413
439
|
}
|
|
414
440
|
}
|
|
441
|
+
this.#oldTailTop = tailTop; // the last-drawn tail top — the resize clear starts here
|
|
442
|
+
this.#oldTailHeight = tailHeight;
|
|
415
443
|
// 3. the cursor home — the input line's edit column.
|
|
416
444
|
out.push(`\x1b[${H};${this.#opts.editCol()}H`);
|
|
417
445
|
out.push("\x1b[?2026l");
|
|
@@ -429,14 +457,22 @@ export class Body {
|
|
|
429
457
|
// rail — a bright-white BOLD ▍ per line, then the text (the
|
|
430
458
|
// reverse-video block is RETIRED: it washed out on light
|
|
431
459
|
// themes). Multi-line whole: every line carries the rail
|
|
432
|
-
// (
|
|
460
|
+
// (coherent across lines); resize-safe; NO_COLOR → the rail renders plain.
|
|
433
461
|
return cell.text.split("\n").map((l) => `${p.bold}▍${p.reset} ${escapeTerminal(l)}`);
|
|
434
462
|
case "thinking": {
|
|
435
463
|
const block = cell.text;
|
|
436
464
|
const trimmed = escapeTerminal(block.trim());
|
|
437
465
|
if (trimmed.length <= 100)
|
|
438
466
|
return [`${p.dim}…${trimmed}${p.reset}`];
|
|
439
|
-
|
|
467
|
+
const suffix = ` (${block.length} chars · /think)`;
|
|
468
|
+
// #17: the fold must FIT its row — every frozen line commits
|
|
469
|
+
// via the REAL-LF scroll path at the SAME write row; a
|
|
470
|
+
// soft-wrapped fold's continuation row would be clobbered by
|
|
471
|
+
// the next line's commit write (the /think suffix lost — the
|
|
472
|
+
// recorded symptom). The slice shrinks with the width; the
|
|
473
|
+
// suffix always rides the fold's own row.
|
|
474
|
+
const slice = Math.max(1, W - 1 - suffix.length);
|
|
475
|
+
return [`${p.dim}…${trimmed.slice(0, slice)}${suffix}${p.reset}`];
|
|
440
476
|
}
|
|
441
477
|
case "tool": {
|
|
442
478
|
const name = escapeTerminal(cell.name);
|
|
@@ -478,7 +514,7 @@ export class Body {
|
|
|
478
514
|
// TUI v5 #16e: the inline-code tint — backtick spans in
|
|
479
515
|
// assistant body text, matched PER LINE after the wrap (a
|
|
480
516
|
// span opened on one line and closed on another does NOT
|
|
481
|
-
// match —
|
|
517
|
+
// match — no cross-line matching). NO_COLOR → the codes are empty →
|
|
482
518
|
// byte-identical.
|
|
483
519
|
return wrapped.length > 0 ? wrapped.map((l) => colorInlineCode(l)) : [""];
|
|
484
520
|
}
|
|
@@ -490,7 +526,7 @@ export class Body {
|
|
|
490
526
|
// SGR is applied at COMPOSITION time (renderRecap/startupBanner),
|
|
491
527
|
// and model/tool content was already escapeTerminal'd there.
|
|
492
528
|
// Re-escaping at render STRIPPED the ESC from the SGR — the
|
|
493
|
-
// literal "[38;5;75m▞[0m" garbage the user saw (the #16
|
|
529
|
+
// literal "[38;5;75m▞[0m" garbage the user saw (the #16 mojibake,
|
|
494
530
|
// also the banner's dim). Verbatim: the injection guard lives
|
|
495
531
|
// at composition, not here.
|
|
496
532
|
return cell.lines;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI v6 (ADR-0046) — the components: EVERY screen line's renderer.
|
|
3
|
+
*
|
|
4
|
+
* Each component turns one piece of state into display lines (SGR
|
|
5
|
+
* included, raw — the compositor writes them verbatim). The folding
|
|
6
|
+
* lives HERE: every line a component returns must fit the terminal
|
|
7
|
+
* width — the compositor's crash-on-violation invariant backs it up
|
|
8
|
+
* (a component that forgets to fold CRASHES with a diagnostic, never
|
|
9
|
+
* silently truncates — pi tui-main-screen.ts:447-473).
|
|
10
|
+
*
|
|
11
|
+
* The fold is SGR-AWARE: a line whose bold/dim span would straddle a
|
|
12
|
+
* fold boundary closes the span at the break and reopens it on the
|
|
13
|
+
* next row — the #16b contract (no literal "[2m" fragments) survives
|
|
14
|
+
* folding. displayWidth/charWidth (editor.ts) are the width primitives
|
|
15
|
+
* (untouched); render.ts supplies the original text (palette, escape,
|
|
16
|
+
* tint, fold wording).
|
|
17
|
+
*/
|
|
18
|
+
import { foldThinking, foldResult, renderToolSummary } from "./render.js";
|
|
19
|
+
/** The spinner glyphs, cycled by the compositor's on-demand tick. */
|
|
20
|
+
export declare const SPINNER: string[];
|
|
21
|
+
/** The frame context the compositor passes down — the pieces of time
|
|
22
|
+
* that make a live render non-deterministic (the running tool's glyph
|
|
23
|
+
* and elapsed). Everything else is a pure function of the cell. */
|
|
24
|
+
export interface FrameCtx {
|
|
25
|
+
readonly spinnerI: number;
|
|
26
|
+
readonly now: number;
|
|
27
|
+
}
|
|
28
|
+
/** ONE screen line a component emits (raw, SGR included). */
|
|
29
|
+
export type RenderLine = string;
|
|
30
|
+
/**
|
|
31
|
+
* The fold — split a display-width line into ≤W rows, preserving SGR
|
|
32
|
+
* spans across the break: a span open at the break closes (reset) at
|
|
33
|
+
* the row's end and reopens on the next row. The rows are what the
|
|
34
|
+
* terminal's own soft-wrap would have produced — except the compositor
|
|
35
|
+
* folds FIRST, so the terminal never reflows a component's line (the
|
|
36
|
+
* #17 merge class cannot reach committed content).
|
|
37
|
+
*/
|
|
38
|
+
export declare function foldLine(line: string, W: number): string[];
|
|
39
|
+
/** The visible width of a rendered line (SGR stripped — the invariant
|
|
40
|
+
* the compositor enforces on every emitted line). */
|
|
41
|
+
export declare function visibleWidth(line: string): number;
|
|
42
|
+
/** A component: render the display lines for one piece of state. */
|
|
43
|
+
export interface Component {
|
|
44
|
+
render(width: number, ctx: FrameCtx): string[];
|
|
45
|
+
}
|
|
46
|
+
/** The container — vertical concatenation of its children. */
|
|
47
|
+
export declare class Container implements Component {
|
|
48
|
+
private readonly children;
|
|
49
|
+
constructor(children: Component[]);
|
|
50
|
+
render(width: number, ctx: FrameCtx): string[];
|
|
51
|
+
}
|
|
52
|
+
export type BodyCell = {
|
|
53
|
+
kind: "user";
|
|
54
|
+
text: string;
|
|
55
|
+
done: true;
|
|
56
|
+
} | {
|
|
57
|
+
kind: "thinking";
|
|
58
|
+
text: string;
|
|
59
|
+
done: boolean;
|
|
60
|
+
} | {
|
|
61
|
+
kind: "tool";
|
|
62
|
+
name: string;
|
|
63
|
+
input: string;
|
|
64
|
+
state: "pending" | "approval" | "running" | "done";
|
|
65
|
+
isError: boolean;
|
|
66
|
+
resultText: string;
|
|
67
|
+
diff: import("./diff.js").DiffLine[] | null;
|
|
68
|
+
added: number;
|
|
69
|
+
removed: number;
|
|
70
|
+
startedAt: number | null;
|
|
71
|
+
doneAt: number | null;
|
|
72
|
+
done: boolean;
|
|
73
|
+
} | {
|
|
74
|
+
kind: "text";
|
|
75
|
+
text: string;
|
|
76
|
+
done: boolean;
|
|
77
|
+
} | {
|
|
78
|
+
kind: "notice";
|
|
79
|
+
text: string;
|
|
80
|
+
done: true;
|
|
81
|
+
} | {
|
|
82
|
+
kind: "raw";
|
|
83
|
+
lines: string[];
|
|
84
|
+
done: true;
|
|
85
|
+
} | {
|
|
86
|
+
kind: "terminal";
|
|
87
|
+
label: string;
|
|
88
|
+
line: string;
|
|
89
|
+
done: true;
|
|
90
|
+
} | {
|
|
91
|
+
kind: "checklist";
|
|
92
|
+
header: string;
|
|
93
|
+
items: {
|
|
94
|
+
text: string;
|
|
95
|
+
status: "pending" | "active" | "done";
|
|
96
|
+
}[];
|
|
97
|
+
done: true;
|
|
98
|
+
};
|
|
99
|
+
declare const TOOL_SUMMARY_MAX = 60;
|
|
100
|
+
/** The component for one cell — the mapping table lives here so the
|
|
101
|
+
* compositor stays a pure writer. */
|
|
102
|
+
export declare function cellComponent(cell: BodyCell): Component;
|
|
103
|
+
/** The status container's row: the status text (+ the tail) with the
|
|
104
|
+
* right-aligned "/ commands · ↑ history" hint in the idle state —
|
|
105
|
+
* the hint CUT FIRST when the width is short (the #16g rule); when
|
|
106
|
+
* the STATUS ITSELF cannot fit, it cuts with a "…" — the last resort,
|
|
107
|
+
* enforced by invariant ① (the old code let the status soft-wrap). */
|
|
108
|
+
export declare function statusLine(status: string, tail: string, question: boolean, W: number): string;
|
|
109
|
+
/** The footer — the ONE dotted row (the old two-row chrome is gone;
|
|
110
|
+
* the wall cannot return by construction). */
|
|
111
|
+
export declare function footerLine(W: number): string;
|
|
112
|
+
/** The terminal label + rhythm gap (the pipe path's v2c bytes — the
|
|
113
|
+
* exact render the passthrough needs). */
|
|
114
|
+
export declare function terminalPipe(label: string, statusLineText: string): string;
|
|
115
|
+
/** The pipe-path pieces the passthrough reuses (byte-identical). */
|
|
116
|
+
export { foldThinking, foldResult, renderToolSummary, TOOL_SUMMARY_MAX };
|