@vincemakes/kiso-tui 0.8.0 → 0.10.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.
@@ -0,0 +1,66 @@
1
+ /**
2
+ * TUI2-R1 (E) — /context's attribution rows.
3
+ *
4
+ * The question "where did my context go?" has had an answer since E3:
5
+ * the trace sidecar's rent ledger records, per request, exactly what
6
+ * each static surface costs, and the context manifest records what the
7
+ * conversation costs. Until now that answer was only readable by
8
+ * someone willing to parse JSONL.
9
+ *
10
+ * This module is the presentation half and nothing else — a pure
11
+ * function from counts to rows, with no idea where the counts came
12
+ * from. That matters for the purity gate: the trace surface is an
13
+ * OBSERVATION surface (ADR-0051 §6), correctness never reads it, and
14
+ * keeping the reader in the CLI and the renderer here means this module
15
+ * cannot accidentally become a second correctness path.
16
+ *
17
+ * Every number is a count the ledger already carries. Nothing here
18
+ * estimates, projects, or predicts.
19
+ */
20
+ /** The counts one request's ledger yields, already grouped by surface.
21
+ * Estimated tokens throughout (the rent ledger's own chars/4 convention
22
+ * — R6), because that is the unit the ledger records. */
23
+ export interface ContextLedger {
24
+ /** The model's context window, as the session is configured. */
25
+ readonly window: number;
26
+ /** system:base + every system:ext:* append EXCEPT skills. */
27
+ readonly systemPrompt: number;
28
+ /** system:base alone — the detail behind the row. */
29
+ readonly systemBase: number;
30
+ /** how many extensions appended (the detail's count). */
31
+ readonly appends: number;
32
+ /** the sum of the tool:* lines. */
33
+ readonly toolTable: number;
34
+ readonly tools: number;
35
+ /** system:ext:skills — broken out because it is an INDEX of content
36
+ * rather than an instruction, and it grows with the workspace rather
37
+ * than with the build. 0 when the extension is not loaded. */
38
+ readonly skillsIndex: number;
39
+ /** how many skills the index lists — 0 when the caller cannot know
40
+ * (the rent ledger records surfaces, never their contents). */
41
+ readonly skills: number;
42
+ /** the per-request skeleton (the `envelope` rent line). */
43
+ readonly envelope: number;
44
+ /** the context manifest's turn segments — the conversation itself. */
45
+ readonly messages: number;
46
+ readonly turns: number;
47
+ }
48
+ /**
49
+ * The rows: the header, the bar, one row per surface that EXISTS, and
50
+ * the free remainder.
51
+ *
52
+ * An absent surface is an absent row — the rent ledger's own R9 rule
53
+ * ("not paid = no rent"), carried into the display: a session with no
54
+ * skills extension should not read a "skills index 0" row, because the
55
+ * zero would look like a measurement rather than an absence.
56
+ *
57
+ * The columns are fixed so the numbers line up as a column of numbers;
58
+ * the detail text rides after them, dim, and is cut by the caller's
59
+ * width if it must be.
60
+ */
61
+ export declare function contextRows(ledger: ContextLedger): string[];
62
+ /** TUI2-R1 (E) — the honest fallback. The ledger is written PER REQUEST:
63
+ * a session that has not called the model yet has no sidecar, and the
64
+ * right thing to show is that fact and the one step that produces one.
65
+ * Never an empty bar — an empty bar reads as "measured zero". */
66
+ export declare function contextUnavailableRows(reason: string): string[];
@@ -0,0 +1,87 @@
1
+ /**
2
+ * TUI2-R1 (E) — /context's attribution rows.
3
+ *
4
+ * The question "where did my context go?" has had an answer since E3:
5
+ * the trace sidecar's rent ledger records, per request, exactly what
6
+ * each static surface costs, and the context manifest records what the
7
+ * conversation costs. Until now that answer was only readable by
8
+ * someone willing to parse JSONL.
9
+ *
10
+ * This module is the presentation half and nothing else — a pure
11
+ * function from counts to rows, with no idea where the counts came
12
+ * from. That matters for the purity gate: the trace surface is an
13
+ * OBSERVATION surface (ADR-0051 §6), correctness never reads it, and
14
+ * keeping the reader in the CLI and the renderer here means this module
15
+ * cannot accidentally become a second correctness path.
16
+ *
17
+ * Every number is a count the ledger already carries. Nothing here
18
+ * estimates, projects, or predicts.
19
+ */
20
+ import { palette } from "./render.js";
21
+ const BAR_CELLS = 12;
22
+ /** k-units for the ledger's columns: 25700 → 25.7k, 300 → 300, 11 → 11.
23
+ *
24
+ * TUI2-R1.5 ⑤ (VD-15): the floor was 100, which put `11`, `0.3k` and
25
+ * `25.7k` in one right-aligned column — two unit systems stacked, and
26
+ * the reader has to switch between them row by row to compare. The
27
+ * repo already had a k-formatter with a 1000 floor (render.ts's kUnit,
28
+ * which the status row and every settled card use); this now agrees
29
+ * with it, so /context speaks the same number language as the rest of
30
+ * the product. It still differs from kUnit in never having a null to
31
+ * report — every ledger figure is a measured count. */
32
+ function k(n) {
33
+ return n >= 1000 ? `${(n / 1000).toFixed(1).replace(/\.0$/, "")}k` : String(Math.round(n));
34
+ }
35
+ /**
36
+ * The rows: the header, the bar, one row per surface that EXISTS, and
37
+ * the free remainder.
38
+ *
39
+ * An absent surface is an absent row — the rent ledger's own R9 rule
40
+ * ("not paid = no rent"), carried into the display: a session with no
41
+ * skills extension should not read a "skills index 0" row, because the
42
+ * zero would look like a measurement rather than an absence.
43
+ *
44
+ * The columns are fixed so the numbers line up as a column of numbers;
45
+ * the detail text rides after them, dim, and is cut by the caller's
46
+ * width if it must be.
47
+ */
48
+ export function contextRows(ledger) {
49
+ const p = palette();
50
+ const used = ledger.systemPrompt + ledger.toolTable + ledger.skillsIndex + ledger.envelope + ledger.messages;
51
+ const free = Math.max(0, ledger.window - used);
52
+ 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)));
54
+ const rows = [
55
+ `${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}`,
57
+ ];
58
+ /** One surface row: the label at 14 columns, the count right-aligned
59
+ * at 5, then the dim detail. */
60
+ const row = (label, value, detail) => ` ${p.bold}▰${p.reset} ${label.padEnd(14)}${p.bold}${k(value).padStart(5)}${p.reset}${detail === "" ? "" : ` ${p.dim}${detail}${p.reset}`}`;
61
+ if (ledger.systemPrompt > 0) {
62
+ rows.push(row("system prompt", ledger.systemPrompt, `(base ${k(ledger.systemBase)}${ledger.appends > 0 ? ` + ${ledger.appends} extension append${ledger.appends === 1 ? "" : "s"}` : ""})`));
63
+ }
64
+ if (ledger.toolTable > 0)
65
+ rows.push(row("tool table", ledger.toolTable, `${ledger.tools} tool${ledger.tools === 1 ? "" : "s"}`));
66
+ if (ledger.skillsIndex > 0) {
67
+ // the skill COUNT is not in the ledger (rent records surfaces, not
68
+ // their contents) — a caller that knows it passes it, and a caller
69
+ // that does not gets the honest half of the sentence rather than a
70
+ // fabricated number.
71
+ rows.push(row("skills index", ledger.skillsIndex, `${ledger.skills > 0 ? `${ledger.skills} skill${ledger.skills === 1 ? "" : "s"}, ` : ""}tier-1 lines only`));
72
+ }
73
+ if (ledger.envelope > 0)
74
+ rows.push(row("envelope", ledger.envelope, ""));
75
+ if (ledger.messages > 0)
76
+ rows.push(row("messages", ledger.messages, `${ledger.turns} turn${ledger.turns === 1 ? "" : "s"}`));
77
+ rows.push(` ${p.dim}▱ ${"free".padEnd(14)}${k(free).padStart(5)}${p.reset}`);
78
+ return rows;
79
+ }
80
+ /** TUI2-R1 (E) — the honest fallback. The ledger is written PER REQUEST:
81
+ * a session that has not called the model yet has no sidecar, and the
82
+ * right thing to show is that fact and the one step that produces one.
83
+ * Never an empty bar — an empty bar reads as "measured zero". */
84
+ export function contextUnavailableRows(reason) {
85
+ const p = palette();
86
+ return [`${p.bold}context — no ledger yet${p.reset}`, ` ${p.dim}${reason}${p.reset}`];
87
+ }
package/dist/editor.d.ts CHANGED
@@ -23,8 +23,9 @@
23
23
  */
