@vincemakes/kiso-tui 0.39.2 → 0.40.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.
@@ -132,10 +132,13 @@ export declare class Body {
132
132
  toolProgress(callId: string, text: string): void;
133
133
  toolSucceeded(callId: string): void;
134
134
  toolFailed(callId: string, error: string): void;
135
+ /** `untimed`: 4c's replay settles a card from the durable log, whose
136
+ * events carry no clock — the card then says nothing about time. */
135
137
  toolResult(callId: string, result: {
136
138
  content: string;
137
139
  isError: boolean;
138
140
  reason?: string | null;
141
+ untimed?: boolean;
139
142
  }): void;
140
143
  textAppend(text: string): void;
141
144
  textEnd(): void;
@@ -144,6 +147,18 @@ export declare class Body {
144
147
  * CLI's wall-clocked thinking window. */
145
148
  endTurn(thoughtSeconds: number): void;
146
149
  terminal(label: string, statusLineText: string): void;
150
+ /**
151
+ * 4c — fold what `replay` puts on the body into ONE row. The replayed
152
+ * cells are the same cells a live run makes (the caller drives the
153
+ * ordinary mutations), moved out of the transcript into the fold cell:
154
+ * the row commits as one line and never expands on screen — so a resize
155
+ * reprint redraws one row however long the history — and the ctrl+r
156
+ * viewer reads them. Mutations only schedule frames, so nothing the
157
+ * replay adds can have committed before it is moved. Active bodies
158
+ * only: a pipe has no viewer to read a fold with (the caller prints the
159
+ * plain tail there).
160
+ */
161
+ fold(label: string, replay: () => void, summary?: string | null): void;
147
162
  notice(text: string): void;
148
163
  /** W20 — the task checklist as STATE, not events: the FIRST call of a
149
164
  * turn creates the ONE live block (done:false — the commit loop only
@@ -220,6 +235,10 @@ export declare class Body {
220
235
  count: number;
221
236
  first: number;
222
237
  } | null;
238
+ visiblePickWindow(): {
239
+ first: number;
240
+ size: number;
241
+ } | null;
223
242
  /** SIGWINCH: clear the OLD live area (recorded geometry, ED only —
224
243
  * zero LF, zero \x1b[3J — the shell history untouched), then the
225
244
  * full-redraw path at the NEW geometry (O(height), zero replay).
@@ -339,8 +358,19 @@ export declare class Body {
339
358
  * reads it — the marker math never desyncs by construction). */
340
359
  editCol(): number;
341
360
  /** The old dock's redraw — the editor's onRender target: mark + the
342
- * scheduler (16ms coalescing — the old sync draw coalesces the same). */
343
- redraw(): void;
361
+ * scheduler (16ms coalescing — the old sync draw coalesces the same).
362
+ *
363
+ * Item 6: a KEY-originated redraw paints on the next tick and takes
364
+ * the pending trailing frame with it. Keyboard input is the one
365
+ * latency-sensitive source: the trailing window made every key wait
366
+ * 16 ms, and 40 ms on Terminal.app — where an IME commit erases the
367
+ * terminal's marked text at once and the committed characters came
368
+ * back a frame window later, a visible blink. Stream, tool and
369
+ * spinner marks keep the trailing window exactly (the conservative
370
+ * mode's throughput protection is for them). setImmediate, not a
371
+ * synchronous paint: every mutation one chunk of keys makes still
372
+ * lands in one frame. */
373
+ redraw(fromKey?: boolean): void;
344
374
  /** Teardown — flush a pending frame, stop the timers. */
345
375
  close(): void;
346
376
  /** The live region's scalar — the unit tests assert the cap directly
@@ -369,6 +399,12 @@ export declare class Dock {
369
399
  count: number;
370
400
  first: number;
371
401
  } | null;
402
+ /** B (review of this round): the pick panel's window for the frame last
403
+ * drawn — what the digit keys must name. */
404
+ visiblePickWindow(): {
405
+ first: number;
406
+ size: number;
407
+ } | null;
372
408
  setStatus(text: string, hint?: string | null): void;
373
409
  setTail(tail: string): void;
374
410
  /** W21: bind the editor's panel state — the PanelSelect slot
@@ -403,5 +439,5 @@ export declare class Dock {
403
439
  * the LineInput's own bindQueue). */
404
440
  bindQueue(state: () => readonly string[]): void;
405
441
  editCol(): number;
406
- redraw(): void;
442
+ redraw(fromKey?: boolean): void;
407
443
  }
@@ -52,6 +52,7 @@ import { leadWidth } from "./width.js"; // W23: the ONE width authority (the edi
52
52
  // for four reads, so an ask can never render half as an approval.
53
53
  import { panelFrameOf, panelLeadOf, panelStatusOf } from "./ask-panel.js";
54
54
  import { MOUSE_OFF } from "./editor.js";
55
+ import { pickWindowOf } from "./approval-panel.js";
55
56
  import { atPanelRows, bandHeader } from "./at-picker.js";
56
57
  // TUI2-R2 ②: the session picker's rows — the band's third occupant.
57
58
  import { sessionPickerRows } from "./session-picker.js";
@@ -197,6 +198,10 @@ export class Body {
197
198
  #lastInputRows = 1;
198
199
  #lastAnchorRow = 0;
199
200
  #frameTimer = null;
201
+ #inputFrame = null;
202
+ /** Body mutations and non-key redraws so far — the input frame's
203
+ * "did anything but typing change the screen?" */
204
+ #runMarks = 0;
200
205
  #spinnerTimer = null;
201
206
  #spinnerI = 0;
202
207
  #lastThinking = null;
@@ -520,6 +525,8 @@ export class Body {
520
525
  this.#write(`${p.red} failed: ${escapeTerminal(error.slice(0, 160))}${p.reset}\n`);
521
526
  }
522
527
  }
528
+ /** `untimed`: 4c's replay settles a card from the durable log, whose
529
+ * events carry no clock — the card then says nothing about time. */
523
530
  toolResult(callId, result) {
524
531
  const call = this.#pendingCalls.get(callId);
525
532
  if (call !== undefined) {
@@ -543,7 +550,7 @@ export class Body {
543
550
  cell.isError = result.isError;
544
551
  cell.resultText = result.content;
545
552
  cell.reason = result.reason ?? null;
546
- cell.doneAt = Date.now();
553
+ cell.doneAt = result.untimed === true ? null : Date.now();
547
554
  cell.done = true;
548
555
  }
549
556
  this.#mark();
@@ -692,6 +699,31 @@ export class Body {
692
699
  this.#cells.push({ kind: "terminal", label: label.trim(), line: statusLineText, done: true });
693
700
  this.#mark();
694
701
  }
702
+ /**
703
+ * 4c — fold what `replay` puts on the body into ONE row. The replayed
704
+ * cells are the same cells a live run makes (the caller drives the
705
+ * ordinary mutations), moved out of the transcript into the fold cell:
706
+ * the row commits as one line and never expands on screen — so a resize
707
+ * reprint redraws one row however long the history — and the ctrl+r
708
+ * viewer reads them. Mutations only schedule frames, so nothing the
709
+ * replay adds can have committed before it is moved. Active bodies
710
+ * only: a pipe has no viewer to read a fold with (the caller prints the
711
+ * plain tail there).
712
+ */
713
+ fold(label, replay, summary = null) {
714
+ if (!this.#isActive())
715
+ return;
716
+ const start = this.#cells.length;
717
+ replay();
718
+ this.#closeOpenThinking();
719
+ this.#closeOpenText();
720
+ const children = this.#cells.splice(start);
721
+ for (const cell of children)
722
+ if (!cell.done)
723
+ cell.done = true; // a replay settles; nothing in a fold is live
724
+ this.#cells.push({ kind: "fold", label, children, summary, done: true });
725
+ this.#mark();
726
+ }
695
727
  notice(text) {
696
728
  if (!this.#isActive()) {
697
729
  this.#closeOpenThinking();
@@ -935,8 +967,15 @@ export class Body {
935
967
  const cell = this.#cells[idx];
936
968
  if (cell === undefined)
937
969
  continue;
938
- // R13 — the viewer's FOLD entry retired with the fold: every
939
- // entry is now a card, and a card's entry is its own full body.
970
+ // 4c — the resumed history's fold: the head is its one row, the
971
+ // body is the replayed turns rendered at the viewer's width —
972
+ // the checkpoint's summary first when it is one.
973
+ if (cell.kind === "fold") {
974
+ out.push({ head: cellComponent(cell).render(inner, ctx)[0] ?? "", body: this.#foldBody(cell, inner, ctx) });
975
+ continue;
976
+ }
977
+ // R13 — every other entry is a card, and a card's entry is its
978
+ // own full body.
940
979
  if (cell.kind !== "tool")
941
980
  continue;
942
981
  // the tool card's FULL body — the same rows its own ctrl+o
@@ -959,6 +998,24 @@ export class Body {
959
998
  }
960
999
  return out;
961
1000
  }
1001
+ /** 4c — a fold's children as the transcript would have shown them: each
1002
+ * cell's own render, spaced by the body's own formula (no blank between
1003
+ * two blocks of one message). */
1004
+ #foldBody(cell, W, ctx) {
1005
+ const out = [];
1006
+ let prev = null;
1007
+ let prevKind = null;
1008
+ const cells = cell.summary === null ? cell.children : [{ kind: "raw", lines: cell.summary.split("\n"), done: true, wrap: "words" }, ...cell.children];
1009
+ for (const child of cells) {
1010
+ const rows = cellComponent(child).render(W, ctx);
1011
+ if (rows.length === 0)
1012
+ continue;
1013
+ out.push(...(prevKind === "md" && child.kind === "md" ? rows : bodySpacing(prev, rows)));
1014
+ prev = rows;
1015
+ prevKind = child.kind;
1016
+ }
1017
+ return out;
1018
+ }
962
1019
  /** The viewer's band: its title, its list, its keys. */
963
1020
  #viewerBand(W) {
964
1021
  if (this.#viewer === null)
@@ -1185,6 +1242,13 @@ export class Body {
1185
1242
  panelOptionRows() {
1186
1243
  return this.#panelRowSpan;
1187
1244
  }
1245
+ /** B (review of this round): the pick panel's window for the frame last
1246
+ * drawn — the renderer's own value, not a second derivation. `null` when
1247
+ * no pick panel was on screen. */
1248
+ #pickWin = null;
1249
+ visiblePickWindow() {
1250
+ return this.#pickWin;
1251
+ }
1188
1252
  /** SIGWINCH: clear the OLD live area (recorded geometry, ED only —
1189
1253
  * zero LF, zero \x1b[3J — the shell history untouched), then the
1190
1254
  * full-redraw path at the NEW geometry (O(height), zero replay).
@@ -1513,20 +1577,64 @@ export class Body {
1513
1577
  return 1 + leadWidth(lead) + st.cursor;
1514
1578
  }
1515
1579
  /** The old dock's redraw — the editor's onRender target: mark + the
1516
- * scheduler (16ms coalescing — the old sync draw coalesces the same). */
1517
- redraw() {
1580
+ * scheduler (16ms coalescing — the old sync draw coalesces the same).
1581
+ *
1582
+ * Item 6: a KEY-originated redraw paints on the next tick and takes
1583
+ * the pending trailing frame with it. Keyboard input is the one
1584
+ * latency-sensitive source: the trailing window made every key wait
1585
+ * 16 ms, and 40 ms on Terminal.app — where an IME commit erases the
1586
+ * terminal's marked text at once and the committed characters came
1587
+ * back a frame window later, a visible blink. Stream, tool and
1588
+ * spinner marks keep the trailing window exactly (the conservative
1589
+ * mode's throughput protection is for them). setImmediate, not a
1590
+ * synchronous paint: every mutation one chunk of keys makes still
1591
+ * lands in one frame. */
1592
+ redraw(fromKey = false) {
1518
1593
  if (!this.#isActive())
1519
1594
  return;
1520
1595
  this.#dirty = true;
1596
+ if (fromKey) {
1597
+ this.#scheduleInputFrame();
1598
+ return;
1599
+ }
1600
+ this.#runMarks += 1; // a non-key redraw is not typing: it keeps the window
1521
1601
  this.#scheduleFrame();
1522
1602
  }
1523
1603
  // ---- the scheduler (event-driven; zero heartbeat timers) ----
1524
1604
  #mark() {
1525
1605
  if (!this.#isActive())
1526
1606
  return;
1607
+ this.#runMarks += 1;
1527
1608
  this.#dirty = true;
1528
1609
  this.#scheduleFrame();
1529
1610
  }
1611
+ #scheduleInputFrame() {
1612
+ if (this.#inputFrame !== null)
1613
+ return;
1614
+ const marksAtKey = this.#runMarks;
1615
+ this.#inputFrame = setImmediate(() => {
1616
+ this.#inputFrame = null;
1617
+ // A key whose consequence is run state (an approval verdict, a
1618
+ // submit) mutates the body before this tick. That frame carries
1619
+ // the run, so it keeps the run's trailing window, exactly as
1620
+ // before: painting it now showed an approved write's transient
1621
+ // live card for one frame before its result settled.
1622
+ if (this.#runMarks !== marksAtKey) {
1623
+ if (this.#dirty)
1624
+ this.#scheduleFrame();
1625
+ return;
1626
+ }
1627
+ if (this.#frameTimer !== null) {
1628
+ clearTimeout(this.#frameTimer);
1629
+ this.#frameTimer = null;
1630
+ }
1631
+ if (this.#dirty) {
1632
+ this.#dirty = false;
1633
+ this.render();
1634
+ }
1635
+ });
1636
+ this.#inputFrame.unref();
1637
+ }
1530
1638
  #scheduleFrame() {
1531
1639
  if (this.#frameTimer !== null)
1532
1640
  return;
@@ -1574,6 +1682,10 @@ export class Body {
1574
1682
  }
1575
1683
  /** Teardown — flush a pending frame, stop the timers. */
1576
1684
  close() {
1685
+ if (this.#inputFrame !== null) {
1686
+ clearImmediate(this.#inputFrame);
1687
+ this.#inputFrame = null;
1688
+ }
1577
1689
  if (this.#frameTimer !== null) {
1578
1690
  clearTimeout(this.#frameTimer);
1579
1691
  this.#frameTimer = null;
@@ -1749,6 +1861,7 @@ export class Body {
1749
1861
  // at when they clicked, which is this one.
1750
1862
  this.#panelRowSpan =
1751
1863
  live.panelSpan === null ? null : { top: liveTop + live.panelSpan.offset, count: live.panelSpan.count, first: live.panelSpan.first };
1864
+ this.#pickWin = live.pickWin ?? null;
1752
1865
  // 5. the frame bytes.
1753
1866
  this.#paint(W, H, liveTop, liveLines, liveRowsTotal, chrome);
1754
1867
  }
@@ -1849,7 +1962,11 @@ export class Body {
1849
1962
  // fires on it. W22: the queue band sits below the panel — the
1850
1963
  // cap shrinks by it.
1851
1964
  const frame = panelFrameOf(panel, W, capped);
1852
- return { lines: frame.rows, panelSpan: frame.options };
1965
+ // B (review of this round): the pick window THIS frame draws. The size
1966
+ // depends on the budget, so the renderer is the only producer — the
1967
+ // digit keys are handed this value instead of deriving a second one.
1968
+ const pick = panel.pick ?? null;
1969
+ return { lines: frame.rows, panelSpan: frame.options, pickWin: pick === null ? null : pickWindowOf(panel.view, pick.cursor, pick.phase, capped) };
1853
1970
  }
1854
1971
  return { lines: this.#liveProjection(W, ctx, cap), panelSpan: null };
1855
1972
  }
@@ -2024,6 +2141,9 @@ export class Body {
2024
2141
  // oldest-first, so the newest cut is at the front.
2025
2142
  if (cell.kind === "tool" && lines.some((l) => l.includes("ctrl+o")))
2026
2143
  this.#collapsed.unshift(i);
2144
+ // 4c: a fold is read in the viewer and nowhere else.
2145
+ if (cell.kind === "fold")
2146
+ this.#collapsed.unshift(i);
2027
2147
  this.#lineCache[i] = lines;
2028
2148
  const placed = this.#space(i, i > 0 ? this.#lineCache[i - 1] : null, lines);
2029
2149
  this.#committed += 1;
@@ -2762,6 +2882,11 @@ export class Dock {
2762
2882
  panelOptionRows() {
2763
2883
  return compositorRef?.panelOptionRows() ?? null;
2764
2884
  }
2885
+ /** B (review of this round): the pick panel's window for the frame last
2886
+ * drawn — what the digit keys must name. */
2887
+ visiblePickWindow() {
2888
+ return compositorRef?.visiblePickWindow() ?? null;
2889
+ }
2765
2890
  setStatus(text, hint) {
2766
2891
  compositorRef?.setStatus(text, hint ?? null);
2767
2892
  }
@@ -2848,8 +2973,8 @@ export class Dock {
2848
2973
  editCol() {
2849
2974
  return compositorRef?.editCol() ?? 1;
2850
2975
  }
2851
- redraw() {
2852
- compositorRef?.redraw();
2976
+ redraw(fromKey = false) {
2977
+ compositorRef?.redraw(fromKey);
2853
2978
  }
2854
2979
  }
2855
2980
  /** The one-compositor registry — the Dock façade routes to it. */
@@ -58,6 +58,11 @@ export interface ContextLedger {
58
58
  * the detail text rides after them, dim, and is cut by the caller's
59
59
  * width if it must be.
60
60
  */
61
+ /** 0.40.0 — the ONE fill rule for a ▰▱ meter: `ratio` of `cells`, rounded,
62
+ * clamped to the bar. The `/context` bar and the compacting row's bar both
63
+ * draw through it, so the two cannot fill differently. Plain glyphs; the
64
+ * caller styles them. */
65
+ export declare function meterGlyphs(ratio: number, cells: number): string;
61
66
  export declare function contextRows(ledger: ContextLedger): string[];
62
67
  /** TUI2-R1 (E) — the honest fallback. The ledger is written PER REQUEST:
63
68
  * a session that has not called the model yet has no sidecar, and the
@@ -45,15 +45,24 @@ function k(n) {
45
45
  * the detail text rides after them, dim, and is cut by the caller's
46
46
  * width if it must be.
47
47
  */
48
+ /** 0.40.0 — the ONE fill rule for a ▰▱ meter: `ratio` of `cells`, rounded,
49
+ * clamped to the bar. The `/context` bar and the compacting row's bar both
50
+ * draw through it, so the two cannot fill differently. Plain glyphs; the
51
+ * caller styles them. */
52
+ export function meterGlyphs(ratio, cells) {
53
+ const filled = Math.max(0, Math.min(cells, Math.round((Number.isFinite(ratio) ? ratio : 0) * cells)));
54
+ return `${"\u25b0".repeat(filled)}${"\u25b1".repeat(cells - filled)}`;
55
+ }
48
56
  export function contextRows(ledger) {
49
57
  const p = palette();
50
58
  const used = ledger.systemPrompt + ledger.toolTable + ledger.skillsIndex + ledger.envelope + ledger.messages;
51
59
  const free = Math.max(0, ledger.window - used);
52
60
  const ratio = ledger.window > 0 ? Math.min(1, used / ledger.window) : 1;
53
- const filled = Math.max(0, Math.min(BAR_CELLS, Math.round(ratio * BAR_CELLS)));
61
+ const bar = meterGlyphs(ratio, BAR_CELLS);
62
+ const filled = bar.indexOf("\u25b1") < 0 ? BAR_CELLS : bar.indexOf("\u25b1");
54
63
  const rows = [
55
64
  `${p.bold}context — ${k(used)} / ${k(ledger.window)} tokens (${Math.round(ratio * 100)}%)${p.reset}`,
56
- `${p.bold}${"▰".repeat(filled)}${p.reset}${p.dim}${"▱".repeat(BAR_CELLS - filled)}${p.reset}`,
65
+ `${p.bold}${bar.slice(0, filled)}${p.reset}${p.dim}${bar.slice(filled)}${p.reset}`,
57
66
  ];
58
67
  /** One surface row: the label at 14 columns, the count right-aligned
59
68
  * at 5, then the dim detail. */
package/dist/editor.d.ts CHANGED
@@ -27,13 +27,20 @@ import { type PanelState, type PanelVerdict, type PanelView, type SaferAnswer }
27
27
  import { type AtItem, type AtMatch } from "./at-picker.js";
28
28
  import { type SessionCardView, type SessionPickState } from "./session-picker.js";
29
29
  /**
30
- * TUI2-R3v2 ② — the mouse-mode bytes, stated once.
30
+ * TUI2-R3v2 ②, corrected by the owner's ruling of 2026-09-21 (finding
31
+ * DC-56) — the mouse-mode bytes, stated once.
31
32
  *
32
33
  * ?1000 is the button-event report and ?1006 is the SGR encoding that
33
34
  * makes it parseable past column 95 (the legacy X10 encoding packs the
34
- * coordinate into one byte and simply breaks on a wide terminal). Both
35
- * go on together and come off together; a terminal left with either one
36
- * set is a terminal that prints escape bytes at the shell prompt.
35
+ * coordinate into one byte and simply breaks on a wide terminal). kiso
36
+ * still RESETS them — on entry (a previous process may have died with
37
+ * reporting left on) and on exit — but it no longer ENABLES them, because
38
+ * a terminal that is reporting stops scrolling its own scrollback: a
39
+ * surface that takes the mouse takes the history with it. kiso's
40
+ * transcript lives in that scrollback by design, and the first version of
41
+ * this feature locked it exactly where a person most wants to read back —
42
+ * under a blocking question. The history wins: no surface claims the
43
+ * mouse, and every surface keeps its keys.
37
44
  */
38
45
  /**
39
46
  * R5 — the viewer's key table, as a pure function of the input chunk.
@@ -45,7 +52,6 @@ import { type SessionCardView, type SessionPickState } from "./session-picker.js
45
52
  * different route.
46
53
  */
47
54
  export declare function viewerCommand(text: string): "up" | "down" | "toggle" | "all" | "pageUp" | "pageDown" | "home" | "end" | "close" | null;
48
- export declare const MOUSE_ON = "\u001B[?1000h\u001B[?1006h";
49
55
  export declare const MOUSE_OFF = "\u001B[?1000l\u001B[?1006l";
50
56
  export declare const PROMPT = "\u258C ";
51
57
  export declare const PROMPT_WIDTH: number;
@@ -65,7 +71,7 @@ export declare const MENU_ITEMS: readonly MenuItem[];
65
71
  export declare class Editor {
66
72
  #private;
67
73
  readonly closed: Promise<void>;
68
- constructor(onRender: () => void);
74
+ constructor(onRender: (fromKey: boolean) => void);
69
75
  /**
70
76
  * DC-7 — the terminal answering a question kiso asked it.
71
77
  *
@@ -153,6 +159,9 @@ export declare class Editor {
153
159
  items: readonly MenuItem[];
154
160
  selected: number;
155
161
  } | null;
162
+ /** 0.40.1 — bind the menu's extra entries (the CLI binds the installed
163
+ * skills). A function, read per keystroke, never a snapshot. */
164
+ bindMenuExtras(extras: () => readonly MenuItem[]): void;
156
165
  /** KC3 §3 — bind the file source. The tui owns no file list and
157
166
  * never touches a disk (input is data, output is bytes): the CLI
158
167
  * feeds the paths, and until it does, the picker cannot open at
@@ -168,7 +177,7 @@ export declare class Editor {
168
177
  capped: boolean;
169
178
  } | null;
170
179
  /** Open the picker on a bound card source (PickInput, S5). */
171
- beginPick(cards: () => readonly SessionCardView[], onPick: (id: string | null) => void): void;
180
+ beginPick(cards: () => readonly SessionCardView[], onPick: (id: string | null) => void, here?: string): void;
172
181
  pickState(): SessionPickState | null;
173
182
  /** One-shot question mode: the NEXT submit answers, not a turn. */
174
183
  question(_query: string, cb: (answer: string) => void): void;
@@ -218,6 +227,12 @@ export declare class Editor {
218
227
  count: number;
219
228
  first?: number;
220
229
  } | null) | null): void;
230
+ /** B: the pick window the last frame drew — the Dock's value, not the
231
+ * editor's guess. Same pull-on-demand shape as `bindPanelRows`. */
232
+ bindPickWindow(fn: (() => {
233
+ first: number;
234
+ size: number;
235
+ } | null) | null): void;
221
236
  /** R3a — cross-session input history: seed the recall buffer and
222
237
  * register the append sink. The cap and the adjacent-duplicate
223
238
  * collapse are unchanged; the seed takes the TAIL of what the CLI