@vincemakes/kiso-tui 0.14.0 → 0.15.1

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.
@@ -74,6 +74,11 @@ export interface InputState {
74
74
  readonly cursorCol?: number;
75
75
  }
76
76
  export interface BodyOptions {
77
+ /** REL-0150-D1 test seam: overrides the TERM_PROGRAM detection for
78
+ * the conservative frame mode — process.env is SHARED across
79
+ * concurrently-running test files in one worker, so a test that
80
+ * mutated the env would bleed 40ms frames into its neighbors. */
81
+ readonly termProgram?: string;
77
82
  /** Is the cell renderer live? A color TTY with a real size — checked
78
83
  * per mutation (the TIOCSWINSZ can land after main constructs us). */
79
84
  active: () => boolean;
@@ -153,9 +153,24 @@ export class Body {
153
153
  // box top), the live caps shrink by their rows, and the status
154
154
  // row's right hint shows the count while any turn waits.
155
155
  #queueState = () => [];
156
+ /** REL-0150-D1 — the conservative frame mode. Terminal.app does not
157
+ * support DEC 2026 synchronized output (half-frames paint on its own
158
+ * schedule — the reviewer dogfood watched the tearing live) and its
159
+ * renderer is throughput-weak (typed input lagged seconds behind the
160
+ * stream). Where TERM_PROGRAM says Apple_Terminal: the frame wraps
161
+ * in ?25l/?25h (cursor hidden during the repaint — the classic
162
+ * anti-tearing degrade; every frame re-shows it) instead of the dead
163
+ * 2026 bytes, and the coalesce window widens 16→40ms (fewer, bigger
164
+ * frames: fewer tear opportunities, less renderer pressure). Every
165
+ * other terminal keeps today's bytes exactly. Heuristic on purpose —
166
+ * a DECRQM round-trip would race the editor for stdin at boot. */
167
+ #conservative;
168
+ #frameMs;
156
169
  constructor(opts) {
157
170
  this.#opts = opts;
158
171
  this.#write = opts.write ?? ((s) => process.stdout.write(s));
172
+ this.#conservative = (opts.termProgram ?? process.env.TERM_PROGRAM) === "Apple_Terminal";
173
+ this.#frameMs = this.#conservative ? 40 : FRAME_MS;
159
174
  this.#active = opts.active();
160
175
  // v6: the single writer — the compositor IS the dock; the CLI's
161
176
  // onDock callback (which used to re-pin the dock after a scroll)
@@ -472,9 +487,6 @@ export class Body {
472
487
  return;
473
488
  }
474
489
  this.#endMd();
475
- const last = this.#cells[this.#cells.length - 1];
476
- if (last !== undefined && last.kind === "text" && !last.done)
477
- last.done = true;
478
490
  this.#mark();
479
491
  }
480
492
  /** W14 — the turn boundary's END: the CLI calls this at the run's
@@ -941,7 +953,7 @@ export class Body {
941
953
  this.#dirty = false;
942
954
  this.render();
943
955
  }
944
- }, FRAME_MS);
956
+ }, this.#frameMs);
945
957
  this.#frameTimer.unref();
946
958
  }
947
959
  /** The spinner: a ONE-SHOT re-armed ONLY while a running tool exists —
@@ -1168,7 +1180,7 @@ export class Body {
1168
1180
  panelSpan === null ? null : { top: liveTop + panelSpan.offset, count: panelSpan.count, first: panelSpan.first };
1169
1181
  // 5. the frame bytes.
1170
1182
  const out = [];
1171
- out.push("\x1b[?2026h"); // synchronized output ON (DEC 2026)
1183
+ out.push(this.#conservative ? "\x1b[?25l" : "\x1b[?2026h"); // D1: sync ON, or cursor-hide where 2026 is dead bytes
1172
1184
  // A8: the bottom-anchored window (the model's last H rows) shifts
1173
1185
  // DOWN when the live region SHRINKS — the done-fold, the fold-hold
1174
1186
  // release at the terminal event. The steady path's scroll syncs
@@ -1189,7 +1201,7 @@ export class Body {
1189
1201
  else {
1190
1202
  this.#drawSteady(out, W, H, liveTop, liveLines, queueRows, menuRows, editor);
1191
1203
  }
1192
- out.push("\x1b[?2026l");
1204
+ out.push(this.#conservative ? "\x1b[?25h" : "\x1b[?2026l");
1193
1205
  this.#write(out.join(""));
1194
1206
  this.#lastLiveTop = liveTop;
1195
1207
  this.#lastLiveRows = liveRowsTotal;
@@ -1688,40 +1700,67 @@ export class Body {
1688
1700
  // content loss).
1689
1701
  if (skip > 0 && !overlay) {
1690
1702
  const leaving = Math.max(0, skip - this.#lastSkip);
1691
- // A8b (the fresh leaving share): a leaving row whose old-screen
1692
- // copy is stalethe committed-this-frame lines (their old rows
1693
- // held the previous live/chrome) is pre-painted at its OLD row
1694
- // so the LF scroll carries it into the scrollback; the frozen
1695
- // leaving rows are already on screen and scroll as-is. The first
1696
- // overflow frame of a batch: the window's top row is the freshly
1697
- // committed line, NEVER on the old screen without the
1698
- // pre-paint the scroll pushes a blank and the line's only paint
1699
- // (the clamped march at row 1) is overwritten by its neighbor.
1700
- if (skip > frozen.length) {
1701
- const fromIdx = Math.max(0, this.#lastSkip - frozen.length);
1702
- const top = Math.min(skip - frozen.length, all.length - frozen.length);
1703
- for (let i = fromIdx; i < top; i += 1) {
1704
- out.push(`\x1b[${Math.max(1, frozen.length + i - this.#lastSkip + 1)};1H\x1b[0K${this.#checked(all[frozen.length + i], W)}`);
1703
+ // TT-1B (finding TUI2-MD-1): a burst taller than the screen can
1704
+ // NOT be staged in one pass rows placed past H are clamped by
1705
+ // the terminal itself (CUP pins to the last row), the pile keeps
1706
+ // only its last member, and the scroll pushes the wreckage: the
1707
+ // short-terminal row loss the finding measured. The transit is
1708
+ // chunked instead: each pass paints <= H leaving rows at rows
1709
+ // 1..chunk and scrolls exactly that many LFs, so every row is ON
1710
+ // the screen when its scroll slot comes up. The <= H path below
1711
+ // keeps its exact pre-round bytes (its staging never exceeds H
1712
+ // position <= skip - lastSkip = leaving).
1713
+ if (leaving > H) {
1714
+ let p = this.#lastSkip;
1715
+ while (p < skip) {
1716
+ const chunk = Math.min(skip - p, H);
1717
+ for (let k = 0; k < chunk; k += 1) {
1718
+ out.push(`\x1b[${k + 1};1H\x1b[0K${this.#checked(all[p + k], W)}`);
1719
+ }
1720
+ if (chunk < H)
1721
+ out.push(`\x1b[${chunk + 1};1H\x1b[0J`);
1722
+ out.push(`\x1b[${H};1H`);
1723
+ for (let k = 0; k < chunk; k += 1)
1724
+ out.push("\n");
1725
+ p += chunk;
1705
1726
  }
1706
1727
  }
1707
- if (leaving < skip)
1708
- out.push(`\x1b[${leaving + 1};1H\x1b[0J`);
1709
- out.push(`\x1b[${H};1H`);
1710
- // TUI2-R2pre scroll the rows that LEFT THE WINDOW SINCE THE
1711
- // LAST FRAME (`leaving`), never `skip`, which is the window's
1712
- // ABSOLUTE top. Scrolling the absolute top re-pushed the whole
1713
- // history's worth of rows on EVERY full redraw and a live-region
1714
- // shrink takes this path, so that was most frames of a real
1715
- // session. The ED above had just blanked everything below row
1716
- // `leaving`, so what those surplus LFs carried into the terminal's
1717
- // scrollback was blank rows: the large blank bands mid-history of
1718
- // the owner's field report. The SCREEN never showed it because the
1719
- // repaint below covers every row 1..H (the V6-1 rule), and the
1720
- // house emulator drops scrolled rows on the floor so no gate
1721
- // could see it either. Measured on the 5-turn 80x24 repro: the
1722
- // scrollback went from 162 blank rows of 168 to 14 of 52.
1723
- for (let i = 0; i < leaving; i += 1)
1724
- out.push("\n");
1728
+ else {
1729
+ // A8b (the fresh leaving share): a leaving row whose old-screen
1730
+ // copy is stale — the committed-this-frame lines (their old rows
1731
+ // held the previous live/chrome) is pre-painted at its OLD row
1732
+ // so the LF scroll carries it into the scrollback; the frozen
1733
+ // leaving rows are already on screen and scroll as-is. The first
1734
+ // overflow frame of a batch: the window's top row is the freshly
1735
+ // committed line, NEVER on the old screen without the
1736
+ // pre-paint the scroll pushes a blank and the line's only paint
1737
+ // (the clamped march at row 1) is overwritten by its neighbor.
1738
+ if (skip > frozen.length) {
1739
+ const fromIdx = Math.max(0, this.#lastSkip - frozen.length);
1740
+ const top = Math.min(skip - frozen.length, all.length - frozen.length);
1741
+ for (let i = fromIdx; i < top; i += 1) {
1742
+ out.push(`\x1b[${Math.max(1, frozen.length + i - this.#lastSkip + 1)};1H\x1b[0K${this.#checked(all[frozen.length + i], W)}`);
1743
+ }
1744
+ }
1745
+ if (leaving < skip)
1746
+ out.push(`\x1b[${leaving + 1};1H\x1b[0J`);
1747
+ out.push(`\x1b[${H};1H`);
1748
+ // TUI2-R2pre ② — scroll the rows that LEFT THE WINDOW SINCE THE
1749
+ // LAST FRAME (`leaving`), never `skip`, which is the window's
1750
+ // ABSOLUTE top. Scrolling the absolute top re-pushed the whole
1751
+ // history's worth of rows on EVERY full redraw — and a live-region
1752
+ // shrink takes this path, so that was most frames of a real
1753
+ // session. The ED above had just blanked everything below row
1754
+ // `leaving`, so what those surplus LFs carried into the terminal's
1755
+ // scrollback was blank rows: the large blank bands mid-history of
1756
+ // the owner's field report. The SCREEN never showed it because the
1757
+ // repaint below covers every row 1..H (the V6-1 rule), and the
1758
+ // house emulator drops scrolled rows on the floor — so no gate
1759
+ // could see it either. Measured on the 5-turn 80x24 repro: the
1760
+ // scrollback went from 162 blank rows of 168 to 14 of 52.
1761
+ for (let i = 0; i < leaving; i += 1)
1762
+ out.push("\n");
1763
+ }
1725
1764
  }
1726
1765
  if (!overlay)
1727
1766
  this.#lastSkip = skip;
@@ -1788,6 +1827,15 @@ export class Body {
1788
1827
  const overlay = this.#overlayFrame;
1789
1828
  const skip = overlay ? this.#lastSkip : Math.max(0, this.#committedLines + liveLines.length + CHROME_ROWS + inputExtra + queueRows.length + menuRows.length - H);
1790
1829
  const leaving = overlay ? 0 : Math.max(0, skip - this.#lastSkip);
1830
+ // TT-1B (finding TUI2-MD-1): the steady staging has the same
1831
+ // past-H clamp as the full path had — a window that moved more than
1832
+ // a screenful in one frame cannot transit on this path at all.
1833
+ // Delegate the oversized frame to #drawFull's chunked emission (the
1834
+ // full path repaints every row, so the hand-off is self-contained).
1835
+ if (leaving > H) {
1836
+ this.#drawFull(out, W, H, liveTop, liveLines, queueRows, menuRows, editor);
1837
+ return;
1838
+ }
1791
1839
  // the jump to the bottom row H, then N real LFs scroll the screen
1792
1840
  // exactly N rows — ONE per committed line (the bookkeeping; the
1793
1841
  // stale 1B anchor jumped to H−1 and the N LFs scrolled only N−1 —
@@ -1999,9 +2047,6 @@ export class Body {
1999
2047
  * tail block closes and its cell becomes commit-eligible. */
2000
2048
  #closeOpenText() {
2001
2049
  this.#endMd();
2002
- const last = this.#cells[this.#cells.length - 1];
2003
- if (last !== undefined && last.kind === "text" && !last.done)
2004
- last.done = true;
2005
2050
  }
2006
2051
  /** Close an open thinking cell when a new cell starts. */
2007
2052
  #closeOpenThinking() {
package/dist/editor.d.ts CHANGED
@@ -60,6 +60,9 @@ export declare class Editor {
60
60
  onSigint(cb: () => void): void;
61
61
  onEot(cb: () => void): void;
62
62
  onEscape(cb: () => void): void;
63
+ /** R3a — Shift+Tab: the approval-tier cycle. The MEANING lives in the
64
+ * CLI (which tier follows which); the editor only reports the key. */
65
+ onModeCycle(cb: () => void): void;
63
66
  onExpand(cb: () => void): void;
64
67
  /** KC2 §2: the redirect chain — the gesture hands the buffer's text
65
68
  * over while the run is told to stop. Mirrors onEscape (a list, so
@@ -153,4 +156,10 @@ export declare class Editor {
153
156
  count: number;
154
157
  first?: number;
155
158
  } | null) | null): void;
159
+ /** R3a — cross-session input history: seed the recall buffer and
160
+ * register the append sink. The cap and the adjacent-duplicate
161
+ * collapse are unchanged; the seed takes the TAIL of what the CLI
162
+ * loaded. Never persists question answers (#remember's callers
163
+ * already exclude them). */
164
+ bindHistory(seed: readonly string[], persist: (line: string) => void): void;
156
165
  }
package/dist/editor.js CHANGED
@@ -55,6 +55,9 @@ export const MENU_ITEMS = [
55
55
  { name: "/mode", desc: "switch the approval tier (manual/default/accept-edits/plan/bypass)" },
56
56
  { name: "/model", desc: "list model profiles; switch with /model <name|provider/model>" },
57
57
  { name: "/compact", desc: "summarize the older conversation to free context" },
58
+ // the /resume+/clear mini-spec: the session-navigation pair
59
+ { name: "/clear", desc: "start a fresh conversation (the old session stays resumable)" },
60
+ { name: "/resume", desc: "switch to another session; /resume <id> goes directly" },
58
61
  { name: "/think", desc: "show the last full thinking block" },
59
62
  { name: "/last", desc: "show the most recent tool call's input and output" },
60
63
  { name: "/status", desc: "show session id, event count, and context estimate" },
@@ -223,6 +226,12 @@ export class Editor {
223
226
  onEscape(cb) {
224
227
  this.#escapeCbs.push(cb);
225
228
  }
229
+ #onModeCycle = null;
230
+ /** R3a — Shift+Tab: the approval-tier cycle. The MEANING lives in the
231
+ * CLI (which tier follows which); the editor only reports the key. */
232
+ onModeCycle(cb) {
233
+ this.#onModeCycle = cb;
234
+ }
226
235
  onExpand(cb) {
227
236
  this.#expandCbs.push(cb);
228
237
  }
@@ -1144,6 +1153,14 @@ export class Editor {
1144
1153
  this.#mouseEvent(params, final === "M");
1145
1154
  return;
1146
1155
  }
1156
+ // R3a — Shift+Tab (CSI Z, the universal back-tab encoding) cycles
1157
+ // the approval tier. Composer-idle ONLY: a panel, picker, menu,
1158
+ // history browse or question owns its keys first (the W21 gate),
1159
+ // and a mid-word back-tab has no meaning the composer would miss.
1160
+ if (final === "Z" && params === "" && this.#composerIdle()) {
1161
+ this.#onModeCycle?.();
1162
+ return;
1163
+ }
1147
1164
  // KC1 §4 — Shift+Enter WHERE THE TERMINAL ENCODES IT: kitty's
1148
1165
  // CSI-u (ESC [ 13;2 u) and xterm's modifyOtherKeys (ESC [ 27;2;13 ~).
1149
1166
  // Never claimed universal — Ctrl+J is the everywhere baseline; a
@@ -1736,11 +1753,25 @@ export class Editor {
1736
1753
  * answers, never empties; adjacent duplicates collapse, the tail
1737
1754
  * caps at 100. A redirect is a turn, so it is remembered too. */
1738
1755
  #remember(line) {
1739
- if (this.#history[this.#history.length - 1] !== line)
1756
+ if (this.#history[this.#history.length - 1] !== line) {
1740
1757
  this.#history.push(line);
1758
+ // R3a: the persistence seam — the CLI owns the file (the tui
1759
+ // stays I/O-free); adjacent-duplicate collapse already applied
1760
+ this.#persistHistory?.(line);
1761
+ }
1741
1762
  if (this.#history.length > 100)
1742
1763
  this.#history.shift();
1743
1764
  }
1765
+ #persistHistory = null;
1766
+ /** R3a — cross-session input history: seed the recall buffer and
1767
+ * register the append sink. The cap and the adjacent-duplicate
1768
+ * collapse are unchanged; the seed takes the TAIL of what the CLI
1769
+ * loaded. Never persists question answers (#remember's callers
1770
+ * already exclude them). */
1771
+ bindHistory(seed, persist) {
1772
+ this.#history = seed.slice(-100).filter((l) => l !== "");
1773
+ this.#persistHistory = persist;
1774
+ }
1744
1775
  /** KC2 §2 — the NORMAL composer state: the redirect gesture is live
1745
1776
  * ONLY here. The approval panel, the slash menu, the history browse
1746
1777
  * and the queue-pop walk each OWN their keys first (the W21 "the
package/dist/status.d.ts CHANGED
@@ -52,6 +52,12 @@ export declare function runningStatus(glyph: string, since: number, outTokens: n
52
52
  */
53
53
  export interface StatusMeter {
54
54
  readonly cacheHitPct: number | null;
55
+ /** RETIRED from the row (the owner's 2026-08-23 directive): live
56
+ * prices fluctuate and the canonical table is "an approximation,
57
+ * not a bill" — a four-decimal figure on the status bar claimed a
58
+ * precision the data never had. The canonical cost STAYS recorded
59
+ * (trace ledger, /context); the field is kept so callers need not
60
+ * change shape, and it renders NOTHING. */
55
61
  readonly costUsd: number | null;
56
62
  }
57
63
  /** The IDLE row: the approval tier as the CALLER names it, the /mode
package/dist/status.js CHANGED
@@ -55,8 +55,7 @@ export function idleStatus(tier, model, ctxRatio, meter) {
55
55
  const parts = [`▸ ${tier}`, "/mode to switch", model];
56
56
  if (meter?.cacheHitPct != null)
57
57
  parts.push(`CH ${Math.round(meter.cacheHitPct)}%`);
58
- if (meter?.costUsd != null)
59
- parts.push(`$${meter.costUsd.toFixed(4)}`);
58
+ // costUsd deliberately NOT rendered — see StatusMeter.costUsd.
60
59
  parts.push(`ctx left ~${ctxLeft(ctxRatio)}%`);
61
60
  return parts.join(" · ");
62
61
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-tui",
3
- "version": "0.14.0",
3
+ "version": "0.15.1",
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.14.0"
38
+ "@vincemakes/kiso-tui-cells": "0.15.1"
39
39
  }
40
40
  }