24
24
  import { charWidth, displayWidth, widthOf } from "./width.js";
25
25
  export { charWidth, displayWidth, widthOf };
26
- import type { PanelState, PanelVerdict, PanelView } from "./approval-panel.js";
26
+ import { type PanelState, type PanelVerdict, type PanelView } from "./approval-panel.js";
27
27
  import { type AtItem, type AtMatch } from "./at-picker.js";
28
+ import { type SessionCardView, type SessionPickState } from "./session-picker.js";
28
29
  export declare const PROMPT = "\u258C ";
29
30
  export declare const PROMPT_WIDTH: number;
30
31
  /** v3 §04 — the slash-command menu's command table (English one-liners). */
@@ -60,6 +61,9 @@ export declare class Editor {
60
61
  bindQueue(state: () => readonly string[], pop: () => string | null): void;
61
62
  /** The whole buffer as text (the CLI's line()/clearLine()). */
62
63
  line(): string;
64
+ /** TUI2-R1 (D): whether the keys sheet is up — the compositor's slot
65
+ * read (bound like the menu and the picker). */
66
+ sheetOpen(): boolean;
63
67
  clearLine(): void;
64
68
  /** The dock's input-row state — ADDITIVE (§5): `line` + `cursor` keep
65
69
  * their legacy meaning (the CURSOR LINE's visible slice and the
@@ -98,6 +102,12 @@ export declare class Editor {
98
102
  selected: number;
99
103
  capped: boolean;
100
104
  } | null;
105
+ /** Open the picker on a bound card source. The composer is cleared
106
+ * (the buffer becomes the filter query) and `onPick` receives the
107
+ * chosen id — or null when the human leaves without picking, which
108
+ * is a first-class outcome and not an error. */
109
+ beginPick(cards: () => readonly SessionCardView[], onPick: (id: string | null) => void): void;
110
+ pickState(): SessionPickState | null;
101
111
  /** One-shot question mode: the NEXT submit answers, not a turn. */
102
112
  question(_query: string, cb: (answer: string) => void): void;
103
113
  /** Cancel a pending question — the buffer stays (its text becomes the
package/dist/editor.js CHANGED
@@ -26,10 +26,15 @@ import { charWidth, displayWidth, leadWidth, widthOf } from "./width.js";
26
26
  // authority) — re-exported so the editor's public surface is unchanged.
27
27
  export { charWidth, displayWidth, widthOf };
28
28
  import { palette } from "./render.js";
29
+ import { PICK_MAX } from "./approval-panel.js";
29
30
  // KC3.5: the panel-slot dispatchers — the ask branch folded into the
30
31
  // W21 lead/rows, so this file keeps ONE panel and one key owner.
31
32
  import { askCommitCustom, askKey, askStart, panelLead } from "./ask-panel.js";
32
33
  import { AT_VISIBLE, atFilter } from "./at-picker.js";
34
+ // TUI2-R2 ②: the session picker — the band's THIRD occupant. Its filter
35
+ // is the @ picker's rank aimed at the session id; the editor owns the
36
+ // keys, the compositor draws the rows.
37
+ import { sessionFilter } from "./session-picker.js";
33
38
  // TUI v4 #16d: the input row is the blue brick + the edit area — the
34
39
  // "you>" text is gone (the brick IS the prompt; the pipe path's readline
35
40
  // prompt keeps its own "you> " — v2a line mode, byte-for-byte).
@@ -42,6 +47,8 @@ export const MENU_ITEMS = [
42
47
  { name: "/think", desc: "show the last full thinking block" },
43
48
  { name: "/last", desc: "show the most recent tool call's input and output" },
44
49
  { name: "/status", desc: "show session id, event count, and context estimate" },
50
+ // TUI2-R1 (E): the rent-ledger attribution — where the context went
51
+ { name: "/context", desc: "show where the context went — the last request's rent ledger" },
45
52
  { name: "/help", desc: "print this list of commands" },
46
53
  ];
47
54
  /** KC1 §3 — the newline code point. Every source (paste, Ctrl+J, the
@@ -109,6 +116,12 @@ export class Editor {
109
116
  // coexist; the editor never interprets the key itself.
110
117
  #expandCbs = [];
111
118
  #onRender;
119
+ /** TUI2-R1 (D): the keys sheet — a static one-screen overlay opened by
120
+ * `?` on an empty composer and closed by the next key, whatever it
121
+ * is. Deliberately a BOOLEAN and not a panel: the panel machinery
122
+ * exists for interactions (a lead, a status, a reducer, a stashed
123
+ * buffer), and the sheet has no interaction to speak of. */
124
+ #sheetOpen = false;
112
125
  #menuOpen = false; // v3 §04: the slash-command menu
113
126
  #menuSel = 0;
114
127
  // KC3 §3 — the @ file picker. THREE fields and no more: the armed
@@ -127,6 +140,20 @@ export class Editor {
127
140
  // next open re-snapshots, so a stale list can never be shown.
128
141
  #atList = null;
129
142
  #atItems = null;
143
+ // TUI2-R2 ② — the session picker. Two fields: the bound source (its
144
+ // presence IS "the picker is up") and the selection. The query, like
145
+ // the @ picker's, is DERIVED from the buffer on every read rather
146
+ // than stored — so every existing buffer op (backspace, the kills,
147
+ // paste) filters correctly with no handler of its own.
148
+ //
149
+ // The picker is MODAL in a way the @ picker is not: it opens before
150
+ // a session exists, owns the whole composer, and the only ways out
151
+ // are a pick and an esc. That is why the commit callback lives here
152
+ // rather than on the line channel — the caller is waiting for an id,
153
+ // not for a turn.
154
+ #pickCards = null;
155
+ #pickCommit = null;
156
+ #pickSel = 0;
130
157
  // A2 (the feel): the session-scoped input history — every submitted TURN
131
158
  // line (never a question answer), capped at 100, never persisted. ↑↓
132
159
  // navigate it ONLY from an empty input or while already browsing.
@@ -194,6 +221,11 @@ export class Editor {
194
221
  line() {
195
222
  return String.fromCodePoint(...this.#chars);
196
223
  }
224
+ /** TUI2-R1 (D): whether the keys sheet is up — the compositor's slot
225
+ * read (bound like the menu and the picker). */
226
+ sheetOpen() {
227
+ return this.#sheetOpen;
228
+ }
197
229
  clearLine() {
198
230
  this.#chars = [];
199
231
  this.#cursor = 0;
@@ -241,7 +273,7 @@ export class Editor {
241
273
  * the frame's clamp is the authority. */
242
274
  #visibleRows(lineCount) {
243
275
  const H = process.stdout.rows ?? 24;
244
- const bands = (this.#menuOpen ? this.#menuFiltered().length : 0) + this.#atRows() + this.#queueState().length;
276
+ const bands = (this.#menuOpen ? this.#menuFiltered().length : 0) + this.#atRows() + this.#pickRows() + this.#queueState().length;
245
277
  return Math.max(1, Math.min(lineCount, N_MAX, Math.max(1, H - 3 - bands)));
246
278
  }
247
279
  /** The dock's input-row state — ADDITIVE (§5): `line` + `cursor` keep
@@ -379,6 +411,11 @@ export class Editor {
379
411
  #atArm() {
380
412
  if (this.#atItems === null)
381
413
  return;
414
+ // TUI2-R2 ②: not inside a session filter. An `@` typed into the
415
+ // picker's query is a character in a session id, and a file picker
416
+ // opening over a session picker would put two bands in one slot.
417
+ if (this.#pickUp())
418
+ return;
382
419
  if (this.#panel !== null || this.#menuOpen || this.#questionCb !== null)
383
420
  return;
384
421
  if (this.#atToken() === null)
@@ -424,6 +461,71 @@ export class Editor {
424
461
  const view = this.#atView();
425
462
  return view === null ? 0 : Math.min(view.matches.length, AT_VISIBLE) + 1;
426
463
  }
464
+ // ── TUI2-R2 ② — the session picker ───────────────────────────────
465
+ /** Open the picker on a bound card source. The composer is cleared
466
+ * (the buffer becomes the filter query) and `onPick` receives the
467
+ * chosen id — or null when the human leaves without picking, which
468
+ * is a first-class outcome and not an error. */
469
+ beginPick(cards, onPick) {
470
+ this.#pickCards = cards;
471
+ this.#pickCommit = onPick;
472
+ this.#pickSel = 0;
473
+ this.#chars = [];
474
+ this.#cursor = 0;
475
+ this.#reflow();
476
+ this.#onRender();
477
+ }
478
+ /** The picker's state, derived: the full card list (the id column
479
+ * measures over ALL of them, so the columns never jump), the
480
+ * filtered matches, and the selection CLAMPED at read time — the
481
+ * same correction discipline the @ picker uses, for the same
482
+ * reason: narrowing can only ever shrink the list. */
483
+ #pickView() {
484
+ if (this.#pickCards === null)
485
+ return null;
486
+ const cards = this.#pickCards();
487
+ const matches = sessionFilter(cards, this.line());
488
+ return { cards, matches, selected: Math.max(0, Math.min(this.#pickSel, matches.length - 1)) };
489
+ }
490
+ pickState() {
491
+ return this.#pickView();
492
+ }
493
+ #pickUp() {
494
+ return this.#pickCards !== null;
495
+ }
496
+ /** The band's height estimate: the header + the windowed rows (or
497
+ * the one "no match" row) + the counter. */
498
+ #pickRows() {
499
+ const view = this.#pickView();
500
+ return view === null ? 0 : Math.min(Math.max(view.matches.length, 1), AT_VISIBLE) + 2;
501
+ }
502
+ /** Close and hand the verdict back. The callback fires AFTER the
503
+ * state is cleared, so a caller that re-enters (a second picker, a
504
+ * session that starts) never sees the closing picker's rows. */
505
+ #pickClose(id) {
506
+ const cb = this.#pickCommit;
507
+ this.#pickCards = null;
508
+ this.#pickCommit = null;
509
+ this.#pickSel = 0;
510
+ this.#chars = [];
511
+ this.#cursor = 0;
512
+ this.#reflow();
513
+ cb?.(id);
514
+ this.#onRender();
515
+ }
516
+ /** Enter takes the SELECTED session. An empty match set takes
517
+ * nothing and leaves the picker up: a picker that invented a pick
518
+ * when the query matched nothing would resume the wrong session,
519
+ * which is the one failure this surface must never have. */
520
+ #pickAccept() {
521
+ const view = this.#pickView();
522
+ if (view === null)
523
+ return;
524
+ const card = view.matches[view.selected];
525
+ if (card === undefined)
526
+ return;
527
+ this.#pickClose(card.id);
528
+ }
427
529
  /** One-shot question mode: the NEXT submit answers, not a turn. */
428
530
  question(_query, cb) {
429
531
  this.#questionCb = cb;
@@ -442,6 +544,9 @@ export class Editor {
442
544
  phase: "options",
443
545
  sel: 0,
444
546
  ask: view.ask === undefined ? null : askStart(view.ask),
547
+ // TUI2-R2 ④: the pick's walk — present exactly when the view is
548
+ // a pick, the same contract the ask's runtime has.
549
+ pick: view.pick === undefined ? null : { cursor: 0, phase: "options" },
445
550
  amend: "yes",
446
551
  onCommit,
447
552
  stash: { chars: this.#chars, cursor: this.#cursor, scroll: this.#scroll },
@@ -466,7 +571,13 @@ export class Editor {
466
571
  const panel = this.#panel;
467
572
  if (panel === null)
468
573
  return null;
469
- return { view: panel.view, phase: panel.phase, sel: panel.sel, ...(panel.ask === null ? {} : { ask: panel.ask }) };
574
+ return {
575
+ view: panel.view,
576
+ phase: panel.phase,
577
+ sel: panel.sel,
578
+ ...(panel.ask === null ? {} : { ask: panel.ask }),
579
+ ...(panel.pick === null ? {} : { pick: panel.pick }),
580
+ };
470
581
  }
471
582
  enter() {
472
583
  if (this.#entered)
@@ -510,6 +621,17 @@ export class Editor {
510
621
  feed(raw) {
511
622
  const text = this.#pending + this.#decoder.decode(raw, { stream: true });
512
623
  this.#pending = "";
624
+ // TUI2-R1 (D): the sheet is up — ANY key closes it, and the key
625
+ // that closed it is CONSUMED. The whole chunk goes, deliberately:
626
+ // an arrow key is three bytes, and closing on the first while
627
+ // letting `[A` fall through as literal text would be a sheet that
628
+ // types into your composer on the way out. A dismissal costs one
629
+ // keystroke; that is the entire contract.
630
+ if (this.#sheetOpen) {
631
+ this.#sheetOpen = false;
632
+ this.#onRender();
633
+ return;
634
+ }
513
635
  let i = 0;
514
636
  while (i < text.length) {
515
637
  const c = text[i];
@@ -530,6 +652,44 @@ export class Editor {
530
652
  // only esc and enter are intercepted while typing), esc
531
653
  // declines the whole call. Everything else falls through to
532
654
  // the ordinary editing chain below.
655
+ // TUI2-R2 ④: a PICK panel routes its own keys — a digit moves
656
+ // the cursor to that option (never commits: the choice is
657
+ // CONFIRMED, so a mistyped digit is a mistake you can see
658
+ // before it takes effect), `t` opens the type-it line, enter
659
+ // commits, esc backs out then cancels. The swallow rule below
660
+ // is the ask's, for the ask's reason: a typed `/` must not arm
661
+ // the menu under a panel that owns the keys.
662
+ if (panel.pick !== null) {
663
+ const typing = panel.pick.phase === "custom";
664
+ if (c === "\x1b" && !text.slice(i + 1).startsWith("[") && !text.slice(i + 1).startsWith("O")) {
665
+ this.#pickPanelEsc();
666
+ i += 1;
667
+ continue;
668
+ }
669
+ if (c === "\x0d" || c === "\x0a") {
670
+ this.#pickPanelEnter();
671
+ i += 1;
672
+ continue;
673
+ }
674
+ if (!typing && c !== undefined && c >= "1" && c <= "9") {
675
+ this.#pickPanelDigit(Number(c) - 1);
676
+ i += 1;
677
+ continue;
678
+ }
679
+ if (!typing && (c === "t" || c === "T")) {
680
+ panel.pick = { cursor: panel.pick.cursor, phase: "custom" };
681
+ this.#chars = [];
682
+ this.#cursor = 0;
683
+ this.#scroll = 0;
684
+ this.#onRender();
685
+ i += 1;
686
+ continue;
687
+ }
688
+ if (!typing && c !== undefined && c >= " " && c !== "\x7f") {
689
+ i += 1;
690
+ continue;
691
+ }
692
+ }
533
693
  if (panel.ask !== null) {
534
694
  const typing = panel.ask.phase === "custom";
535
695
  if (c === "\x1b" && !text.slice(i + 1).startsWith("[") && !text.slice(i + 1).startsWith("O")) {
@@ -578,22 +738,49 @@ export class Editor {
578
738
  i += 1;
579
739
  continue;
580
740
  }
581
- if (c === "1" || c === "y" || c === "Y") {
582
- if (panel.phase === "options")
741
+ // TUI2-R2 the shortcut keys belong to the OPTIONS phase and
742
+ // to it alone.
743
+ //
744
+ // `1`/`y` select yes and `3`/`n` select no, and they used to be
745
+ // applied in every phase of every flavour: the `i += 1;
746
+ // continue;` sat OUTSIDE the phase check, so a phase where the
747
+ // key meant nothing swallowed it anyway. A phase where a letter
748
+ // means nothing is exactly a phase where a human is typing
749
+ // prose — so every y, n, 1 and 3 vanished from the line,
750
+ // silently, with no error and no visible cause. "yes, run it
751
+ // now 13" committed as "es, ru it ow ".
752
+ //
753
+ // Three typed phases were affected: the ask's custom answer,
754
+ // the approval panel's rule input, and its amend/feedback line.
755
+ // The rule input is the one that mattered most — it writes a
756
+ // DURABLE don't-ask-again rule, so a dropped character persists
757
+ // a rule the human never typed.
758
+ //
759
+ // Slice ④ met the same mechanism on the new pick panel
760
+ // ("openai/deepseek-reasoner" -> "opeai/deepseek-reasoer") and
761
+ // guarded pick alone, because the rest was a behaviour change
762
+ // owed its own red. This is that guard, stated once for every
763
+ // flavour: the options phase keeps its keys, and every typed
764
+ // phase keeps its text.
765
+ const optionsPhase = panel.pick === null && // a pick has no yes and no no
766
+ panel.phase === "options" && // the rule / amend lines are prose
767
+ (panel.ask === null || panel.ask.phase === "options"); // and so is a typed ask answer
768
+ if (optionsPhase) {
769
+ if (c === "1" || c === "y" || c === "Y") {
583
770
  this.#panelSelect(1);
584
- i += 1;
585
- continue;
586
- }
587
- if (c === "2" && panel.phase === "options" && panel.view.flavor === "approval") {
588
- this.#panelRule();
589
- i += 1;
590
- continue;
591
- }
592
- if (c === "3" || c === "n" || c === "N") {
593
- if (panel.phase === "options")
771
+ i += 1;
772
+ continue;
773
+ }
774
+ if (c === "2" && panel.view.flavor === "approval") {
775
+ this.#panelRule();
776
+ i += 1;
777
+ continue;
778
+ }
779
+ if (c === "3" || c === "n" || c === "N") {
594
780
  this.#panelSelect(3);
595
- i += 1;
596
- continue;
781
+ i += 1;
782
+ continue;
783
+ }
597
784
  }
598
785
  }
599
786
  if (c === "\x1b") {
@@ -632,6 +819,15 @@ export class Editor {
632
819
  this.#refreshMenu();
633
820
  i += 1;
634
821
  }
822
+ else if (this.#pickUp()) {
823
+ // TUI2-R2 ②: esc leaves the picker with nothing picked.
824
+ // The caller reads null and exits 0 — declining to resume
825
+ // is a normal thing to do, not a failure, so it must not
826
+ // fall through to the escapeCbs (which mean "abort the
827
+ // run" and there is no run yet).
828
+ this.#pickClose(null);
829
+ i += 1;
830
+ }
635
831
  else if (this.#atUp()) {
636
832
  // KC3 §3: esc closes the picker and leaves the BUFFER
637
833
  // ALONE — unlike the menu's esc, which clears it. The
@@ -752,6 +948,17 @@ export class Editor {
752
948
  cb();
753
949
  i += 1;
754
950
  }
951
+ else if (c === "?" && this.#composerIdle() && this.#chars.length === 0) {
952
+ // TUI2-R1 (D): `?` opens the keys sheet — but ONLY on an
953
+ // empty composer with nobody else holding the keys. Mid-text
954
+ // it is the question mark a human is typing, and #composerIdle
955
+ // already encodes "no panel, no menu, no picker, no browse".
956
+ // The precedence can only ever ADD: every state that used to
957
+ // insert a `?` still inserts one.
958
+ this.#sheetOpen = true;
959
+ this.#onRender();
960
+ i += 1;
961
+ }
755
962
  else if (c !== undefined && c < " ") {
756
963
  i += 1; // other control — ignored
757
964
  }
@@ -804,9 +1011,25 @@ export class Editor {
804
1011
  if (this.#panel !== null) {
805
1012
  // W21: the panel owns the keys. KC3.5: an ask uses ↑↓ for
806
1013
  // the option cursor (the approval panel still has no ↑↓ role).
807
- if (this.#panel.ask !== null && this.#panel.ask.phase === "options")
1014
+ if (this.#panel.pick !== null && this.#panel.pick.phase === "options") {
1015
+ // TUI2-R2 ④: ↑↓ walk the pick's cursor — the same list the
1016
+ // digits address, the other muscle.
1017
+ const n = Math.min(this.#panel.view.pick.options.length, PICK_MAX);
1018
+ const cur = this.#panel.pick.cursor;
1019
+ this.#panel.pick = { cursor: final === "A" ? Math.max(0, cur - 1) : Math.min(Math.max(0, n - 1), cur + 1), phase: "options" };
1020
+ }
1021
+ else if (this.#panel.ask !== null && this.#panel.ask.phase === "options")
808
1022
  this.#askStep(final === "A" ? "up" : "down");
809
1023
  }
1024
+ else if (this.#pickUp()) {
1025
+ // TUI2-R2 ②: the session picker owns ↑↓ while up — the
1026
+ // SELECTION, never the composer's line walk and never the
1027
+ // history browse. It sits above both because the picker is
1028
+ // modal: there is no turn to recall and no second line to
1029
+ // walk to while it is open.
1030
+ const view = this.#pickView();
1031
+ this.#pickSel = final === "A" ? Math.max(0, view.selected - 1) : Math.min(Math.max(0, view.matches.length - 1), view.selected + 1);
1032
+ }
810
1033
  else if (this.#menuOpen) {
811
1034
  if (final === "A")
812
1035
  this.#menuSel = Math.max(0, this.#menuSel - 1);
@@ -990,6 +1213,60 @@ export class Editor {
990
1213
  }
991
1214
  this.#onRender();
992
1215
  }
1216
+ /**
1217
+ * TUI2-R2 ④ — the pick panel's three keys.
1218
+ *
1219
+ * A digit MOVES the cursor rather than committing. The list is short
1220
+ * and the digits are adjacent on the keyboard; a picker that acted on
1221
+ * the keypress would make a mistyped 3 a model switch, and the whole
1222
+ * point of a confirm step is that the choice is visible before it is
1223
+ * taken.
1224
+ */
1225
+ #pickPanelDigit(index) {
1226
+ const panel = this.#panel;
1227
+ if (panel === null || panel.pick === null)
1228
+ return;
1229
+ // a digit past the list is INERT — an option nobody has is never
1230
+ // selected, and the cursor stays where the human left it
1231
+ if (index < 0 || index >= Math.min(panel.view.pick.options.length, PICK_MAX))
1232
+ return;
1233
+ panel.pick = { cursor: index, phase: "options" };
1234
+ this.#onRender();
1235
+ }
1236
+ /** enter — the typed line when there is one (an EMPTY line is not a
1237
+ * choice and commits nothing), else the option under the cursor. */
1238
+ #pickPanelEnter() {
1239
+ const panel = this.#panel;
1240
+ if (panel === null || panel.pick === null)
1241
+ return;
1242
+ if (panel.pick.phase === "custom") {
1243
+ const line = this.line().trim();
1244
+ if (line === "")
1245
+ return;
1246
+ this.#panelClose({ action: "picked", result: { custom: line } });
1247
+ return;
1248
+ }
1249
+ if (panel.view.pick.options.length === 0)
1250
+ return; // nothing to take
1251
+ this.#panelClose({ action: "picked", result: { index: panel.pick.cursor } });
1252
+ }
1253
+ /** esc — back out of the type-it line first, then cancel the panel.
1254
+ * Two escapes, two meanings, exactly as the approval panel's
1255
+ * rule/amend phases already work. */
1256
+ #pickPanelEsc() {
1257
+ const panel = this.#panel;
1258
+ if (panel === null || panel.pick === null)
1259
+ return;
1260
+ if (panel.pick.phase === "custom") {
1261
+ panel.pick = { cursor: panel.pick.cursor, phase: "options" };
1262
+ this.#chars = [];
1263
+ this.#cursor = 0;
1264
+ this.#scroll = 0;
1265
+ this.#onRender();
1266
+ return;
1267
+ }
1268
+ this.#panelClose({ action: "cancel" });
1269
+ }
993
1270
  #panelClose(verdict) {
994
1271
  const panel = this.#panel;
995
1272
  if (panel === null)
@@ -1113,6 +1390,7 @@ export class Editor {
1113
1390
  return (this.#panel === null &&
1114
1391
  !this.#menuOpen &&
1115
1392
  !this.#atUp() && // KC3 §3: the @ picker owns the keys while up, exactly like the menu
1393
+ !this.#pickUp() && // TUI2-R2 ②: and so does the session picker — `?` is a query character there
1116
1394
  this.#historyIdx === null &&
1117
1395
  !this.#queuePopMode &&
1118
1396
  !this.#pasting &&
@@ -1145,6 +1423,12 @@ export class Editor {
1145
1423
  this.#onRender();
1146
1424
  }
1147
1425
  #submit() {
1426
+ // TUI2-R2 ②: the session picker takes Enter before anything else —
1427
+ // while it is up there is no turn to submit and no line to send.
1428
+ if (this.#pickUp()) {
1429
+ this.#pickAccept();
1430
+ return;
1431
+ }
1148
1432
  // KC3 §3: Enter ACCEPTS while the picker is up — the same rule the
1149
1433
  // menu's A1 feel established (complete first, let the user read
1150
1434
  // what they got, and let the NEXT Enter send it). An @ reference
package/dist/index.d.ts CHANGED
@@ -6,13 +6,15 @@
6
6
  * editor, the diff renderer, and the palette.
7
7
  */
8
8
  export { Body, Dock, CURSOR_MARKER, type BodyOptions } from "./compositor.js";
9
- export { panelAffordance, panelBlockRows, panelLead, panelLeadPlain, panelLeadWidth, panelStatus, type PanelArgs, type PanelFlavor, type PanelPhase, type PanelSel, type PanelState, type PanelVerdict, type PanelView, } from "./approval-panel.js";
10
- export { Container, foldLine, visibleWidth, SPINNER, type Component, type FrameCtx } from "./components.js";
9
+ export { panelAffordance, panelBlockRows, panelLead, panelLeadPlain, panelLeadWidth, panelStatus, PICK_MAX, modelPickView, pickAffordance, pickBlockRows, pickLeadPlain, type PickOption, type PickResult, type PickRuntime, type PickSpec, type PanelArgs, type PanelFlavor, type PanelPhase, type PanelSel, type PanelState, type PanelVerdict, type PanelView, } from "./approval-panel.js";
10
+ export { Container, foldLine, foldWords, visibleWidth, SPINNER, type Component, type FrameCtx } from "./components.js";
11
11
  export { Editor, MENU_ITEMS, PROMPT, PROMPT_WIDTH, displayWidth, charWidth, widthOf, type MenuItem, } from "./editor.js";
12
12
  export { bannerLines, COLOR_OFF, COLOR_ON, escapeTerminal, foldResult, foldThinking, kUnit, palette, renderEvent, renderRecap, renderResumeList, renderSessionLine, renderStatusLine, relativeTime, renderTerminalGap, renderToolSummary, TAGLINE, toolTarget, truncateRow, type Palette, type PathResolver, type RecapStats, type ResumeMeta, type RenderInput, type RenderResult, type RunUsage, } from "./render.js";
13
13
  export { editFileDiff, truncateDiff, writeFileDiff, type DiffLine, type DiffResult } from "./diff.js";
14
- export { STATUS_GLYPHS, idleStatus, runningStatus } from "./status.js";
14
+ export { STATUS_GLYPHS, cacheHitPct, idleStatus, runningStatus, type StatusMeter } from "./status.js";
15
+ export { contextRows, contextUnavailableRows, type ContextLedger } from "./context-ledger.js";
15
16
  export { interactivePrompt, projectTrustRows, projectTrustView, projectUntrustedNote, uncertainView, type TrustArtifact } from "./strings.js";
16
- export { AT_CAP, AT_SKIP, AT_VISIBLE, atEmbed, atFilter, atPanelRows, atWindow, longestRun, type AtItem, type AtMatch } from "./at-picker.js";
17
+ export { AT_CAP, AT_SKIP, AT_VISIBLE, atEmbed, atFilter, atPanelRows, atWindow, bandHeader, longestRun, type AtItem, type AtMatch } from "./at-picker.js";
18
+ export { BADGE_GLYPH, idColumn, sessionAge, sessionBadge, sessionCounterRow, sessionFilter, sessionListFooter, sessionListRow, sessionNote, sessionPickerRows, sessionRow, type SessionCardView, type SessionPickState, } from "./session-picker.js";
17
19
  export { ASK_HEADER_CAP, ASK_MAX_OPTIONS, ASK_MAX_QUESTIONS, ASK_MIN_OPTIONS, askAffordance, askAnswers, askBlockRows, askCommitCustom, askDeclineAll, askDeclineList, askKey, askLeadPlain, askStart, askStatus, askView, type AskAnswer, type AskOption, type AskQuestion, type AskResult, type AskRuntime, type AskSpec, type AskStep, } from "./ask-panel.js";
18
- export { extensionsBannerText, helpRows, unansweredAskView, type BannerExtension } from "./strings.js";
20
+ export { KEY_BINDINGS, PANEL_KEYS_ROW, displayVerb, extensionsBannerText, helpRows, keysHelpRow, keysSheetRows, unansweredAskView, type BannerExtension, type KeyBinding } from "./strings.js";