@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 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
- * TUI v4 #16a: a resize reflows the terminal's scrollback — the frozen
97
- * rows' positions are no longer what we tracked (and the frozen CONTENT
98
- * is never re-emitted: the terminal reflowed it, we only redraw the
99
- * dock + active tail). The counters reset so the next render writes new
100
- * frozen lines through the REAL-LF scroll path above the tail, never at
101
- * a stale CUP row (the drag-garbage the #16 user saw).
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
- #frozenRows = 0; // the frozen area's rows filled WITHOUT scrolling (then the real LFs take over)
40
- #oldTailTop = 0; // the tail's previous first row for the clear pass
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
- // TUI v4 #16a: the resize event — the terminal reflows its
58
- // scrollback, so OUR row bookkeeping is stale. The frozen
59
- // content is never re-emitted (the terminal reflowed it); the
60
- // counters reset so the next render lands new frozen lines via
61
- // the REAL-LF scroll path (just above the tail), never at stale
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 handlerclear 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
- * TUI v4 #16a: a resize reflows the terminal's scrollback — the frozen
108
- * rows' positions are no longer what we tracked (and the frozen CONTENT
109
- * is never re-emitted: the terminal reflowed it, we only redraw the
110
- * dock + active tail). The counters reset so the next render writes new
111
- * frozen lines through the REAL-LF scroll path above the tail, never at
112
- * a stale CUP row (the drag-garbage the #16 user saw).
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
- this.#frozenRows = Number.MAX_SAFE_INTEGER; // the frozen area is "full" the next line scrolls
118
- this.#oldTailTop = 0; // the reflowed tail's old rows are gone clear only the new area
119
- // NO #dirty: an immediate frame would re-render the ACTIVE TAIL —
120
- // re-emitting its bytes (the terminal already reflowed the tail's
121
- // content; a re-print duplicates the text in the byte stream the
122
- // #16 storm gate's "response text exactly once"). The geometry
123
- // reset takes effect at the next NATURAL render (the next event).
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 — overflow scrolls with a REAL LF at
369
- // the screen's last row, so the frozen lines enter the terminal's
370
- // NATIVE scrollback deterministically (region-scrolled lines are
371
- // terminal-dependent; some terminals drop them the measured v2d-A
372
- // defect). The body fills from the top without scrolling; once full,
373
- // every new frozen line scrolls the whole screen (\x1b[H;1H\n — the
374
- // top line leaves into the scrollback) and lands at the body's
375
- // bottom row, just above the active tail. The dock is redrawn after.
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
- const tailHeight = tail.reduce((n, c) => n + this.#cellHeight(c, W), 0);
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
- if (this.#frozenRows < writeRow) {
390
- this.#frozenRows += 1;
391
- out.push(`\x1b[${this.#frozenRows};1H\x1b[0K${line}`);
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 freeze
402
- // scrolls) and the current area, draw the cells at the body's bottom.
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
- for (let row = clearFrom; row <= H - 4; row += 1) {
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
- // (多行连贯); resize-safe; NO_COLOR → the rail renders plain.
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
- return [`${p.dim}…${trimmed.slice(0, 100)} (${block.length} chars · /think)${p.reset}`];
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 — 跨行不匹配). NO_COLOR → the codes are empty →
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 };