@vincemakes/kiso-tui 0.25.0 → 0.26.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/index.d.ts CHANGED
@@ -10,7 +10,7 @@ export { Body, Dock, CURSOR_MARKER, type BodyOptions } from "./compositor.js";
10
10
  export { panelAffordance, panelBlockRows, panelLead, panelLeadPlain, panelLeadWidth, panelStatus, PICK_MAX, modePickView, modelPickView, pickAffordance, pickBlockRows, pickLeadPlain, type PickOption, type PickResult, type PickRuntime, type PickSpec, type PanelArgs, type PanelFlavor, type PanelPhase, deletionRiskHint, SAFER_BACK, SAFER_DEGRADED, SAFER_DEGRADED_TRUNCATED, saferDegradedNote, type SaferAnswer, type SaferFailure, type SaferOption, type SaferRuntime, panelOptions, type PanelOption, type PanelOptionKind, type PanelState, type PanelVerdict, type PanelView, } from "./approval-panel.js";
11
11
  export { Container, foldLine, foldWords, visibleWidth, SPINNER, type Component, type FrameCtx } from "./components.js";
12
12
  export { Editor, MENU_ITEMS, PROMPT, PROMPT_WIDTH, displayWidth, charWidth, widthOf, type MenuItem, } from "./editor.js";
