@vincemakes/kiso-tui 0.20.1 → 0.20.3

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.
@@ -227,6 +227,22 @@ export declare class Body {
227
227
  * there is no pointer, so there is nothing a pointer could reach
228
228
  * that a keyboard cannot. */
229
229
  viewerKey(cmd: "up" | "down" | "toggle" | "all" | "pageUp" | "pageDown" | "home" | "end"): void;
230
+ /**
231
+ * DC-35 — ctrl+r does not print the same expansion twice in a row.
232
+ *
233
+ * The ring walks newest-back and restarts its cycle once every entry
234
+ * has been opened (R4/C1, which is what makes the walk immune to the
235
+ * ring growing underneath it). With a ring of ONE the restart is
236
+ * immediate, so holding the key appended the identical block over
237
+ * and over — the owner got three copies of the same four rows, each
238
+ * closing with `ctrl+r opens the one before it`, a footer naming
239
+ * something that does not exist.
240
+ *
241
+ * The bar is the BOTTOM of the transcript, not "ever shown": once
242
+ * other content has arrived the expansion has scrolled up and
243
+ * re-opening it is the point of the key, so the guard clears itself
244
+ * the moment a cell is added.
245
+ */
230
246
  expandNext(): {
231
247
  kind: "toggled";
232
248
  } | {
@@ -234,6 +250,7 @@ export declare class Body {
234
250
  lines: string[];
235
251
  } | {
236
252
  kind: "none";
253
+ why?: "already-last";
237
254
  };
238
255
  /** Docked = the chrome is live (a color TTY with a real size). */
239
256
  get active(): boolean;
@@ -292,6 +292,20 @@ export class Body {
292
292
  #needsReset = false;
293
293
  #resizeTimer = null;
294
294
  #lastH = 0;
295
+ /** DC-34 — did THIS FRAME refold the committed cells?
296
+ *
297
+ * It must be reset where the question is asked, not only where it
298
+ * is answered. Armed once and consumed later, it latched: the
299
+ * session's FIRST frame ran a vacuous refold over zero committed
300
+ * cells and set it, and nothing cleared it until the first resize —
301
+ * so every session's first widen still ran the adopt it was
302
+ * supposed to skip, and swallowed the live band's worth of
303
+ * committed rows. Three paragraphs, in the measurement that found
304
+ * it. */
305
+ #refolded = false;
306
+ /** DC-34 — the previous frame's width; the reach-back guard is for a
307
+ * WIDTH change, which re-indexes the model, not a height change. */
308
+ #lastW = 0;
295
309
  // KC1 §6: the composer's recorded extent — the row count the last
296
310
  // frame drew (exit's clear walks it) and the row its CHA parked the
297
311
  // cursor on (the steady frame's relative anchor). N = 1 reproduces
@@ -1336,7 +1350,42 @@ export class Body {
1336
1350
  cutLine(`${p.dim} ${viewerHint(this.#viewer, entries)}${p.reset}`, W),
1337
1351
  ];
1338
1352
  }
1353
+ /** DC-35 — the last block this key appended, and the cell count when
1354
+ * it did. An expansion earns its rows by showing something the
1355
+ * transcript does not already END with. */
1356
+ #lastAppend = null;
1357
+ /**
1358
+ * DC-35 — ctrl+r does not print the same expansion twice in a row.
1359
+ *
1360
+ * The ring walks newest-back and restarts its cycle once every entry
1361
+ * has been opened (R4/C1, which is what makes the walk immune to the
1362
+ * ring growing underneath it). With a ring of ONE the restart is
1363
+ * immediate, so holding the key appended the identical block over
1364
+ * and over — the owner got three copies of the same four rows, each
1365
+ * closing with `ctrl+r opens the one before it`, a footer naming
1366
+ * something that does not exist.
1367
+ *
1368
+ * The bar is the BOTTOM of the transcript, not "ever shown": once
1369
+ * other content has arrived the expansion has scrolled up and
1370
+ * re-opening it is the point of the key, so the guard clears itself
1371
+ * the moment a cell is added.
1372
+ */
1339
1373
  expandNext() {
1374
+ const out = this.#expandNextRaw();
1375
+ if (out.kind !== "appended")
1376
+ return out;
1377
+ const lines = out.lines.join("\n");
1378
+ if (this.#lastAppend !== null && this.#lastAppend.lines === lines && this.#lastAppend.atCells === this.#cells.length) {
1379
+ // NOT the same answer as "nothing is folded". The caller says
1380
+ // which, because a reader who pressed the key deserves to know
1381
+ // whether there is nothing to open or whether they are already
1382
+ // looking at it.
1383
+ return { kind: "none", why: "already-last" };
1384
+ }
1385
+ this.#lastAppend = { lines, atCells: this.#cells.length };
1386
+ return out;
1387
+ }
1388
+ #expandNextRaw() {
1340
1389
  for (let i = this.#cells.length - 1; i >= this.#committed; i -= 1) {
1341
1390
  const cell = this.#cells[i];
1342
1391
  if (cell.kind === "tool" && cell.state !== "pending") {
@@ -2408,15 +2457,86 @@ export class Body {
2408
2457
  // the NEW width, so the cached folds are stale. Re-fold the
2409
2458
  // committed cells so the every-row draw below re-paints them at the
2410
2459
  // current geometry — the frame's model and the screen agree.
2460
+ // DC-34 — A WIDEN DOES NOT REFOLD WHAT IS ALREADY COMMITTED.
2461
+ //
2462
+ // Every count here is physical ROWS at the fold width in force
2463
+ // when it was computed. Refolding the committed cells at a new W
2464
+ // changes what every index MEANS while `#scrolledOff` is carried
2465
+ // across untranslated — and no translation exists, because the
2466
+ // row the scroll stopped at does not occur in the new fold. On a
2467
+ // widen the stale count then points at text the terminal already
2468
+ // holds, and the frame paints it a second time.
2469
+ //
2470
+ // A committed row is ink (ADR-0046): the rows still on screen are
2471
+ // the same thing as the rows in the scrollback minus a scroll
2472
+ // that has not happened, and no terminal reflows either. Leaving
2473
+ // them folded as they were printed keeps every index valid.
2474
+ //
2475
+ // NARROWING still refolds — an old wide row does not FIT, and
2476
+ // `#checked` would throw invariant ①. The comparison is against
2477
+ // the CACHE's fold width, not the last render's: after 60 → 100
2478
+ // (no refold, the cache is still 60) a narrowing to 80 must NOT
2479
+ // refold, because 80 columns hold a 60-column row.
2480
+ this.#refolded = false;
2411
2481
  if (this.#fullRedraw) {
2412
- this.#lineCache = this.#lineCache.map(() => null);
2482
+ // DC-34 THE REFOLD IS SCOPED BY THE FRONTIER.
2483
+ //
2484
+ // `#scrolledOff` is the record of what reached the terminal:
2485
+ // rows [0, #scrolledOff) are in its scrollback, immutable, and
2486
+ // no path of ours may contradict them. A cell with any row
2487
+ // down there keeps the fold it was COMMITTED at, forever — in
2488
+ // either direction. A cell entirely above the frontier has
2489
+ // never left the screen, so re-folding it is free.
2490
+ //
2491
+ // Two scalar predicates were tried before this and both
2492
+ // failed, in different ways: the last-refold width crashed on
2493
+ // 60 → 100 → 80 (a cell committed at 100 emitted into an
2494
+ // 80-column screen), and the cache's widest fold fires a FULL
2495
+ // refold at the first narrowing, which re-wraps rows the
2496
+ // scrollback already holds — the original defect, alive in
2497
+ // the other direction. The frontier is not an approximation
2498
+ // of them; it is the question they were both approximating.
2499
+ // A cell is refolded when EITHER is true:
2500
+ // - it is entirely above the frontier (never left the
2501
+ // screen, so re-wrapping it contradicts nothing), or
2502
+ // - it does not FIT: some cached row is wider than W.
2503
+ //
2504
+ // The second is not a compromise of the first, it is the
2505
+ // answer to a question the first cannot reach. A cell can
2506
+ // STRADDLE the frontier — its head in the scrollback, its
2507
+ // tail still on screen — and the tail must be painted at the
2508
+ // current width. Holding its commit fold there emitted a
2509
+ // 100-column row into an 80-column screen and invariant ①
2510
+ // threw (60 → 100 → 80, measured). Fitting wins: a crash is
2511
+ // worse than a seam, and the seam a narrowing leaves is
2512
+ // rider 2's, stated rather than hidden.
2513
+ let row = 0;
2514
+ const refold = new Array(this.#committed).fill(false);
2515
+ for (let i = 0; i < this.#committed; i += 1) {
2516
+ const lines = this.#lineCache[i];
2517
+ if (lines === null || lines === undefined) {
2518
+ refold[i] = true;
2519
+ continue;
2520
+ }
2521
+ const above = row >= this.#scrolledOff;
2522
+ const fits = lines.every((l) => visibleWidth(l) <= W);
2523
+ refold[i] = above || !fits;
2524
+ const prev = i > 0 ? this.#lineCache[i - 1] : null;
2525
+ row += this.#space(i, prev ?? null, lines).length;
2526
+ }
2527
+ for (let i = 0; i < this.#committed; i += 1) {
2528
+ if (refold[i])
2529
+ this.#lineCache[i] = cellComponent(this.#cells[i]).render(W, ctx);
2530
+ }
2531
+ // #committedLines is re-derived over the WHOLE cache, because
2532
+ // the frozen prefix still occupies its own rows.
2413
2533
  this.#committedLines = 0;
2414
2534
  for (let i = 0; i < this.#committed; i += 1) {
2415
- const cell = this.#cells[i];
2416
- const lines = cellComponent(cell).render(W, ctx);
2417
- this.#lineCache[i] = lines; // the cell's OWN rows the cache stays raw
2418
- this.#committedLines += this.#space(i, i > 0 ? this.#lineCache[i - 1] : null, lines).length;
2535
+ const lines = this.#lineCache[i] ?? cellComponent(this.#cells[i]).render(W, ctx);
2536
+ this.#lineCache[i] = lines;
2537
+ this.#committedLines += this.#space(i, i > 0 ? (this.#lineCache[i - 1] ?? []) : null, lines).length;
2419
2538
  }
2539
+ this.#refolded = refold.some(Boolean);
2420
2540
  }
2421
2541
  // 1. the natural commits — the leading DONE cells freeze: their
2422
2542
  // lines leave the live region, the scrolls + the committed
@@ -3542,7 +3662,25 @@ export class Body {
3542
3662
  // discarded because a reflow invalidates every row of it: the
3543
3663
  // next diff repaints the whole screen, which is exactly what a
3544
3664
  // resize needs.
3545
- this.#scrolledOff = Math.max(this.#scrolledOff, Math.max(0, Math.min(skip, all.length)));
3665
+ // DC-34 NO HIGH-WATER MARK ON A RESIZE.
3666
+ //
3667
+ // This was `max(#scrolledOff, …)`, which held a stale count
3668
+ // whenever a widen made the fresh one smaller; `leaving` then
3669
+ // stayed <= 0 and the text that marched past in the meantime
3670
+ // never entered the scrollback at all — the hole, the other
3671
+ // half of the same off-by-a-refold.
3672
+ //
3673
+ // The other implementation in this space reached the same
3674
+ // conclusion independently and says so in its own source: a
3675
+ // historical high-water mark "caused self-reinforcing
3676
+ // inflation that pushed content into scrollback on terminal
3677
+ // widen". Dropping it alone brings the DUPLICATE back — it is
3678
+ // the pair with the no-refold rule above, not a substitute
3679
+ // for it.
3680
+ // PROBE 3: a widen leaves it ALONE; a narrow keeps REL-0152-R1.
3681
+ if (this.#refolded)
3682
+ this.#scrolledOff = Math.max(this.#scrolledOff, Math.max(0, Math.min(skip, all.length)));
3683
+ this.#refolded = false;
3546
3684
  this.#screen = new Array(H).fill(NOT_PAINTED);
3547
3685
  this.#resizeFrame = false;
3548
3686
  }
@@ -3562,7 +3700,26 @@ export class Body {
3562
3700
  // OLDEST on screen — they are still in the model and come back on
3563
3701
  // the close.
3564
3702
  const contentRows = Math.max(0, H - CHROME_ROWS - inputExtra - queueRows.length - menuRows.length);
3565
- const march = all.slice(skip);
3703
+ // DC-34 THE MARCH NEVER REACHES BELOW THE FRONTIER.
3704
+ //
3705
+ // Rows [0, #scrolledOff) are in the terminal's scrollback and are
3706
+ // immutable; painting one puts the same prose on screen twice,
3707
+ // which is the owner's report. `skip` can drop below it whenever
3708
+ // the model shrinks under a fixed screen — a widen refolding the
3709
+ // cells above the frontier, or the live band collapsing — and
3710
+ // nothing stopped it (rider 3's ungated reach-back).
3711
+ //
3712
+ // Clamping costs a gap under short content for one frame, which
3713
+ // the next commit fills. Reaching back costs a duplicate that
3714
+ // stands in the transcript forever.
3715
+ // ...but only when the WIDTH moved. A height change re-indexes
3716
+ // nothing — the folds are untouched, every row means what it
3717
+ // meant — so reaching back there is the pre-existing behaviour a
3718
+ // gate already covers (the A8 windowing case: grow the screen and
3719
+ // the banner returns). The duplication measured in this round is
3720
+ // width-driven, and so is the guard.
3721
+ const march = all.slice(this.#lastW !== 0 && this.#lastW !== W ? Math.max(skip, this.#scrolledOff) : skip);
3722
+ this.#lastW = W;
3566
3723
  for (const line of march.length > contentRows ? march.slice(march.length - contentRows) : march) {
3567
3724
  desired[r - 1] = this.#checked(line, W);
3568
3725
  r += 1;
package/dist/editor.js CHANGED
@@ -1004,7 +1004,11 @@ export class Editor {
1004
1004
  i += 1;
1005
1005
  continue;
1006
1006
  }
1007
- if (!typing && (c === "t" || c === "T")) {
1007
+ // DC-36: no `t` row means no custom phase to enter — the
1008
+ // option list IS the world (a closed set), and a key
1009
+ // that leads to a surface the panel does not draw is
1010
+ // worse than an absent key.
1011
+ if (!typing && (c === "t" || c === "T") && this.#panel?.view.pick?.typeHint !== undefined) {
1008
1012
  panel.pick = { cursor: panel.pick.cursor, phase: "custom" };
1009
1013
  this.#chars = [];
1010
1014
  this.#cursor = 0;
package/dist/index.d.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * editor, the diff renderer, and the palette.
8
8
  */
9
9
  export { Body, Dock, CURSOR_MARKER, type BodyOptions } from "./compositor.js";
10
- export { panelAffordance, panelBlockRows, panelLead, panelLeadPlain, panelLeadWidth, panelStatus, PICK_MAX, modelPickView, pickAffordance, pickBlockRows, pickLeadPlain, type PickOption, type PickResult, type PickRuntime, type PickSpec, type PanelArgs, type PanelFlavor, type PanelPhase, deletionRiskHint, SAFER_BACK, SAFER_DEGRADED, SAFER_DEGRADED_TRUNCATED, saferDegradedNote, type SaferAnswer, type SaferFailure, type SaferOption, type SaferRuntime, panelOptions, type PanelOption, type PanelOptionKind, type PanelState, type PanelVerdict, type PanelView, } from "./approval-panel.js";
10
+ export { panelAffordance, panelBlockRows, panelLead, panelLeadPlain, panelLeadWidth, panelStatus, PICK_MAX, modePickView, modelPickView, pickAffordance, pickBlockRows, pickLeadPlain, type PickOption, type PickResult, type PickRuntime, type PickSpec, type PanelArgs, type PanelFlavor, type PanelPhase, deletionRiskHint, SAFER_BACK, SAFER_DEGRADED, SAFER_DEGRADED_TRUNCATED, saferDegradedNote, type SaferAnswer, type SaferFailure, type SaferOption, type SaferRuntime, panelOptions, type PanelOption, type PanelOptionKind, type PanelState, type PanelVerdict, type PanelView, } from "./approval-panel.js";
11
11
  export { Container, foldLine, foldWords, visibleWidth, SPINNER, type Component, type FrameCtx } from "./components.js";
12
12
  export { Editor, MENU_ITEMS, PROMPT, PROMPT_WIDTH, displayWidth, charWidth, widthOf, type MenuItem, } from "./editor.js";
13
13
  export { bannerLines, COLOR_OFF, COLOR_ON, currentGround, setGround, escapeTerminal, foldResult, foldThinking, kUnit, palette, renderEvent, renderRecap, renderResumeList, renderSessionLine, renderStatusLine, relativeTime, renderTerminalGap, renderToolSummary, TAGLINE, toolTarget, truncateRow, type Palette, type PathResolver, type RecapStats, type ResumeMeta, type RenderInput, type RenderResult, type RunUsage, } from "./render.js";
package/dist/index.js CHANGED
@@ -12,7 +12,7 @@ export { Body, Dock, CURSOR_MARKER } from "./compositor.js";
12
12
  // approval is pending (the shape authority is the committed preview).
13
13
  export { panelAffordance, panelBlockRows, panelLead, panelLeadPlain, panelLeadWidth, panelStatus,
14
14
  // TUI2-R2 ④: the pick payload — the panel slot's third occupant.
15
- PICK_MAX, modelPickView, pickAffordance, pickBlockRows, pickLeadPlain, deletionRiskHint, SAFER_BACK, SAFER_DEGRADED, SAFER_DEGRADED_TRUNCATED, saferDegradedNote, panelOptions, } from "./approval-panel.js";
15
+ PICK_MAX, modePickView, modelPickView, pickAffordance, pickBlockRows, pickLeadPlain, deletionRiskHint, SAFER_BACK, SAFER_DEGRADED, SAFER_DEGRADED_TRUNCATED, saferDegradedNote, panelOptions, } from "./approval-panel.js";
16
16
  export { Container, foldLine, foldWords, visibleWidth, SPINNER } from "./components.js";
17
17
  export { Editor, MENU_ITEMS, PROMPT, PROMPT_WIDTH, displayWidth, charWidth, widthOf, } from "./editor.js";
18
18
  export { bannerLines, COLOR_OFF, COLOR_ON, currentGround, setGround, escapeTerminal, foldResult, foldThinking, kUnit, palette, renderEvent, renderRecap, renderResumeList, renderSessionLine, renderStatusLine, relativeTime, renderTerminalGap, renderToolSummary, TAGLINE, toolTarget, truncateRow, } from "./render.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-tui",
3
- "version": "0.20.1",
3
+ "version": "0.20.3",
4
4
  "description": "kiso tui — the pure terminal layer (cell renderer, dock, raw editor, diff, palette). Zero runtime dependencies: input is data, output is bytes.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -35,6 +35,6 @@
35
35
  },
36
36
  "homepage": "https://github.com/vincemakes/kiso/tree/main/packages/tui#readme",
37
37
  "dependencies": {
38
- "@vincemakes/kiso-tui-cells": "0.20.1"
38
+ "@vincemakes/kiso-tui-cells": "0.20.3"
39
39
  }
40
40
  }