@vincemakes/kiso-tui 0.31.0 → 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.
package/dist/ask-panel.js CHANGED
@@ -460,7 +460,7 @@ export function panelStatus(view, phase, cursor, ask, pick) {
460
460
  }
461
461
  export function panelAffordance(view, phase, cursor, ask, pick, safer) {
462
462
  if (view.pick !== undefined && pick !== undefined)
463
- return pickAffordance(pick);
463
+ return pickAffordance(pick, view.pick.options[pick.cursor]?.levels !== undefined);
464
464
  if (view.ask !== undefined && ask !== undefined)
465
465
  return askAffordance(ask, view.ask.questions[ask.qIndex]);
466
466
  return basePanelAffordance(view, phase, cursor, safer);
@@ -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). */
@@ -58,6 +58,10 @@ import { sessionPickerRows } from "./session-picker.js";
58
58
  import { CAP_PREVIEW, thinkingRow, Container, MOTION_FRAMES, MdStream, bodySpacing, boxBottom, boxTop, cellComponent, gutterCut, cutLine, pendingQueueRows, statusLine, visibleWidth, breathFrame, } from "./components.js";
59
59
  import { bannerLines, escapeTerminal, foldResult, foldThinking, palette, renderTerminalGap, renderToolSummary } from "./lines.js";
60
60
  import { displayVerb, keysSheetRows } from "./strings.js";
61
+ /** DC-56: does a rendered row carry any visible text once its SGR is
62
+ * stripped? A washed pad row is spaces under a background colour — width
63
+ * without words. */
64
+ const saysSomething = (row) => row.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, "").trim() !== "";
61
65
  // R5 — the transcript viewer's PURE projection. The compositor supplies
62
66
  // the entries (it holds the cells); the arrangement lives there.
63
67
  import { VIEWER_GUTTER, viewerFlat, viewerHint, viewerInit, viewerMove, viewerRows, viewerScroll, viewerTitle, viewerToggle, viewerToggleAll, } from "./transcript.js";
