@vincemakes/kiso-tui 0.24.5 → 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/ask-panel.js +1 -1
- package/dist/at-picker.js +1 -1
- package/dist/components.d.ts +1 -1
- package/dist/components.js +1 -1
- package/dist/compositor.d.ts +40 -67
- package/dist/compositor.js +433 -1043
- package/dist/context-ledger.js +1 -1
- package/dist/editor.d.ts +6 -7
- package/dist/editor.js +376 -786
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/{render.js → lines.js} +3 -3
- package/dist/panel-input.d.ts +105 -0
- package/dist/panel-input.js +468 -0
- package/dist/pick-input.d.ts +34 -0
- package/dist/pick-input.js +100 -0
- package/dist/session-picker.js +1 -1
- package/dist/status.js +1 -1
- package/dist/transcript.js +4 -27
- package/package.json +2 -2
- /package/dist/{render.d.ts → lines.d.ts} +0 -0
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 "./
|
|
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 "./
|
|
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
|
|
169
|
-
*
|
|
170
|
-
*
|
|
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
|
+
}
|