@vincemakes/kiso-tui 0.31.1 → 0.32.0

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.
@@ -284,8 +284,25 @@ export declare class Body {
284
284
  * reaching into a card whose content is still arriving.
285
285
  */
286
286
  toggleExpanded(): void;
287
+ /** §2.4 — the terminal holds someone else's drawing (an external
288
+ * editor had it) and the model is the only authority on what should
289
+ * be there. The same act as a resize; the same path. */
290
+ reprint(): void;
287
291
  /** DC-50 — the switch itself, for the CLI's affordance text. */
288
292
  expandedAll(): boolean;
293
+ /** §2.3 — ctrl+t: the committed thinking blocks fold, and fold back.
294
+ *
295
+ * DC-50's mechanism exactly: one boolean, then the session is printed
296
+ * again, so the blocks ALREADY on screen obey the switch rather than
297
+ * only the next ones. Nothing durable moves — the events are
298
+ * untouched and `/think` still reaches the last block whole.
299
+ *
300
+ * COMMITTED blocks only. The live `thinking…` placeholder belongs to
301
+ * the live region, not to a cell, and an OPEN thinking cell is text
302
+ * still arriving; a global "quiet down" has no business reaching into
303
+ * either, which is the same exemption `toggleExpanded` states for a
304
+ * card that is still growing. */
305
+ toggleThinking(): void;
289
306
  /** W18: the status row's right-aligned hint is part of the status
290
307
  * state — the compacting row passes "esc to cancel" (the affordance
291
308
  * must survive repaints). */
@@ -202,6 +202,9 @@ export class Body {
202
202
  #lastTool = null;
203
203
  #pendingCalls = new Map();
204
204
  #pipeBuf = ""; // the passthrough's thinking buffer
205
+ // §2.3 — the ctrl+t switch. A block that completes AFTER the press
206
+ // inherits it, so the session stays one way up rather than mixing.
207
+ #thinkingFolded = false;
205
208
  /** TUI2-MD ⑤ — the markdown scanner of the message currently
206
209
  * streaming, and the cell index its first block landed at. Null
207
210
  * between messages: the scanner's life is one assistant message. */
@@ -390,6 +393,9 @@ export class Body {
390
393
  const last = this.#cells[this.#cells.length - 1];
391
394
  if (last !== undefined && last.kind === "thinking" && !last.done) {
392
395
  last.done = true;
396
+ // §2.3: a block that SETTLES after the switch was thrown is
397
+ // folded like the rest — the session stays one way up.
398
+ last.folded = this.#thinkingFolded;
393
399
  this.#lastThinking = last.text;
394
400
  if (!this.#isActive())
395
401
  this.#write(foldThinking(last.text));
@@ -1303,11 +1309,12 @@ export class Body {
1303
1309
  /**
1304
1310
  * R14 — ERASE THE TERMINAL AND PRINT THE SESSION AGAIN.
1305
1311
  *
1306
- * Two callers: a settled resize, and DC-50's ctrl+o. They are the
1307
- * same act — the rendering the terminal holds is wrong (wrong
1308
- * geometry, or wrong expansion state) and the model is the only
1309
- * authority on what it should be — so they share the path rather
1310
- * than growing two.
1312
+ * Four callers now: a settled resize, DC-50's ctrl+o, §2.3's ctrl+t,
1313
+ * and §2.4's return from an external editor. They are the same act —
1314
+ * the rendering the terminal holds is wrong (wrong geometry, wrong
1315
+ * expansion state, wrong fold, or another program drew over it) and
1316
+ * the model is the only authority on what it should be — so they
1317
+ * share the path rather than growing four.
1311
1318
  */
1312
1319
  #reprint() {
1313
1320
  const H = this.#opts.height();
@@ -1367,10 +1374,36 @@ export class Body {
1367
1374
  }
1368
1375
  this.#reprint();
1369
1376
  }
1377
+ /** §2.4 — the terminal holds someone else's drawing (an external
1378
+ * editor had it) and the model is the only authority on what should
1379
+ * be there. The same act as a resize; the same path. */
1380
+ reprint() {
1381
+ this.#reprint();
1382
+ }
1370
1383
  /** DC-50 — the switch itself, for the CLI's affordance text. */
1371
1384
  expandedAll() {
1372
1385
  return this.#expandedAll;
1373
1386
  }
1387
+ /** §2.3 — ctrl+t: the committed thinking blocks fold, and fold back.
1388
+ *
1389
+ * DC-50's mechanism exactly: one boolean, then the session is printed
1390
+ * again, so the blocks ALREADY on screen obey the switch rather than
1391
+ * only the next ones. Nothing durable moves — the events are
1392
+ * untouched and `/think` still reaches the last block whole.
1393
+ *
1394
+ * COMMITTED blocks only. The live `thinking…` placeholder belongs to
1395
+ * the live region, not to a cell, and an OPEN thinking cell is text
1396
+ * still arriving; a global "quiet down" has no business reaching into
1397
+ * either, which is the same exemption `toggleExpanded` states for a
1398
+ * card that is still growing. */
1399
+ toggleThinking() {
1400
+ this.#thinkingFolded = !this.#thinkingFolded;
1401
+ for (const cell of this.#cells) {
1402
+ if (cell.kind === "thinking" && cell.done)
1403
+ cell.folded = this.#thinkingFolded;
1404
+ }
1405
+ this.#reprint();
1406
+ }
1374
1407
  /** W18: the status row's right-aligned hint is part of the status
1375
1408
  * state — the compacting row passes "esc to cancel" (the affordance
1376
1409
  * must survive repaints). */
package/dist/editor.d.ts CHANGED
@@ -86,6 +86,26 @@ export declare class Editor {
86
86
  /** E1 §3 — the copy key (ctrl+x). */
87
87
  onCopy(cb: () => void): void;
88
88
  onExpand(cb: () => void): void;
89
+ /** §2.3: the thinking key (ctrl+t) — the chain-level action, wired
90
+ * exactly like ctrl+o because it is the same kind of thing: a switch
91
+ * the compositor throws, never an interpretation the editor makes. */
92
+ onThink(cb: () => void): void;
93
+ /** §2.4: the external-editor key (ctrl+g). */
94
+ onEditor(cb: () => void): void;
95
+ /** §2.4 — hand the terminal to an external program, and take it back.
96
+ *
97
+ * `run` receives the composer's text and returns what should replace
98
+ * it, or null to leave it alone (no editor configured, the editor
99
+ * failed, nothing changed). The SPAWN is the caller's: this package is
100
+ * pure terminal — input is data, output is bytes, zero runtime deps —
101
+ * so what lives here is the handover and nothing else.
102
+ *
103
+ * NOT `exit()` / `enter()`. Those are the session's boundary: exit()
104
+ * also resolves the closed promise, which tells the layer above that
105
+ * the session is over. Suspending is a different act with the same
106
+ * terminal moves, and conflating them would end the session every time
107
+ * someone opened their editor. */
108
+ externalEdit(run: (text: string) => string | null): void;
89
109
  /** KC2 §2: the redirect chain — the gesture hands the buffer's text
90
110
  * over while the run is told to stop. Mirrors onEscape (a list, so
91
111
  * listeners can coexist); the line arrives already gone from the
@@ -168,6 +188,20 @@ export declare class Editor {
168
188
  panelState(): PanelState | null;
169
189
  enter(): void;
170
190
  exit(): void;
191
+ /** OR-11 (a) — the lead the composer's row is DRAWN with, which is not
192
+ * always the brick. The CLI binds the compositor's input lead as `""`
193
+ * (a prompt character is a third thing saying "input lives here", and
194
+ * it cost the row a column), while the editor measured against PROMPT
195
+ * — so the editor believed the row two columns narrower than the
196
+ * compositor drew it, and W23's "the two width authorities can never
197
+ * disagree" was off by two.
198
+ *
199
+ * A PROVIDER, not a string, because both renderers are live in the
200
+ * same process: the dock draws the row when it is active and
201
+ * `selfRender` draws it when it is not, and they lead it differently.
202
+ * Whoever binds the row answers for whichever is drawing. The default
203
+ * is the brick, which is what `selfRender` has always drawn. */
204
+ setInputLead(lead: () => string): void;
171
205
  /** The row's own render when the dock is inactive (a TTY without a
172
206
  * real size): \r + clear + blue brick prompt + visible + cursor
173
207
  * column. */
package/dist/editor.js CHANGED
@@ -22,7 +22,7 @@
22
22
  * source funnels through the ONE normalizer in feed() (§3).
23
23
  */
24
24
  var _a;
25
- import { charWidth, displayWidth, leadWidth, widthOf } from "./width.js";
25
+ import { breakable, charWidth, displayWidth, leadWidth, widthOf } from "./width.js";
26
26
  // the width primitives moved to width.ts (W1, the single width
27
27
  // authority) — re-exported so the editor's public surface is unchanged.
28
28
  export { charWidth, displayWidth, widthOf };
@@ -146,8 +146,16 @@ const N_MAX = 6;
146
146
  * payload for someone else, and holding it is how the editor goes
147
147
  * deaf. */
148
148
  const OSC_MAX = 1024;
149
- /** The dim "…" — the ONE truncation mark: the horizontal scroll's
150
- * prefix (unchanged) and the viewport's hidden-rows markers. */
149
+ /** TMUX-F1 ①: a CSI parameter string longer than this with no final byte
150
+ * is not a sequence any terminal sends; it is dropped rather than held, so
151
+ * a runaway can never park the editor. */
152
+ const CSI_MAX = 64;
153
+ /** TMUX-F1 ②: identical arrow sequences in ONE read at or past this count
154
+ * are a wheel notch, not a hand — treated as one press. */
155
+ const ARROW_BURST = 3;
156
+ /** The dim "…" — the ONE truncation mark. OR-11 retired its other use
157
+ * (the horizontal scroll's prefix) with the scroll itself; what is left
158
+ * is the viewport's hidden-rows markers, above and below. */
151
159
  /** R3: built per call from the palette — `dim` is an absolute grey once
152
160
  * the ground is known, so a frozen SGR 2 here would be the one span
153
161
  * that ignores it. */
@@ -198,11 +206,10 @@ const shown = (chars) => {
198
206
  export class Editor {
199
207
  #chars = [];
200
208
  #cursor = 0;
201
- #scroll = 0; // chars scrolled off the left of the CURSOR'S LINE (width-based reflow; KC1: line-local, so a single-line buffer is unchanged)
202
209
  // KC1 §2 — the ONE new ephemeral field: the desired column for the
203
210
  // ↑/↓ walk (a long line's column 20 → a short line clamps to 5 → the
204
211
  // next long line RETURNS to 20). Set on the first vertical move,
205
- // kept across consecutive ones, reset by any horizontal move, insert
212
+ // kept across consecutive ones, reset by any left/right move, insert
206
213
  // or delete. Never stashed — it is a walk's state, not the buffer's.
207
214
  #verticalGoalCol = null;
208
215
  #questionCb = null;
@@ -322,6 +329,8 @@ export class Editor {
322
329
  // expanded block). Mirrors the escape list: multiple listeners can
323
330
  // coexist; the editor never interprets the key itself.
324
331
  #expandCbs = [];
332
+ #thinkCbs = [];
333
+ #editorCbs = [];
325
334
  /** E1 §3 — ctrl+x. Same shape as the expand key: the editor owns the
326
335
  * KEY, the CLI owns what it means. */
327
336
  #copyCbs = [];
@@ -455,6 +464,57 @@ export class Editor {
455
464
  onExpand(cb) {
456
465
  this.#expandCbs.push(cb);
457
466
  }
467
+ /** §2.3: the thinking key (ctrl+t) — the chain-level action, wired
468
+ * exactly like ctrl+o because it is the same kind of thing: a switch
469
+ * the compositor throws, never an interpretation the editor makes. */
470
+ onThink(cb) {
471
+ this.#thinkCbs.push(cb);
472
+ }
473
+ /** §2.4: the external-editor key (ctrl+g). */
474
+ onEditor(cb) {
475
+ this.#editorCbs.push(cb);
476
+ }
477
+ /** §2.4 — hand the terminal to an external program, and take it back.
478
+ *
479
+ * `run` receives the composer's text and returns what should replace
480
+ * it, or null to leave it alone (no editor configured, the editor
481
+ * failed, nothing changed). The SPAWN is the caller's: this package is
482
+ * pure terminal — input is data, output is bytes, zero runtime deps —
483
+ * so what lives here is the handover and nothing else.
484
+ *
485
+ * NOT `exit()` / `enter()`. Those are the session's boundary: exit()
486
+ * also resolves the closed promise, which tells the layer above that
487
+ * the session is over. Suspending is a different act with the same
488
+ * terminal moves, and conflating them would end the session every time
489
+ * someone opened their editor. */
490
+ externalEdit(run) {
491
+ const before = this.line();
492
+ let next = null;
493
+ process.stdin.off("data", this.#onData);
494
+ process.stdout.write(MOUSE_OFF);
495
+ process.stdout.write("\x1b[?2004l"); // bracketed paste OFF — the child's, not ours
496
+ process.stdout.write("\x1b[?25h"); // and it needs a cursor to draw
497
+ process.stdin.setRawMode(false);
498
+ try {
499
+ next = run(before);
500
+ }
501
+ finally {
502
+ // the handover comes back whatever the child did, including
503
+ // throwing: a terminal left in the child's mode is unusable and
504
+ // the human has no way to ask for it back.
505
+ process.stdin.setRawMode(true);
506
+ process.stdout.write(MOUSE_OFF);
507
+ this.#mouseOn = false;
508
+ process.stdout.write("\x1b[?2004h");
509
+ process.stdin.on("data", this.#onData);
510
+ }
511
+ if (next !== null && next !== before) {
512
+ this.#chars = [...next].map((ch) => ch.codePointAt(0));
513
+ this.#cursor = this.#chars.length;
514
+ this.#reflow();
515
+ }
516
+ this.#onRender();
517
+ }
458
518
  /** KC2 §2: the redirect chain — the gesture hands the buffer's text
459
519
  * over while the run is told to stop. Mirrors onEscape (a list, so
460
520
  * listeners can coexist); the line arrives already gone from the
@@ -509,7 +569,6 @@ export class Editor {
509
569
  this.#redoStack.length = 0;
510
570
  this.#chars = [];
511
571
  this.#cursor = 0;
512
- this.#scroll = 0;
513
572
  this.#verticalGoalCol = null;
514
573
  this.#onRender();
515
574
  }
@@ -539,12 +598,129 @@ export class Editor {
539
598
  }
540
599
  return bounds.length - 1;
541
600
  }
542
- /** The cursor's OWN line — the unit of the horizontal scroll and of
543
- * the line-local A/E/U/K (A3). */
601
+ /** The cursor's OWN LOGICAL line — the unit of the line-local A/E/U/K
602
+ * (A3) and of the `@` token scan. The visual rows a long line folds
603
+ * into are `#visualRows`; these two answer different questions and
604
+ * OR-11 kept them apart deliberately. */
544
605
  #cursorBounds() {
545
606
  const bounds = this.#lineBounds();
546
607
  return bounds[this.#cursorLine(bounds)];
547
608
  }
609
+ /** OR-11 — the row's width budget: the SAME number the compositor
610
+ * gives the row (W23's one formula), against the lead that is
611
+ * actually drawn. The fold and the cursor math both ask here. */
612
+ #budget() {
613
+ const W = (process.stdout.columns ?? 0) || 80;
614
+ const ps = this.#panelInput.state();
615
+ const lead = ps !== null ? panelLead(ps.view, ps.phase, ps.cursor, ps.ask) : this.#inputLead();
616
+ return Math.max(1, W - leadWidth(lead) - 1);
617
+ }
618
+ /** The `[Pasted text #N +M lines]` tokens inside [start, end).
619
+ * A capsule is ONE thing on screen and one thing to the cursor, so a
620
+ * fold never lands inside it: it is cut when drawn if it is wider
621
+ * than the row, and it is never split into two. */
622
+ #capsules(start, end) {
623
+ const text = String.fromCodePoint(...this.#chars.slice(start, end));
624
+ const units = [];
625
+ // the regex indexes UTF-16 units; the buffer is code points
626
+ let cp = start;
627
+ for (const ch of text) {
628
+ units.push(cp);
629
+ cp += 1;
630
+ if (ch.length === 2)
631
+ units.push(cp - 1); // a surrogate pair is one code point
632
+ }
633
+ units.push(end);
634
+ const out = [];
635
+ for (const m of text.matchAll(_a.#CAPSULE)) {
636
+ const i = m.index ?? 0;
637
+ out.push({ start: units[i] ?? start, end: units[i + m[0].length] ?? end });
638
+ }
639
+ return out;
640
+ }
641
+ /** OR-11 — one logical line's VISUAL rows, as [start, end) into
642
+ * #chars. The rows TILE the line exactly: no character is dropped and
643
+ * none is shown twice, which is what lets the cursor map back.
644
+ *
645
+ * The break, in order:
646
+ * 1. everything fits — one row;
647
+ * 2. the cut falls on a CJK boundary (either side breakable) — take
648
+ * it, because looking further back for a space would leave the
649
+ * row half empty for text that breaks anywhere;
650
+ * 3. otherwise the last whitespace RUN that fits: the whole run ends
651
+ * the row it is on, so a continuation row never begins with a
652
+ * space — unless the run itself straddles the budget, where ①
653
+ * wins and the leftover spaces open the next row (OR11-F1);
654
+ * 4. otherwise a hard break at the last code point that fits — never
655
+ * inside a wide character, because `#indexAtWidth` is that walk. */
656
+ #foldLine(start, end, budget) {
657
+ const rows = [];
658
+ const atoms = this.#capsules(start, end);
659
+ let from = start;
660
+ while (from < end) {
661
+ const fits = this.#fitsWithin(from, end, budget);
662
+ if (fits >= end)
663
+ break;
664
+ let cut = fits;
665
+ const atom = atoms.find((a) => a.start < cut && cut < a.end);
666
+ if (atom !== undefined) {
667
+ // the capsule keeps its own row: start one before it when
668
+ // there is text ahead of it, otherwise let it run whole.
669
+ cut = atom.start > from ? atom.start : atom.end;
670
+ }
671
+ else if (!breakable(this.#chars[fits] ?? 0) && !breakable(this.#chars[fits - 1] ?? 0)) {
672
+ let w = -1;
673
+ for (let i = cut - 1; i > from; i -= 1) {
674
+ if (this.#chars[i] === SPACE) {
675
+ w = i;
676
+ break;
677
+ }
678
+ }
679
+ if (w >= 0) {
680
+ while (this.#chars[w + 1] === SPACE && w + 1 < end)
681
+ w += 1;
682
+ // OR11-F1: capped at `fits`. Ending the row with the WHOLE
683
+ // run is what keeps a continuation row from opening with a
684
+ // space — but that is a preference, and invariant ① is
685
+ // not: every row kiso produces measures ≤ W, because
686
+ // autowrap is off and the terminal will not save it. A run
687
+ // STRADDLING the boundary was carrying the row past the
688
+ // budget (34 letters + 12 spaces + 20 letters at width 40
689
+ // measured 46 against 39). When the run itself does not
690
+ // fit, the row stops at the budget and the leftover spaces
691
+ // open the next row.
692
+ cut = Math.min(w + 1, fits);
693
+ }
694
+ }
695
+ if (cut <= from || cut >= end)
696
+ break; // no progress, or the rest fits
697
+ rows.push({ start: from, end: cut });
698
+ from = cut;
699
+ }
700
+ rows.push({ start: from, end });
701
+ return rows;
702
+ }
703
+ /** OR-11 — every VISUAL row of the buffer, in order. ONE fold, built
704
+ * once per read; `dockState`, the cursor mapping and the ↑/↓ walk all
705
+ * READ this table rather than folding again. */
706
+ #visualRows() {
707
+ const budget = this.#budget();
708
+ const out = [];
709
+ for (const b of this.#lineBounds())
710
+ out.push(...this.#foldLine(b.start, b.end, budget));
711
+ return out;
712
+ }
713
+ /** The visual row the cursor is on: the LAST row it can belong to. At
714
+ * a fold boundary that is the row the next character would go on; at
715
+ * a newline it is the row the newline closes, never the next line. */
716
+ #cursorVisualRow(rows) {
717
+ let hit = 0;
718
+ for (let i = 0; i < rows.length; i += 1) {
719
+ if (rows[i].start <= this.#cursor && this.#cursor <= rows[i].end)
720
+ hit = i;
721
+ }
722
+ return hit;
723
+ }
548
724
  /** KC1 §5 — N_visible = min(lineCount, N_MAX, max(1, H − 3 − the
549
725
  * menu/queue bands)). The height clamp guarantees legal geometry
550
726
  * down to the compositor's enter gate; the compositor re-applies the
@@ -568,27 +744,27 @@ export class Editor {
568
744
  * stash / restore / clear / submit path has new state to carry. A
569
745
  * dim "…" marks whichever edge hides rows. */
570
746
  dockState() {
571
- const bounds = this.#lineBounds();
572
- const cursorLine = this.#cursorLine(bounds);
573
- const n = this.#visibleRows(bounds.length);
574
- const first = Math.max(0, Math.min(cursorLine - n + 1, bounds.length - n));
747
+ // OR-11: VISUAL rows. One logical line may be several of them, and
748
+ // the window, the markers and the cursor all count them the same
749
+ // way — N_MAX has always been about how tall the box may get, and
750
+ // a folded line is as tall as a pasted one.
751
+ const rows = this.#visualRows();
752
+ const cursorLine = this.#cursorVisualRow(rows);
753
+ const n = this.#visibleRows(rows.length);
754
+ const first = Math.max(0, Math.min(cursorLine - n + 1, rows.length - n));
575
755
  const lines = [];
576
756
  for (let i = first; i < first + n; i += 1) {
577
- const b = bounds[i];
578
- // the cursor's own row carries the horizontal scroll (and its
579
- // "…"); the other rows render whole and cap at the frame's wall
580
- const from = i === cursorLine ? b.start + this.#scroll : b.start;
581
- const scrolled = i === cursorLine && this.#scroll > 0 ? ellipsis() : "";
757
+ const b = rows[i];
582
758
  const above = i === first && first > 0 ? ellipsis() : "";
583
- const below = i === first + n - 1 && first + n < bounds.length ? ellipsis() : "";
584
- lines.push(`${above}${scrolled}${shown(this.#chars.slice(from, b.end))}${below}`);
759
+ const below = i === first + n - 1 && first + n < rows.length ? ellipsis() : "";
760
+ lines.push(`${above}${shown(this.#chars.slice(b.start, b.end))}${below}`);
585
761
  }
586
762
  const cursorRow = cursorLine - first;
587
763
  // the window trails the cursor, so the hidden-above marker can only
588
764
  // share the cursor's row in the degenerate one-row window (a tiny
589
- // terminal) — where it shifts the column like the scroll's does
590
- const marks = (cursorRow === 0 && first > 0 ? 1 : 0) + (this.#scroll > 0 ? 1 : 0);
591
- const cursorCol = marks + widthOf(this.#chars.slice(bounds[cursorLine].start + this.#scroll, this.#cursor));
765
+ // terminal) — where it shifts the column by its one cell
766
+ const marks = cursorRow === 0 && first > 0 ? 1 : 0;
767
+ const cursorCol = marks + widthOf(this.#chars.slice(rows[cursorLine].start, this.#cursor));
592
768
  return { line: lines[cursorRow], cursor: cursorCol, lines, cursorRow, cursorCol };
593
769
  }
594
770
  /** v3 §04: the menu's visible state for the dock — null when closed. */
@@ -852,6 +1028,24 @@ export class Editor {
852
1028
  #syncMouse() {
853
1029
  this.#setMouse(this.#panelInput.up() || this.#pickInput.up() || this.#atUp());
854
1030
  }
1031
+ /** OR-11 (a) — the lead the composer's row is DRAWN with, which is not
1032
+ * always the brick. The CLI binds the compositor's input lead as `""`
1033
+ * (a prompt character is a third thing saying "input lives here", and
1034
+ * it cost the row a column), while the editor measured against PROMPT
1035
+ * — so the editor believed the row two columns narrower than the
1036
+ * compositor drew it, and W23's "the two width authorities can never
1037
+ * disagree" was off by two.
1038
+ *
1039
+ * A PROVIDER, not a string, because both renderers are live in the
1040
+ * same process: the dock draws the row when it is active and
1041
+ * `selfRender` draws it when it is not, and they lead it differently.
1042
+ * Whoever binds the row answers for whichever is drawing. The default
1043
+ * is the brick, which is what `selfRender` has always drawn. */
1044
+ setInputLead(lead) {
1045
+ this.#inputLead = lead;
1046
+ this.#reflow();
1047
+ }
1048
+ #inputLead = () => PROMPT;
855
1049
  /** The row's own render when the dock is inactive (a TTY without a
856
1050
  * real size): \r + clear + blue brick prompt + visible + cursor
857
1051
  * column. */
@@ -862,7 +1056,7 @@ export class Editor {
862
1056
  // W21: the panel's lead owns the row while up (the brick returns
863
1057
  // when the panel closes).
864
1058
  const panel = this.#panelInput.state();
865
- const lead = panel !== null ? panelLead(panel.view, panel.phase, panel.cursor, panel.ask) : `${p.bold}${PROMPT}${p.reset}`;
1059
+ const lead = panel !== null ? panelLead(panel.view, panel.phase, panel.cursor, panel.ask) : `${p.bold}${this.#inputLead()}${p.reset}`;
866
1060
  // W23: the ONE width authority — leadWidth(lead), the ANSI-stripped
867
1061
  // visible width (the styled panel lead / the styled brick measure
868
1062
  // the same as their plain text — a lead can never measure
@@ -952,13 +1146,47 @@ export class Editor {
952
1146
  // never complete. The editor went deaf. It never happened
953
1147
  // because nothing ever enabled reporting — which is exactly
954
1148
  // the kind of latent break turning a feature on discovers.
955
- const m = rest.match(/^\[([0-9;?<]*)([A-Za-z~])/);
1149
+ //
1150
+ // TMUX-F1 ①: the whole CSI grammar — parameters (the private
1151
+ // markers `?` `<` `>` `=` included), intermediates (0x20–0x2F),
1152
+ // a final (0x40–0x7E). The class above could not END a sequence
1153
+ // carrying an intermediate — tmux answers DECRQM with
1154
+ // `ESC[?69;0$y` — so it was parked as "incomplete" and every
1155
+ // later keystroke joined a sequence that never completed: the
1156
+ // editor went deaf for the session. A sequence with
1157
+ // intermediates is a reply kiso did not ask for; it is skipped
1158
+ // whole. A parameter string past CSI_MAX with no final is
1159
+ // dropped, not held.
1160
+ const m = rest.match(/^\[([0-9;?<>=]*)([ -/]*)([@-~])/);
956
1161
  if (m === null) {
1162
+ const junk = /^\[[0-9;?<>=]*[ -/]*/.exec(rest)[0];
1163
+ if (junk.length > CSI_MAX) {
1164
+ i += 1 + junk.length; // the introducer and its runaway parameters go; parsing continues
1165
+ continue;
1166
+ }
957
1167
  this.#pending = text.slice(i); // incomplete CSI — wait for more
958
1168
  break;
959
1169
  }
960
- this.#csi(m[1], m[2]);
961
- i += m[0].length + 1;
1170
+ const seqLen = m[0].length + 1;
1171
+ if (m[2] !== "") {
1172
+ i += seqLen; // intermediates: a report kiso did not ask for — skipped whole
1173
+ continue;
1174
+ }
1175
+ // TMUX-F1 ②: a BURST of identical arrows in ONE read is a wheel,
1176
+ // not a hand. Apple Terminal turns wheel and trackpad scrolling
1177
+ // into arrow keys for an alternate-screen app (tmux's client is
1178
+ // one) — three or more per notch in a single write, which a
1179
+ // person never produces in one read — and each one walked the
1180
+ // history. The burst is one press (owner ruling 2026-09-10, c);
1181
+ // separate reads stay separate presses.
1182
+ const seq = `\x1b${m[0]}`;
1183
+ let run = 1;
1184
+ if (m[1] === "" && "ABCD".includes(m[3])) {
1185
+ while (text.startsWith(seq, i + run * seq.length))
1186
+ run += 1;
1187
+ }
1188
+ this.#csi(m[1], m[3], run);
1189
+ i += run * seqLen;
962
1190
  }
963
1191
  else if (rest.startsWith("]")) {
964
1192
  // DC-7: an OSC is a message FROM the terminal — a background
@@ -1041,7 +1269,6 @@ export class Editor {
1041
1269
  this.#checkpoint(); // UD-1
1042
1270
  this.#chars = [];
1043
1271
  this.#cursor = 0;
1044
- this.#scroll = 0;
1045
1272
  this.#verticalGoalCol = null;
1046
1273
  this.#refreshMenu();
1047
1274
  i += 1;
@@ -1209,6 +1436,33 @@ export class Editor {
1209
1436
  cb();
1210
1437
  i += 1;
1211
1438
  }
1439
+ else if (c === "\x14") {
1440
+ // §2.3 — ctrl+t folds the committed thinking blocks, and
1441
+ // folds them back. `\x14` was unbound across the tree
1442
+ // (checked before the round), and it is the key the
1443
+ // reference implementation uses for this same gesture, so a
1444
+ // reader arriving from it is not retrained.
1445
+ //
1446
+ // Forwarded, not interpreted: the switch is the
1447
+ // compositor's, exactly as ctrl+o's is.
1448
+ for (const cb of [...this.#thinkCbs])
1449
+ cb();
1450
+ i += 1;
1451
+ }
1452
+ else if (c === "\x07") {
1453
+ // §2.4 — ctrl+g opens $VISUAL / $EDITOR on the composer.
1454
+ //
1455
+ // 0x07 is BEL, which is also the terminator a terminal puts
1456
+ // on an OSC reply (DC-7). This branch is only reached by a
1457
+ // BARE 0x07: the OSC arm above consumes its own terminator,
1458
+ // so the terminal's answer never arrives here. One byte, two
1459
+ // meanings, told apart by what precedes it — and there is a
1460
+ // gate for exactly that, because "should not reach here" is
1461
+ // not something to take on trust.
1462
+ for (const cb of [...this.#editorCbs])
1463
+ cb();
1464
+ i += 1;
1465
+ }
1212
1466
  else if (c === "\x18" && this.#composerIdle()) {
1213
1467
  // E1 §3 — ctrl+x copies the last answer. `\x18` was unbound
1214
1468
  // across the whole tree (checked before the round started),
@@ -1323,7 +1577,22 @@ export class Editor {
1323
1577
  bindPanelRows(fn) {
1324
1578
  this.#panelRows = fn;
1325
1579
  }
1326
- #csi(params, final) {
1580
+ /** TMUX-F1 ②: `run` identical arrow sequences arrived in ONE read. Every
1581
+ * surface but one gets every press — a wheel over the transcript viewer,
1582
+ * a panel, a multi-row draft scrolls, as a wheel should. The HISTORY
1583
+ * walk is the one that replaces the draft's content, and there a burst
1584
+ * (ARROW_BURST or more in one read — a wheel notch, never a hand) is one
1585
+ * step. The first press decides which branch it was, so the gate is not
1586
+ * written twice. */
1587
+ #csi(params, final, run = 1) {
1588
+ const took = this.#csiOnce(params, final);
1589
+ if (took === "history" && run >= ARROW_BURST)
1590
+ return;
1591
+ for (let k = 1; k < run; k += 1)
1592
+ this.#csiOnce(params, final);
1593
+ }
1594
+ #csiOnce(params, final) {
1595
+ let took;
1327
1596
  // TUI2-R3v2 ②: an SGR 1006 report — `\x1b[<b;col;rowM` (press) or
1328
1597
  // `...m` (release). It is routed FIRST because a `<` parameter is
1329
1598
  // never anything else, and because a mouse byte must never fall
@@ -1434,11 +1703,18 @@ export class Editor {
1434
1703
  const view = this.#atView();
1435
1704
  this.#atSel = final === "A" ? Math.max(0, view.selected - 1) : Math.min(view.matches.length - 1, view.selected + 1);
1436
1705
  }
1437
- else if (this.#chars.includes(NEWLINE)) {
1438
- // KC1 §4: a MULTI-LINE buffer's ↑↓ walk its lines. The
1439
- // history and the queue-pop below stay gated on an EMPTY
1440
- // buffer — a multi-line buffer is never empty, so the
1441
- // precedence can only ever add, never take.
1706
+ else if (this.#visualRows().length > 1) {
1707
+ // KC1 §4, restated by OR-11 over VISUAL rows: a buffer that
1708
+ // occupies more than one row has its ↑↓ walk them. It used
1709
+ // to read `#chars.includes(NEWLINE)`, which was the same
1710
+ // question while one logical line was always one row; a
1711
+ // folded line is several now, and the walk is about what the
1712
+ // eye sees.
1713
+ //
1714
+ // The history and the queue-pop below stay gated on an EMPTY
1715
+ // buffer, and an empty buffer is exactly one row — so the
1716
+ // precedence can only ever add, never take, which is what
1717
+ // T-E4 pins.
1442
1718
  this.#verticalMove(final === "A" ? -1 : 1);
1443
1719
  }
1444
1720
  else if (final === "A" && this.#queuePop !== null && (this.#queuePopMode || this.line() === "") && this.#queueState().length > 0) {
@@ -1452,6 +1728,7 @@ export class Editor {
1452
1728
  }
1453
1729
  else if (this.#historyIdx !== null || this.line() === "") {
1454
1730
  this.#historyMove(final === "A" ? -1 : 1);
1731
+ took = "history"; // the repaint below is still owed — an early return here left the recall unpainted (r3a red)
1455
1732
  }
1456
1733
  this.#onRender();
1457
1734
  }
@@ -1468,22 +1745,31 @@ export class Editor {
1468
1745
  this.#move(1);
1469
1746
  }
1470
1747
  else if (final === "H") {
1748
+ // OR-11 (b): and REPAINT. Ctrl+A and Ctrl+E render; their arrow
1749
+ // spellings reflowed and stopped there, so the caret stayed
1750
+ // where it had been until some later key happened to draw. A
1751
+ // gesture that moves the cursor is a gesture that shows it.
1471
1752
  this.#cursor = this.#cursorBounds().start; // A3: Home follows Ctrl+A — line-local
1472
1753
  this.#reflow();
1754
+ this.#onRender();
1473
1755
  }
1474
1756
  else if (final === "F") {
1475
1757
  this.#cursor = this.#cursorBounds().end; // A3: End follows Ctrl+E — line-local
1476
1758
  this.#reflow();
1759
+ this.#onRender();
1477
1760
  }
1761
+ return took;
1478
1762
  }
1479
1763
  /** KC1 §4 — the ↑/↓ walk. The cursor keeps its DESIRED column across
1480
1764
  * a short line: the goal is captured at the FIRST vertical move and
1481
- * survives consecutive ones (#reflow clears it, so any horizontal
1765
+ * survives consecutive ones (#reflow clears it, so any left/right
1482
1766
  * move / insert / delete ends the walk); a step past either end
1483
1767
  * stays put. */
1484
1768
  #verticalMove(delta) {
1485
- const bounds = this.#lineBounds();
1486
- const cur = this.#cursorLine(bounds);
1769
+ // OR-11: VISUAL rows — one long logical line is several of them, and
1770
+ // ↑/↓ walk what the eye sees. The goal column is unchanged.
1771
+ const bounds = this.#visualRows();
1772
+ const cur = this.#cursorVisualRow(bounds);
1487
1773
  const next = cur + delta;
1488
1774
  if (next < 0 || next >= bounds.length)
1489
1775
  return;
@@ -1508,16 +1794,14 @@ export class Editor {
1508
1794
  clear: () => {
1509
1795
  this.#chars = [];
1510
1796
  this.#cursor = 0;
1511
- this.#scroll = 0;
1512
1797
  this.#verticalGoalCol = null;
1513
1798
  },
1514
1799
  insert: (cp) => this.#insert(cp),
1515
1800
  newline: () => this.#insert(NEWLINE),
1516
- stash: () => ({ chars: this.#chars, cursor: this.#cursor, scroll: this.#scroll }),
1801
+ stash: () => ({ chars: this.#chars, cursor: this.#cursor }),
1517
1802
  restore: (st) => {
1518
1803
  this.#chars = [...st.chars];
1519
1804
  this.#cursor = st.cursor;
1520
- this.#scroll = st.scroll;
1521
1805
  },
1522
1806
  reflow: () => this.#reflow(),
1523
1807
  render: () => this.#onRender(),
@@ -1615,7 +1899,6 @@ export class Editor {
1615
1899
  #restoreSnap(s) {
1616
1900
  this.#chars = [...s.chars];
1617
1901
  this.#cursor = s.cursor;
1618
- this.#scroll = 0;
1619
1902
  this.#verticalGoalCol = null;
1620
1903
  this.#reflow();
1621
1904
  this.#refreshMenu();
@@ -1778,8 +2061,8 @@ export class Editor {
1778
2061
  this.#cursor = this.#wordEdge(this.#cursor, dir);
1779
2062
  this.#reflow();
1780
2063
  }
1781
- /** KC1/KC2 — the buffer LEAVES: the flat chars, the cursor, the
1782
- * horizontal scroll, the ↑/↓ goal, the menu and the pop-walk all
2064
+ /** KC1/KC2 — the buffer LEAVES: the flat chars, the cursor, the ↑/↓
2065
+ * goal, the menu and the pop-walk all
1783
2066
  * reset together (W22: a departing line ends the pop-walk, so the
1784
2067
  * next esc at rest interrupts again). Shared by the submit and the
1785
2068
  * redirect — the two doors a line can leave by. */
@@ -1892,7 +2175,6 @@ export class Editor {
1892
2175
  this.#redoStack.length = 0;
1893
2176
  this.#chars = [];
1894
2177
  this.#cursor = 0;
1895
- this.#scroll = 0;
1896
2178
  this.#verticalGoalCol = null;
1897
2179
  this.#menuOpen = false;
1898
2180
  this.#menuSel = 0;
@@ -2061,50 +2343,41 @@ export class Editor {
2061
2343
  this.#checkpoint(); // UD-1: a mid-walk edit is recoverable
2062
2344
  this.#chars = [...line].map((ch) => ch.codePointAt(0));
2063
2345
  this.#cursor = this.#chars.length;
2064
- this.#scroll = 0;
2065
2346
  this.#verticalGoalCol = null;
2066
2347
  this.#onRender();
2067
2348
  }
2068
- // ---- width-based horizontal scroll ----
2349
+ // ---- the width walks ----
2069
2350
  #reflow() {
2070
2351
  // KC1: any key that reaches the reflow ended a ↑/↓ walk (the walk
2071
2352
  // itself re-arms the goal right after its own reflow call).
2072
2353
  this.#verticalGoalCol = null;
2073
- const W = (process.stdout.columns ?? 0) || 80; // degenerate 0 falls back to 80
2074
- // W21: the panel's phase lead owns the input row while up — the
2075
- // line's max width follows the lead (the rule/amend leads are
2076
- // wider than the brick).
2077
- // W23: the ONE width authority — leadWidth(lead) — the cap follows
2078
- // the lead the editor itself renders (the panel lead when the panel
2079
- // owns the keys, the brick otherwise): maxW = W − walls − lead.
2080
- const ps = this.#panelInput.state();
2081
- const lead = ps !== null ? panelLead(ps.view, ps.phase, ps.cursor, ps.ask) : PROMPT;
2082
- const leadW = leadWidth(lead);
2083
- // DC-17: ONE column, not four. W6's box took 2+2 and this kept
2084
- // reserving them after law 1.1 retired it — so the horizontal
2085
- // scroll fired three columns early, the cursor could never reach
2086
- // the row's last three cells, and the two width authorities the
2087
- // W23 contract says must never disagree disagreed by 3. The
2088
- // compositor's walk caps at W−1 (the drawn cursor's own cell);
2089
- // this is the same one column, on the same row.
2090
- const maxW = Math.max(1, W - leadW - 1);
2091
- // KC1: the scroll is the CURSOR LINE's own offset — a single-line
2092
- // buffer's line starts at 0, so the math is today's exactly. The
2093
- // clamp catches a walk onto a line SHORTER than the old offset.
2094
- const { start, end } = this.#cursorBounds();
2095
- this.#scroll = Math.min(this.#scroll, end - start);
2096
- const curCol = widthOf(this.#chars.slice(start, this.#cursor));
2097
- const scrolledW = widthOf(this.#chars.slice(start, start + this.#scroll));
2098
- if (curCol < scrolledW) {
2099
- this.#scroll = this.#indexAtWidth(start, end, curCol) - start;
2100
- }
2101
- else if (curCol >= scrolledW + maxW) {
2102
- this.#scroll = this.#indexAtWidth(start, end, Math.max(0, curCol - maxW + 1)) - start;
2103
- }
2354
+ // OR-11 — DECLARED SUPERSESSION of ADR-0039 Amendment 2's horizontal
2355
+ // scrolling. There is nothing to reflow horizontally any more: a
2356
+ // long line FOLDS (see #foldLine), so every character is on screen
2357
+ // and the row offset that used to be kept here is gone with it.
2358
+ // What survives is the goal-column reset above, which every key
2359
+ // that is not a ↑/↓ walk still owes.
2104
2360
  }
2105
2361
  /** The first index in [start, end] whose display width from `start`
2106
2362
  * reaches `target` — the width-based column walk (a wide char never
2107
2363
  * splits: the index lands BEFORE it). */
2364
+ /** OR-11 — the end of the WIDEST PREFIX of [start, end) that fits in
2365
+ * `budget` columns. Distinct from `#indexAtWidth`, which answers the
2366
+ * scroll's question ("the first index AT or past this width") and is
2367
+ * one character too generous for a fold: 20 wide characters are 40
2368
+ * columns and do not fit in 39. The walk stops BEFORE a character
2369
+ * that would overflow, so a wide character is never split — the same
2370
+ * `w + cw > limit` the compositor's own row cut uses. */
2371
+ #fitsWithin(start, end, budget) {
2372
+ let w = 0;
2373
+ for (let i = start; i < end; i += 1) {
2374
+ const cw = charWidth(this.#chars[i]);
2375
+ if (w + cw > budget)
2376
+ return i;
2377
+ w += cw;
2378
+ }
2379
+ return end;
2380
+ }
2108
2381
  #indexAtWidth(start, end, target) {
2109
2382
  let w = 0;
2110
2383
  for (let i = start; i < end; i += 1) {
@@ -17,11 +17,12 @@
17
17
  * goes through the host.
18
18
  */
19
19
  import { type PanelState, type PanelVerdict, type PanelView, type SaferAnswer } from "./approval-panel.js";
20
- /** The composer's buffer, as a band puts it aside and gets it back. */
20
+ /** The composer's buffer, as a band puts it aside and gets it back.
21
+ * OR-11 dropped `scroll`: a long line folds now, so there is no
22
+ * horizontal offset left to put aside. */
21
23
  export interface BufferStash {
22
24
  readonly chars: number[];
23
25
  readonly cursor: number;
24
- readonly scroll: number;
25
26
  }
26
27
  /** The composer, as a band controller is allowed to see it. Every
27
28
  * entry is something a panel or the session picker does to the
@@ -31,7 +32,7 @@ export interface BandHost {
31
32
  line(): string;
32
33
  /** The buffer with its paste capsules expanded — the text that would leave the editor. */
33
34
  expandPastes(line: string): string;
34
- /** Empty the buffer: chars, cursor, scroll and the ↑↓ goal column. */
35
+ /** Empty the buffer: chars, cursor and the ↑↓ goal column. */
35
36
  clear(): void;
36
37
  /** Type one code point at the cursor. */
37
38
  insert(cp: number): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-tui",
3
- "version": "0.31.1",
3
+ "version": "0.32.0",
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.31.1"
38
+ "@vincemakes/kiso-tui-cells": "0.32.0"
39
39
  }
40
40
  }