@@ -198,6 +202,9 @@ export class Body {
198
202
  #lastTool = null;
199
203
  #pendingCalls = new Map();
200
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;
201
208
  /** TUI2-MD ⑤ — the markdown scanner of the message currently
202
209
  * streaming, and the cell index its first block landed at. Null
203
210
  * between messages: the scanner's life is one assistant message. */
@@ -386,6 +393,9 @@ export class Body {
386
393
  const last = this.#cells[this.#cells.length - 1];
387
394
  if (last !== undefined && last.kind === "thinking" && !last.done) {
388
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;
389
399
  this.#lastThinking = last.text;
390
400
  if (!this.#isActive())
391
401
  this.#write(foldThinking(last.text));
@@ -936,7 +946,15 @@ export class Body {
936
946
  cell.expanded = true;
937
947
  const rows = cellComponent(cell).render(inner, ctx);
938
948
  cell.expanded = saved;
939
- out.push({ head: rows[0] ?? "", body: rows.slice(1) });
949
+ // DC-56 (owner, 2026-09-09): the head is the first row that SAYS
950
+ // something. R13's card wears a washed PAD row above its head on
951
+ // the palettes with a real background (light, dark) and none under
952
+ // the neutral reverse-video wash — so `rows[0]` was the head on
953
+ // one palette and a blank washed row on the others, and ctrl+r on
954
+ // a light terminal showed "5 folds" over five empty grey rows.
955
+ const first = rows.findIndex((r) => saysSomething(r));
956
+ const headAt = first < 0 ? 0 : first;
957
+ out.push({ head: rows[headAt] ?? "", body: rows.slice(headAt + 1) });
940
958
  }
941
959
  return out;
942
960
  }
@@ -1291,11 +1309,12 @@ export class Body {
1291
1309
  /**
1292
1310
  * R14 — ERASE THE TERMINAL AND PRINT THE SESSION AGAIN.
1293
1311
  *
1294
- * Two callers: a settled resize, and DC-50's ctrl+o. They are the
1295
- * same act — the rendering the terminal holds is wrong (wrong
1296
- * geometry, or wrong expansion state) and the model is the only
1297
- * authority on what it should be — so they share the path rather
1298
- * 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.
1299
1318
  */
1300
1319
  #reprint() {
1301
1320
  const H = this.#opts.height();
@@ -1355,10 +1374,36 @@ export class Body {
1355
1374
  }
1356
1375
  this.#reprint();
1357
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
+ }
1358
1383
  /** DC-50 — the switch itself, for the CLI's affordance text. */
1359
1384
  expandedAll() {
1360
1385
  return this.#expandedAll;
1361
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
+ }
1362
1407
  /** W18: the status row's right-aligned hint is part of the status
1363
1408
  * state — the compacting row passes "esc to cancel" (the affordance
1364
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
  }
@@ -1462,25 +1739,37 @@ export class Editor {
1462
1739
  this.#move(-1);
1463
1740
  }
1464
1741
  else if (final === "C") {
1465
- this.#move(1);
1742
+ // OR-7: → walks the pick panel's level axis when one is up; the
1743
+ // composer keeps the key everywhere else, exactly as ← does.
1744
+ if (!this.#panelInput.right())
1745
+ this.#move(1);
1466
1746
  }
1467
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.
1468
1752
  this.#cursor = this.#cursorBounds().start; // A3: Home follows Ctrl+A — line-local
1469
1753
  this.#reflow();
1754
+ this.#onRender();
1470
1755
  }
1471
1756
  else if (final === "F") {
1472
1757
  this.#cursor = this.#cursorBounds().end; // A3: End follows Ctrl+E — line-local
1473
1758
  this.#reflow();
1759
+ this.#onRender();
1474
1760
  }
1761
+ return took;
1475
1762
  }
1476
1763
  /** KC1 §4 — the ↑/↓ walk. The cursor keeps its DESIRED column across
1477
1764
  * a short line: the goal is captured at the FIRST vertical move and
1478
- * survives consecutive ones (#reflow clears it, so any horizontal
1765
+ * survives consecutive ones (#reflow clears it, so any left/right
1479
1766
  * move / insert / delete ends the walk); a step past either end
1480
1767
  * stays put. */
1481
1768
  #verticalMove(delta) {
1482
- const bounds = this.#lineBounds();
1483
- 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);
1484
1773
  const next = cur + delta;
1485
1774
  if (next < 0 || next >= bounds.length)
1486
1775
  return;
@@ -1505,16 +1794,14 @@ export class Editor {
1505
1794
  clear: () => {
1506
1795
  this.#chars = [];
1507
1796
  this.#cursor = 0;
1508
- this.#scroll = 0;
1509
1797
  this.#verticalGoalCol = null;
1510
1798
  },
1511
1799
  insert: (cp) => this.#insert(cp),
1512
1800
  newline: () => this.#insert(NEWLINE),
1513
- stash: () => ({ chars: this.#chars, cursor: this.#cursor, scroll: this.#scroll }),
1801
+ stash: () => ({ chars: this.#chars, cursor: this.#cursor }),
1514
1802
  restore: (st) => {
1515
1803
  this.#chars = [...st.chars];
1516
1804
  this.#cursor = st.cursor;
1517
- this.#scroll = st.scroll;
1518
1805
  },
1519
1806
  reflow: () => this.#reflow(),
1520
1807
  render: () => this.#onRender(),
@@ -1612,7 +1899,6 @@ export class Editor {
1612
1899
  #restoreSnap(s) {
1613
1900
  this.#chars = [...s.chars];
1614
1901
  this.#cursor = s.cursor;
1615
- this.#scroll = 0;
1616
1902
  this.#verticalGoalCol = null;
1617
1903
  this.#reflow();
1618
1904
  this.#refreshMenu();
@@ -1775,8 +2061,8 @@ export class Editor {
1775
2061
  this.#cursor = this.#wordEdge(this.#cursor, dir);
1776
2062
  this.#reflow();
1777
2063
  }
1778
- /** KC1/KC2 — the buffer LEAVES: the flat chars, the cursor, the
1779
- * 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
1780
2066
  * reset together (W22: a departing line ends the pop-walk, so the
1781
2067
  * next esc at rest interrupts again). Shared by the submit and the
1782
2068
  * redirect — the two doors a line can leave by. */
@@ -1889,7 +2175,6 @@ export class Editor {
1889
2175
  this.#redoStack.length = 0;
1890
2176
  this.#chars = [];
1891
2177
  this.#cursor = 0;
1892
- this.#scroll = 0;
1893
2178
  this.#verticalGoalCol = null;
1894
2179
  this.#menuOpen = false;
1895
2180
  this.#menuSel = 0;
@@ -2058,50 +2343,41 @@ export class Editor {
2058
2343
  this.#checkpoint(); // UD-1: a mid-walk edit is recoverable
2059
2344
  this.#chars = [...line].map((ch) => ch.codePointAt(0));
2060
2345
  this.#cursor = this.#chars.length;
2061
- this.#scroll = 0;
2062
2346
  this.#verticalGoalCol = null;
2063
2347
  this.#onRender();
2064
2348
  }
2065
- // ---- width-based horizontal scroll ----
2349
+ // ---- the width walks ----
2066
2350
  #reflow() {
2067
2351
  // KC1: any key that reaches the reflow ended a ↑/↓ walk (the walk
2068
2352
  // itself re-arms the goal right after its own reflow call).
2069
2353
  this.#verticalGoalCol = null;
2070
- const W = (process.stdout.columns ?? 0) || 80; // degenerate 0 falls back to 80
2071
- // W21: the panel's phase lead owns the input row while up — the
2072
- // line's max width follows the lead (the rule/amend leads are
2073
- // wider than the brick).
2074
- // W23: the ONE width authority — leadWidth(lead) — the cap follows
2075
- // the lead the editor itself renders (the panel lead when the panel
2076
- // owns the keys, the brick otherwise): maxW = W − walls − lead.
2077
- const ps = this.#panelInput.state();
2078
- const lead = ps !== null ? panelLead(ps.view, ps.phase, ps.cursor, ps.ask) : PROMPT;
2079
- const leadW = leadWidth(lead);
2080
- // DC-17: ONE column, not four. W6's box took 2+2 and this kept
2081
- // reserving them after law 1.1 retired it — so the horizontal
2082
- // scroll fired three columns early, the cursor could never reach
2083
- // the row's last three cells, and the two width authorities the
2084
- // W23 contract says must never disagree disagreed by 3. The
2085
- // compositor's walk caps at W−1 (the drawn cursor's own cell);
2086
- // this is the same one column, on the same row.
2087
- const maxW = Math.max(1, W - leadW - 1);
2088
- // KC1: the scroll is the CURSOR LINE's own offset — a single-line
2089
- // buffer's line starts at 0, so the math is today's exactly. The
2090
- // clamp catches a walk onto a line SHORTER than the old offset.
2091
- const { start, end } = this.#cursorBounds();
2092
- this.#scroll = Math.min(this.#scroll, end - start);
2093
- const curCol = widthOf(this.#chars.slice(start, this.#cursor));
2094
- const scrolledW = widthOf(this.#chars.slice(start, start + this.#scroll));
2095
- if (curCol < scrolledW) {
2096
- this.#scroll = this.#indexAtWidth(start, end, curCol) - start;
2097
- }
2098
- else if (curCol >= scrolledW + maxW) {
2099
- this.#scroll = this.#indexAtWidth(start, end, Math.max(0, curCol - maxW + 1)) - start;
2100
- }
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.
2101
2360
  }
2102
2361
  /** The first index in [start, end] whose display width from `start`
2103
2362
  * reaches `target` — the width-based column walk (a wide char never
2104
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
+ }
2105
2381
  #indexAtWidth(start, end, target) {
2106
2382
  let w = 0;
2107
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;
@@ -97,8 +98,14 @@ export declare class PanelInput {
97
98
  * panel owned the key (the caller renders); false when no panel is
98
99
  * up. A phase without a list (asking) swallows the key. */
99
100
  arrow(dir: "up" | "down"): boolean;
100
- /** ← while an ask's option row is up walks the ask; otherwise the key is the composer's. */
101
+ /** ← while an ask's option row is up walks the ask; while a pick's is
102
+ * up it walks the highlighted option's level axis (OR-7); otherwise
103
+ * the key is the composer's. */
101
104
  left(): boolean;
105
+ /** → is the level axis's other direction. The ask never owned it (its
106
+ * own walk is ← back and enter forward), so this key was the
107
+ * composer's alone until OR-7 and still is when no axis is up. */
108
+ right(): boolean;
102
109
  /** TUI2-R3v2 ②: a left-button press on the option rows the
103
110
  * compositor placed. Outside the list — inert. */
104
111
  click(span: PanelRowSpan | null | undefined, row: number | undefined): void;
@@ -16,7 +16,7 @@
16
16
  * and the mouse hit. The bodies are the same; only the buffer access
17
17
  * goes through the host.
18
18
  */
19
- import { PICK_MAX, panelOptions, saferDegradedNote, } from "./approval-panel.js";
19
+ import { PICK_MAX, panelOptions, saferDegradedNote, startLevel, stepLevel, } from "./approval-panel.js";
20
20
  import { askCommitCustom, askKey, askOnCustomRow, askStart } from "./ask-panel.js";
21
21
  export class PanelInput {
22
22
  host;
@@ -49,7 +49,7 @@ export class PanelInput {
49
49
  safer: opts?.safer,
50
50
  saferRun: null,
51
51
  ask: view.ask === undefined ? null : askStart(view.ask),
52
- pick: view.pick === undefined ? null : { cursor: 0, phase: "options" },
52
+ pick: view.pick === undefined ? null : { cursor: 0, phase: "options", level: startLevel(view.pick.options[0]) },
53
53
  onCommit,
54
54
  stash: this.host.stash(),
55
55
  };
@@ -113,8 +113,10 @@ export class PanelInput {
113
113
  this.#pickPanelDigit(Number(c) - 1);
114
114
  return 1;
115
115
  }
116
+ if (!typing && (c === "h" || c === "l") && this.#levelStep(c === "h" ? -1 : 1))
117
+ return 1;
116
118
  if (!typing && (c === "t" || c === "T") && panel.view.pick?.typeHint !== undefined) {
117
- panel.pick = { cursor: panel.pick.cursor, phase: "custom" };
119
+ panel.pick = { cursor: panel.pick.cursor, phase: "custom", level: panel.pick.level };
118
120
  this.host.clear();
119
121
  this.host.render();
120
122
  return 1;
@@ -206,7 +208,12 @@ export class PanelInput {
206
208
  if (panel.pick !== null && panel.pick.phase === "options") {
207
209
  const n = Math.min(panel.view.pick.options.length, PICK_MAX);
208
210
  const cur = panel.pick.cursor;
209
- panel.pick = { cursor: dir === "up" ? Math.max(0, cur - 1) : Math.min(Math.max(0, n - 1), cur + 1), phase: "options" };
211
+ const next = dir === "up" ? Math.max(0, cur - 1) : Math.min(Math.max(0, n - 1), cur + 1);
212
+ // OR-7: the second axis belongs to the highlighted option, so it
213
+ // re-lands on THAT option's own level. Carrying the previous
214
+ // row's index across would point at a level this model may not
215
+ // have — the silent clamp the coordination note exists to end.
216
+ panel.pick = { cursor: next, phase: "options", level: startLevel(panel.view.pick.options[next]) };
210
217
  }
211
218
  else if (panel.ask !== null && panel.ask.phase === "options")
212
219
  this.#askStep(dir);
@@ -216,14 +223,37 @@ export class PanelInput {
216
223
  this.#panelMove(dir === "up" ? -1 : 1);
217
224
  return true;
218
225
  }
219
- /** ← while an ask's option row is up walks the ask; otherwise the key is the composer's. */
226
+ /** ← while an ask's option row is up walks the ask; while a pick's is
227
+ * up it walks the highlighted option's level axis (OR-7); otherwise
228
+ * the key is the composer's. */
220
229
  left() {
230
+ if (this.#levelStep(-1))
231
+ return true;
221
232
  const panel = this.#panel;
222
233
  if (panel?.ask == null || panel.ask.phase !== "options")
223
234
  return false;
224
235
  this.#askStep("left");
225
236
  return true;
226
237
  }
238
+ /** → is the level axis's other direction. The ask never owned it (its
239
+ * own walk is ← back and enter forward), so this key was the
240
+ * composer's alone until OR-7 and still is when no axis is up. */
241
+ right() {
242
+ return this.#levelStep(1);
243
+ }
244
+ /** One step along the highlighted option's levels. False when there is
245
+ * no axis to walk, which is what leaves the key to the composer. */
246
+ #levelStep(dir) {
247
+ const panel = this.#panel;
248
+ if (panel === null || panel.pick === null || panel.pick.phase !== "options")
249
+ return false;
250
+ const o = panel.view.pick?.options[panel.pick.cursor];
251
+ if (o?.levels === undefined)
252
+ return false;
253
+ panel.pick = { cursor: panel.pick.cursor, phase: "options", level: stepLevel(o, panel.pick.level, dir) };
254
+ this.host.render();
255
+ return true;
256
+ }
227
257
  /** TUI2-R3v2 ②: a left-button press on the option rows the
228
258
  * compositor placed. Outside the list — inert. */
229
259
  click(span, row) {
@@ -420,7 +450,7 @@ export class PanelInput {
420
450
  return;
421
451
  if (index < 0 || index >= Math.min(panel.view.pick.options.length, PICK_MAX))
422
452
  return;
423
- panel.pick = { cursor: index, phase: "options" };
453
+ panel.pick = { cursor: index, phase: "options", level: startLevel(panel.view.pick.options[index]) };
424
454
  this.host.render();
425
455
  }
426
456
  #pickPanelEnter() {
@@ -436,14 +466,15 @@ export class PanelInput {
436
466
  }
437
467
  if (panel.view.pick.options.length === 0)
438
468
  return; // nothing to take
439
- this.#close({ action: "picked", result: { index: panel.pick.cursor } });
469
+ const level = panel.pick.level;
470
+ this.#close({ action: "picked", result: { index: panel.pick.cursor }, ...(level === null ? {} : { level }) });
440
471
  }
441
472
  #pickPanelEsc() {
442
473
  const panel = this.#panel;
443
474
  if (panel === null || panel.pick === null)
444
475
  return;
445
476
  if (panel.pick.phase === "custom") {
446
- panel.pick = { cursor: panel.pick.cursor, phase: "options" };
477
+ panel.pick = { cursor: panel.pick.cursor, phase: "options", level: panel.pick.level };
447
478
  this.host.clear();
448
479
  this.host.render();
449
480
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-tui",
3
- "version": "0.31.0",
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.0"
38
+ "@vincemakes/kiso-tui-cells": "0.32.0"
39
39
  }
40
40
  }