13
- export { bannerLines, COLOR_OFF, COLOR_ON, currentGround, setGround, 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
+ export { bannerLines, COLOR_OFF, COLOR_ON, currentGround, setGround, 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 "./lines.js";
14
14
  export { editFileDiff, truncateDiff, writeFileDiff, type DiffLine, type DiffResult } from "./diff.js";
15
15
  export { STATUS_GLYPHS, cacheHitPct, idleStatus, runningStatus, type StatusMeter } from "./status.js";
16
16
  export { contextRows, contextUnavailableRows, type ContextLedger } from "./context-ledger.js";
package/dist/index.js CHANGED
@@ -15,7 +15,7 @@ export { panelAffordance, panelBlockRows, panelLead, panelLeadPlain, panelLeadWi
15
15
  PICK_MAX, modePickView, modelPickView, pickAffordance, pickBlockRows, pickLeadPlain, deletionRiskHint, SAFER_BACK, SAFER_DEGRADED, SAFER_DEGRADED_TRUNCATED, saferDegradedNote, panelOptions, } from "./approval-panel.js";
16
16
  export { Container, foldLine, foldWords, visibleWidth, SPINNER } from "./components.js";
17
17
  export { Editor, MENU_ITEMS, PROMPT, PROMPT_WIDTH, displayWidth, charWidth, widthOf, } from "./editor.js";
18
- export { bannerLines, COLOR_OFF, COLOR_ON, currentGround, setGround, escapeTerminal, foldResult, foldThinking, kUnit, palette, renderEvent, renderRecap, renderResumeList, renderSessionLine, renderStatusLine, relativeTime, renderTerminalGap, renderToolSummary, TAGLINE, toolTarget, truncateRow, } from "./render.js";
18
+ export { bannerLines, COLOR_OFF, COLOR_ON, currentGround, setGround, escapeTerminal, foldResult, foldThinking, kUnit, palette, renderEvent, renderRecap, renderResumeList, renderSessionLine, renderStatusLine, relativeTime, renderTerminalGap, renderToolSummary, TAGLINE, toolTarget, truncateRow, } from "./lines.js";
19
19
  export { editFileDiff, truncateDiff, writeFileDiff } from "./diff.js";
20
20
  // KC2 §5: the status rows' formatters — the CLI keeps the state and the
21
21
  // repaint, the terminal layer owns what the row says.
@@ -165,9 +165,9 @@ export function renderStatusLine(turn, usage, ctxRatio, faux = false) {
165
165
  return null;
166
166
  return `[turn ${turn} · ${parts.join(" · ")}]`;
167
167
  }
168
- /** R3d — the per-tool terms of a settled turn, in the fold line's own
169
- * vocabulary (ROLLUP_NOUN plurals, zero terms dropped). One wording for
170
- * "what a run did", wherever it is said. */
168
+ /** R3d — the per-tool terms of a settled turn (`read 4 files · ran 1
169
+ * shell command`, zero terms dropped). One wording for "what a run
170
+ * did", wherever it is said. */
171
171
  function recapWork(byTool) {
172
172
  let reads = 0;
173
173
  let edits = 0;
@@ -0,0 +1,105 @@
1
+ /**
2
+ * The approval / ask / pick PANEL's input controller — S5 (finding C10).
3
+ *
4
+ * The editor parses BYTES and owns the composer buffer; this controller
5
+ * owns the panel's STATE and answers KEYS. One key is handled by at most
6
+ * one band, in an order the editor keeps in one place: the panel first,
7
+ * then the session picker, then the composer's own bands. What the
8
+ * panel may do to the composer is the `BandHost` surface and nothing
9
+ * else: it types its amend reason, its custom ask answer and its custom
10
+ * pick into the composer, stashes the composer at open, restores it at
11
+ * close — that is the whole coupling, and it is visible here.
12
+ *
13
+ * DECLARED MOVE (S5, 2026-09-06): every method below stood in editor.ts
14
+ * — `#panelMove` … `#panelClose`, `beginPanel`, `cancelPanel`,
15
+ * `panelState`, the panel block of `feed()`, the ↑↓/← branches of `#csi`
16
+ * and the mouse hit. The bodies are the same; only the buffer access
17
+ * goes through the host.
18
+ */
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. */
21
+ export interface BufferStash {
22
+ readonly chars: number[];
23
+ readonly cursor: number;
24
+ readonly scroll: number;
25
+ }
26
+ /** The composer, as a band controller is allowed to see it. Every
27
+ * entry is something a panel or the session picker does to the
28
+ * editor; nothing a band needs is reached any other way. */
29
+ export interface BandHost {
30
+ /** The buffer as text. */
31
+ line(): string;
32
+ /** The buffer with its paste capsules expanded — the text that would leave the editor. */
33
+ expandPastes(line: string): string;
34
+ /** Empty the buffer: chars, cursor, scroll and the ↑↓ goal column. */
35
+ clear(): void;
36
+ /** Type one code point at the cursor. */
37
+ insert(cp: number): void;
38
+ /** Insert the buffer's newline — a pasted line break while a band is up. */
39
+ newline(): void;
40
+ stash(): BufferStash;
41
+ restore(s: BufferStash): void;
42
+ reflow(): void;
43
+ render(): void;
44
+ syncMouse(): void;
45
+ /** A panel opening closes the composer's own bands: the menu, the queue-pop mode, the @ picker. */
46
+ closeBands(): void;
47
+ /** The Enter that closed a panel must not also submit the restored buffer. */
48
+ swallowNextEnter(): void;
49
+ }
50
+ /** A key the editor has already parsed. `enter.crlf` says the bytes
51
+ * were CR LF, so a consumed Enter is two bytes in a paste. */
52
+ export type BandKey = {
53
+ kind: "esc";
54
+ } | {
55
+ kind: "enter";
56
+ crlf: boolean;
57
+ } | {
58
+ kind: "tab";
59
+ } | {
60
+ kind: "char";
61
+ ch: string;
62
+ };
63
+ /** The option rows' place on screen, as the compositor reported it (TUI2-R3v2 ②). */
64
+ export interface PanelRowSpan {
65
+ readonly top: number;
66
+ readonly count: number;
67
+ readonly first?: number;
68
+ }
69
+ export declare class PanelInput {
70
+ #private;
71
+ private readonly host;
72
+ constructor(host: BandHost);
73
+ up(): boolean;
74
+ /** W21: open the approval panel. The current buffer is stashed
75
+ * (restored at close — commit AND cancel), the panel takes the
76
+ * keys and the input row's lead, the composer's own bands close. */
77
+ begin(view: PanelView, onCommit: (v: PanelVerdict) => void, opts?: {
78
+ safer?: () => Promise<SaferAnswer>;
79
+ }): void;
80
+ /** W21: cancel the panel — the SIGINT path's pair to begin. */
81
+ cancel(): void;
82
+ /** W21: the compositor's bound view — the phase/selection while the
83
+ * panel is up, null otherwise. */
84
+ state(): PanelState | null;
85
+ /**
86
+ * A parsed key while the panel is up. Returns the bytes it consumed,
87
+ * or null when the key falls through to the composer — the amend
88
+ * reason, a custom ask answer and a custom pick are typed into the
89
+ * composer through the editor's ordinary path, and a control byte
90
+ * the panel does not claim (ctrl+c, ctrl+z) keeps its meaning.
91
+ *
92
+ * The order is the one `feed()` kept: the pick flavour's keys, then
93
+ * the ask flavour's, then the keys every flavour shares.
94
+ */
95
+ feed(key: BandKey, pasting: boolean): number | null;
96
+ /** ↑↓ while the panel is up: the flavour's own walk. True when the
97
+ * panel owned the key (the caller renders); false when no panel is
98
+ * up. A phase without a list (asking) swallows the key. */
99
+ 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
+ left(): boolean;
102
+ /** TUI2-R3v2 ②: a left-button press on the option rows the
103
+ * compositor placed. Outside the list — inert. */
104
+ click(span: PanelRowSpan | null | undefined, row: number | undefined): void;
105
+ }
@@ -0,0 +1,468 @@
1
+ /**
2
+ * The approval / ask / pick PANEL's input controller — S5 (finding C10).
3
+ *
4
+ * The editor parses BYTES and owns the composer buffer; this controller
5
+ * owns the panel's STATE and answers KEYS. One key is handled by at most
6
+ * one band, in an order the editor keeps in one place: the panel first,
7
+ * then the session picker, then the composer's own bands. What the
8
+ * panel may do to the composer is the `BandHost` surface and nothing
9
+ * else: it types its amend reason, its custom ask answer and its custom
10
+ * pick into the composer, stashes the composer at open, restores it at
11
+ * close — that is the whole coupling, and it is visible here.
12
+ *
13
+ * DECLARED MOVE (S5, 2026-09-06): every method below stood in editor.ts
14
+ * — `#panelMove` … `#panelClose`, `beginPanel`, `cancelPanel`,
15
+ * `panelState`, the panel block of `feed()`, the ↑↓/← branches of `#csi`
16
+ * and the mouse hit. The bodies are the same; only the buffer access
17
+ * goes through the host.
18
+ */
19
+ import { PICK_MAX, panelOptions, saferDegradedNote, } from "./approval-panel.js";
20
+ import { askCommitCustom, askKey, askOnCustomRow, askStart } from "./ask-panel.js";
21
+ export class PanelInput {
22
+ host;
23
+ // W21: the panel state machine — the approval/trust panel owns the
24
+ // interaction while up: the digit/y/n/esc/tab routing, the rule
25
+ // input, the tab-amend feedback, the phase/selection the compositor
26
+ // renders. The menu never opens while a panel is up; the pre-panel
27
+ // buffer is stashed at open and restored at close (commit AND
28
+ // cancel) — the panel's rule/feedback text never leaks into the
29
+ // user's next turn.
30
+ #panel = null;
31
+ /** TUI2-R3v2 ③: the safer ask's generation. A panel the human escaped
32
+ * must not be resurrected by a promise nobody is waiting for. */
33
+ #saferToken = 0;
34
+ constructor(host) {
35
+ this.host = host;
36
+ }
37
+ up() {
38
+ return this.#panel !== null;
39
+ }
40
+ /** W21: open the approval panel. The current buffer is stashed
41
+ * (restored at close — commit AND cancel), the panel takes the
42
+ * keys and the input row's lead, the composer's own bands close. */
43
+ begin(view, onCommit, opts) {
44
+ this.#panel = {
45
+ view,
46
+ phase: "options",
47
+ cursor: 0,
48
+ note: null,
49
+ safer: opts?.safer,
50
+ saferRun: null,
51
+ ask: view.ask === undefined ? null : askStart(view.ask),
52
+ pick: view.pick === undefined ? null : { cursor: 0, phase: "options" },
53
+ onCommit,
54
+ stash: this.host.stash(),
55
+ };
56
+ this.host.clear();
57
+ this.host.closeBands();
58
+ this.host.syncMouse();
59
+ this.host.render();
60
+ }
61
+ /** W21: cancel the panel — the SIGINT path's pair to begin. */
62
+ cancel() {
63
+ this.#close({ action: "cancel" });
64
+ }
65
+ /** W21: the compositor's bound view — the phase/selection while the
66
+ * panel is up, null otherwise. */
67
+ state() {
68
+ const panel = this.#panel;
69
+ if (panel === null)
70
+ return null;
71
+ return {
72
+ view: panel.view,
73
+ phase: panel.phase,
74
+ cursor: panel.cursor,
75
+ ...(panel.note === null ? {} : { note: panel.note }),
76
+ ...(panel.saferRun === null ? {} : { safer: panel.saferRun }),
77
+ ...(panel.ask === null ? {} : { ask: panel.ask }),
78
+ ...(panel.pick === null ? {} : { pick: panel.pick }),
79
+ };
80
+ }
81
+ /**
82
+ * A parsed key while the panel is up. Returns the bytes it consumed,
83
+ * or null when the key falls through to the composer — the amend
84
+ * reason, a custom ask answer and a custom pick are typed into the
85
+ * composer through the editor's ordinary path, and a control byte
86
+ * the panel does not claim (ctrl+c, ctrl+z) keeps its meaning.
87
+ *
88
+ * The order is the one `feed()` kept: the pick flavour's keys, then
89
+ * the ask flavour's, then the keys every flavour shares.
90
+ */
91
+ feed(key, pasting) {
92
+ const panel = this.#panel;
93
+ if (panel === null)
94
+ return null;
95
+ const enterBytes = key.kind === "enter" && key.crlf ? 2 : 1;
96
+ if (panel.pick !== null) {
97
+ const typing = panel.pick.phase === "custom";
98
+ if (key.kind === "esc") {
99
+ this.#pickPanelEsc();
100
+ return 1;
101
+ }
102
+ if (key.kind === "enter") {
103
+ if (pasting) {
104
+ this.host.newline();
105
+ return enterBytes;
106
+ }
107
+ this.#pickPanelEnter();
108
+ return 1;
109
+ }
110
+ if (key.kind === "char") {
111
+ const c = key.ch;
112
+ if (!typing && c >= "1" && c <= "9") {
113
+ this.#pickPanelDigit(Number(c) - 1);
114
+ return 1;
115
+ }
116
+ if (!typing && (c === "t" || c === "T") && panel.view.pick?.typeHint !== undefined) {
117
+ panel.pick = { cursor: panel.pick.cursor, phase: "custom" };
118
+ this.host.clear();
119
+ this.host.render();
120
+ return 1;
121
+ }
122
+ if (!typing && c >= " " && c !== "\x7f")
123
+ return 1;
124
+ }
125
+ }
126
+ if (panel.ask !== null) {
127
+ const typing = panel.ask.phase === "custom";
128
+ if (key.kind === "esc") {
129
+ this.#askStep("esc");
130
+ return 1;
131
+ }
132
+ if (key.kind === "enter") {
133
+ if (pasting) {
134
+ this.host.newline();
135
+ return enterBytes;
136
+ }
137
+ this.#askStep(typing ? "commit" : "enter");
138
+ return 1;
139
+ }
140
+ if (key.kind === "char") {
141
+ const c = key.ch;
142
+ if (askOnCustomRow(panel.view.ask, panel.ask) && c >= " " && c !== "\x7f") {
143
+ this.#askStep("type");
144
+ this.host.insert(c.codePointAt(0));
145
+ this.host.render();
146
+ return 1;
147
+ }
148
+ if (!typing && (c === " " || (c >= "1" && c <= "4") || c === "t" || c === "T")) {
149
+ this.#askStep(c === " " ? "space" : c === "T" ? "t" : c);
150
+ return 1;
151
+ }
152
+ if (!typing && c >= " " && c !== "\x7f")
153
+ return 1;
154
+ }
155
+ }
156
+ if (key.kind === "esc") {
157
+ this.#panelEsc();
158
+ return 1;
159
+ }
160
+ if (key.kind === "tab") {
161
+ if (panel.phase === "options")
162
+ this.#panelTab();
163
+ return 1;
164
+ }
165
+ if (key.kind === "enter") {
166
+ if (pasting) {
167
+ this.host.newline();
168
+ return enterBytes;
169
+ }
170
+ this.#panelEnter();
171
+ return 1;
172
+ }
173
+ const c = key.ch;
174
+ if (panel.phase === "safer" && c >= "1" && c <= "9") {
175
+ this.#saferConfirm(Number(c) - 1);
176
+ return 1;
177
+ }
178
+ if (panel.phase === "asking" && c >= " " && c !== "\x7f")
179
+ return 1;
180
+ if (panel.phase === "options" && panel.ask === null && panel.pick === null && c >= "1" && c <= "9") {
181
+ this.#panelConfirm(Number(c) - 1);
182
+ return 1;
183
+ }
184
+ const optionsPhase = panel.pick === null && // a pick has no yes and no no
185
+ panel.phase === "options" && // the amend line is prose
186
+ (panel.ask === null || panel.ask.phase === "options"); // and so is a typed ask answer
187
+ if (optionsPhase && panel.ask === null) {
188
+ if (c === "y" || c === "Y") {
189
+ this.#panelConfirm(0);
190
+ return 1;
191
+ }
192
+ if (c === "n" || c === "N") {
193
+ this.#panelConfirm(panelOptions(panel.view).length - 1);
194
+ return 1;
195
+ }
196
+ }
197
+ return null;
198
+ }
199
+ /** ↑↓ while the panel is up: the flavour's own walk. True when the
200
+ * panel owned the key (the caller renders); false when no panel is
201
+ * up. A phase without a list (asking) swallows the key. */
202
+ arrow(dir) {
203
+ const panel = this.#panel;
204
+ if (panel === null)
205
+ return false;
206
+ if (panel.pick !== null && panel.pick.phase === "options") {
207
+ const n = Math.min(panel.view.pick.options.length, PICK_MAX);
208
+ 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" };
210
+ }
211
+ else if (panel.ask !== null && panel.ask.phase === "options")
212
+ this.#askStep(dir);
213
+ else if (panel.phase === "safer")
214
+ this.#saferMove(dir === "up" ? -1 : 1);
215
+ else if (panel.phase !== "asking")
216
+ this.#panelMove(dir === "up" ? -1 : 1);
217
+ return true;
218
+ }
219
+ /** ← while an ask's option row is up walks the ask; otherwise the key is the composer's. */
220
+ left() {
221
+ const panel = this.#panel;
222
+ if (panel?.ask == null || panel.ask.phase !== "options")
223
+ return false;
224
+ this.#askStep("left");
225
+ return true;
226
+ }
227
+ /** TUI2-R3v2 ②: a left-button press on the option rows the
228
+ * compositor placed. Outside the list — inert. */
229
+ click(span, row) {
230
+ const panel = this.#panel;
231
+ if (panel === null || (panel.phase !== "options" && panel.phase !== "safer"))
232
+ return;
233
+ if (span == null || row === undefined || !Number.isFinite(row))
234
+ return;
235
+ const offset = row - span.top;
236
+ if (offset < 0 || offset >= span.count)
237
+ return; // outside the list — inert
238
+ if (panel.phase === "safer")
239
+ this.#saferConfirm(offset);
240
+ else
241
+ this.#panelConfirm((span.first ?? 0) + offset);
242
+ }
243
+ // ---- W21 / TUI2-R3v2 ①: the panel state machine ----
244
+ /** ↑↓ — the bar walks the list and STOPS at both ends. A list that
245
+ * wraps makes the fastest gesture (hold ↓ to reach the bottom) into
246
+ * a gamble about where you landed, and the bottom option here is the
247
+ * denial. */
248
+ #panelMove(delta) {
249
+ const panel = this.#panel;
250
+ if (panel === null || panel.phase !== "options")
251
+ return;
252
+ const n = panelOptions(panel.view).length;
253
+ panel.cursor = Math.max(0, Math.min(n - 1, panel.cursor + delta));
254
+ this.host.render();
255
+ }
256
+ #panelConfirm(index) {
257
+ const panel = this.#panel;
258
+ if (panel === null || panel.phase !== "options")
259
+ return;
260
+ const options = panelOptions(panel.view);
261
+ const option = options[index];
262
+ if (option === undefined)
263
+ return; // a digit past the list is inert
264
+ panel.cursor = index;
265
+ switch (option.kind) {
266
+ case "allow":
267
+ this.#close({ action: "allow", reason: "" });
268
+ return;
269
+ case "rule":
270
+ this.#close({ action: "allow-rule", rule: panel.view.name });
271
+ return;
272
+ case "safer":
273
+ this.#panelSafer();
274
+ return;
275
+ case "deny":
276
+ if (panel.view.flavor === "approval")
277
+ this.#panelAmend();
278
+ else
279
+ this.#close({ action: "deny", reason: "" });
280
+ return;
281
+ }
282
+ }
283
+ /** TUI2-R3v2 ③: ask the caller for safer options. The panel shows
284
+ * "asking" until the answer lands; a generation token guards the
285
+ * landing so an escaped panel is never resurrected by it. */
286
+ #panelSafer() {
287
+ const panel = this.#panel;
288
+ if (panel === null)
289
+ return;
290
+ const ask = panel.safer;
291
+ panel.phase = "asking";
292
+ panel.note = null;
293
+ this.host.render();
294
+ const token = ++this.#saferToken;
295
+ const settle = (answer) => {
296
+ if (this.#panel !== panel || token !== this.#saferToken)
297
+ return;
298
+ const options = Array.isArray(answer) ? answer : null;
299
+ if (options === null || options.length === 0) {
300
+ panel.phase = "options";
301
+ panel.note = saferDegradedNote(answer);
302
+ panel.cursor = 0;
303
+ this.host.render();
304
+ return;
305
+ }
306
+ panel.phase = "safer";
307
+ panel.saferRun = { options, cursor: 0 };
308
+ this.host.render();
309
+ };
310
+ if (ask === undefined) {
311
+ settle(null); // no provider bound — the button says so rather than lying
312
+ return;
313
+ }
314
+ void Promise.resolve()
315
+ .then(ask)
316
+ .then(settle)
317
+ .catch(() => settle(null));
318
+ }
319
+ #saferConfirm(index) {
320
+ const panel = this.#panel;
321
+ if (panel === null || panel.saferRun === null)
322
+ return;
323
+ const { options } = panel.saferRun;
324
+ if (index === options.length) {
325
+ panel.phase = "options";
326
+ panel.saferRun = null;
327
+ panel.cursor = 0;
328
+ this.host.render();
329
+ return;
330
+ }
331
+ const chosen = options[index];
332
+ if (chosen === undefined)
333
+ return; // past the list — inert
334
+ this.#close({ action: "deny", reason: `run this instead: ${chosen.command}` });
335
+ }
336
+ #saferMove(delta) {
337
+ const panel = this.#panel;
338
+ if (panel === null || panel.saferRun === null)
339
+ return;
340
+ const last = panel.saferRun.options.length; // + the way-back row
341
+ panel.saferRun = { options: panel.saferRun.options, cursor: Math.max(0, Math.min(last, panel.saferRun.cursor + delta)) };
342
+ this.host.render();
343
+ }
344
+ /** The amend phase: the composer becomes the denial's reason. */
345
+ #panelAmend() {
346
+ const panel = this.#panel;
347
+ if (panel === null)
348
+ return;
349
+ panel.phase = "amend";
350
+ this.host.clear();
351
+ this.host.render();
352
+ }
353
+ #panelTab() {
354
+ const panel = this.#panel;
355
+ if (panel === null || panel.view.flavor !== "approval")
356
+ return;
357
+ panel.cursor = panelOptions(panel.view).length - 1;
358
+ this.#panelAmend();
359
+ }
360
+ /** Esc walks BACK one phase before it cancels: a safer list or a
361
+ * pending ask returns to the options, an amend line returns to the
362
+ * options, and only the options phase itself cancels. */
363
+ #panelEsc() {
364
+ const panel = this.#panel;
365
+ if (panel === null)
366
+ return;
367
+ if (panel.phase === "safer" || panel.phase === "asking") {
368
+ this.#saferToken += 1;
369
+ panel.phase = "options";
370
+ panel.saferRun = null;
371
+ panel.cursor = 0;
372
+ this.host.render();
373
+ return;
374
+ }
375
+ if (panel.phase !== "options") {
376
+ panel.phase = "options";
377
+ this.host.clear();
378
+ this.host.render();
379
+ return;
380
+ }
381
+ this.#close({ action: "cancel" });
382
+ }
383
+ #panelEnter() {
384
+ const panel = this.#panel;
385
+ if (panel === null)
386
+ return;
387
+ if (panel.phase === "amend") {
388
+ this.#close({ action: "deny", reason: this.host.line() });
389
+ return;
390
+ }
391
+ if (panel.phase === "safer" && panel.saferRun !== null) {
392
+ this.#saferConfirm(panel.saferRun.cursor);
393
+ return;
394
+ }
395
+ if (panel.phase === "asking")
396
+ return; // nothing to confirm yet
397
+ this.#panelConfirm(panel.cursor);
398
+ }
399
+ /** KC3.5: one step of the ask's walk; a phase change empties the
400
+ * composer (the custom answer's text field), a result closes. */
401
+ #askStep(key) {
402
+ const panel = this.#panel;
403
+ if (panel === null || panel.ask === null)
404
+ return;
405
+ const spec = panel.view.ask;
406
+ const before = panel.ask.phase;
407
+ const step = key === "commit" ? askCommitCustom(spec, panel.ask, this.host.expandPastes(this.host.line())) : askKey(spec, panel.ask, key);
408
+ panel.ask = step.state;
409
+ if (step.state.phase !== before)
410
+ this.host.clear();
411
+ if (step.result !== undefined) {
412
+ this.#close({ action: "answers", result: step.result });
413
+ return;
414
+ }
415
+ this.host.render();
416
+ }
417
+ #pickPanelDigit(index) {
418
+ const panel = this.#panel;
419
+ if (panel === null || panel.pick === null)
420
+ return;
421
+ if (index < 0 || index >= Math.min(panel.view.pick.options.length, PICK_MAX))
422
+ return;
423
+ panel.pick = { cursor: index, phase: "options" };
424
+ this.host.render();
425
+ }
426
+ #pickPanelEnter() {
427
+ const panel = this.#panel;
428
+ if (panel === null || panel.pick === null)
429
+ return;
430
+ if (panel.pick.phase === "custom") {
431
+ const line = this.host.line().trim();
432
+ if (line === "")
433
+ return;
434
+ this.#close({ action: "picked", result: { custom: line } });
435
+ return;
436
+ }
437
+ if (panel.view.pick.options.length === 0)
438
+ return; // nothing to take
439
+ this.#close({ action: "picked", result: { index: panel.pick.cursor } });
440
+ }
441
+ #pickPanelEsc() {
442
+ const panel = this.#panel;
443
+ if (panel === null || panel.pick === null)
444
+ return;
445
+ if (panel.pick.phase === "custom") {
446
+ panel.pick = { cursor: panel.pick.cursor, phase: "options" };
447
+ this.host.clear();
448
+ this.host.render();
449
+ return;
450
+ }
451
+ this.#close({ action: "cancel" });
452
+ }
453
+ /** Close: the state is cleared FIRST, the stashed composer comes
454
+ * back, the Enter that closed the panel is swallowed, and only then
455
+ * does the verdict reach the caller — so a caller that re-enters
456
+ * (a second panel, a run that resumes) never sees the closing one. */
457
+ #close(verdict) {
458
+ const panel = this.#panel;
459
+ if (panel === null)
460
+ return;
461
+ this.#panel = null;
462
+ this.host.syncMouse();
463
+ this.host.swallowNextEnter();
464
+ this.host.restore(panel.stash);
465
+ this.host.render();
466
+ panel.onCommit(verdict);
467
+ }
468
+ }
@@ -0,0 +1,34 @@
1
+ import type { BandHost } from "./panel-input.js";
2
+ import { type SessionCardView, type SessionPickState } from "./session-picker.js";
3
+ export declare class PickInput {
4
+ #private;
5
+ private readonly host;
6
+ constructor(host: Pick<BandHost, "line" | "clear" | "reflow" | "render" | "syncMouse">);
7
+ up(): boolean;
8
+ /** Open the picker on a bound card source. The composer is cleared
9
+ * (the buffer becomes the filter query) and `onPick` receives the
10
+ * chosen id — or null when the human leaves without picking, which
11
+ * is a first-class outcome and not an error. */
12
+ begin(cards: () => readonly SessionCardView[], onPick: (id: string | null) => void): void;
13
+ /** The picker's state, derived: the full card list (the id column
14
+ * measures over ALL of them, so the columns never jump), the
15
+ * filtered matches, and the selection CLAMPED at read time — the
16
+ * same correction discipline the @ picker uses, for the same
17
+ * reason: narrowing can only ever shrink the list. */
18
+ state(): SessionPickState | null;
19
+ /** The band's height estimate: the header + the windowed rows (or
20
+ * the one "no match" row) + the counter. */
21
+ rows(): number;
22
+ /** ↑↓: the selection walks the matches and stops at both ends. True
23
+ * when the picker owned the key (the caller renders). */
24
+ arrow(dir: "up" | "down"): boolean;
25
+ /** Close and hand the verdict back. The callback fires AFTER the
26
+ * state is cleared, so a caller that re-enters (a second picker, a
27
+ * session that starts) never sees the closing picker's rows. */
28
+ close(id: string | null): void;
29
+ /** Enter takes the SELECTED session. An empty match set takes
30
+ * nothing and leaves the picker up: a picker that invented a pick
31
+ * when the query matched nothing would resume the wrong session,
32
+ * which is the one failure this surface must never have. */
33
+ accept(): void;
34
+ }