@vincemakes/kiso-tui 0.6.0 → 0.8.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.d.ts +95 -0
- package/dist/ask-panel.js +268 -0
- package/dist/at-picker.d.ts +159 -0
- package/dist/at-picker.js +228 -0
- package/dist/compositor.d.ts +16 -1
- package/dist/compositor.js +48 -7
- package/dist/editor.d.ts +16 -1
- package/dist/editor.js +271 -7
- package/dist/index.d.ts +4 -0
- package/dist/index.js +14 -0
- package/dist/strings.d.ts +6 -0
- package/dist/strings.js +6 -0
- package/package.json +2 -2
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KC3.5 — the ask view: the model asks the human a real question.
|
|
3
|
+
*
|
|
4
|
+
* The W21 approval panel generalizes rather than duplicates. An ask is a
|
|
5
|
+
* PanelView carrying `ask`, so it rides the EXISTING panel slot: the
|
|
6
|
+
* compositor's live region, the input row's lead, the status/affordance
|
|
7
|
+
* pair, and — the part that matters — the editor's buffer stash/restore
|
|
8
|
+
* and its key precedence (the panel owns the keys; the menu, the history
|
|
9
|
+
* walk and the @ picker never open under it).
|
|
10
|
+
*
|
|
11
|
+
* This module owns three things and nothing else:
|
|
12
|
+
*
|
|
13
|
+
* 1. the ROWS — the question, the ‹ n/m › counter, the numbered
|
|
14
|
+
* options with their descriptions and selection marks, the
|
|
15
|
+
* type-your-own line;
|
|
16
|
+
* 2. the pure REDUCER — a key plus a state gives the next state (and,
|
|
17
|
+
* when the walk ends, the result). No I/O, no editor internals: the
|
|
18
|
+
* editor feeds it keys and renders what comes back, which is what
|
|
19
|
+
* makes the whole interaction unit-testable without a terminal;
|
|
20
|
+
* 3. the DISPATCHERS — panelBlockRows/panelLead/panelStatus/
|
|
21
|
+
* panelAffordance re-exported with the ask branch folded in, so the
|
|
22
|
+
* compositor and the editor change ONE import line between them and
|
|
23
|
+
* the panel slot itself stays exactly as W21 built it.
|
|
24
|
+
*
|
|
25
|
+
* What is deliberately NOT here (the round's stop clauses): no partial
|
|
26
|
+
* answer durability (a crash re-presents the WHOLE call — per-toggle
|
|
27
|
+
* durability would need a new durable mechanism), no "chat about this"
|
|
28
|
+
* hand-off, no timeout and no countdown.
|
|
29
|
+
*/
|
|
30
|
+
import { type AskAnswer, type AskOption, type AskQuestion, type AskResult, type AskRuntime, type AskSpec, type PanelPhase, type PanelSel, type PanelState, type PanelView } from "./approval-panel.js";
|
|
31
|
+
/** The schema's own bounds — the registry refuses anything outside them
|
|
32
|
+
* (extensions/ask validates; these are the numbers it validates to). */
|
|
33
|
+
export declare const ASK_MAX_QUESTIONS = 4;
|
|
34
|
+
export declare const ASK_MIN_OPTIONS = 2;
|
|
35
|
+
export declare const ASK_MAX_OPTIONS = 4;
|
|
36
|
+
export declare const ASK_HEADER_CAP = 12;
|
|
37
|
+
/** The ask panel's opening state: nothing picked, the cursor on the
|
|
38
|
+
* first option of the first question. */
|
|
39
|
+
export declare function askStart(spec: AskSpec): AskRuntime;
|
|
40
|
+
/** The decline's honest record: every question with its options, so the
|
|
41
|
+
* model reads what it did NOT get an answer to (frame 4). The SAME
|
|
42
|
+
* list whether the human pressed esc on question one or question four
|
|
43
|
+
* — the round declines the CALL, never half of it. */
|
|
44
|
+
export declare function askDeclineList(spec: AskSpec): string[];
|
|
45
|
+
export declare function askDeclineAll(spec: AskSpec): AskResult;
|
|
46
|
+
/** The answers collected so far, in the tool_result's own shapes: a
|
|
47
|
+
* typed answer wins over the picks (the human typed it last), a
|
|
48
|
+
* multi-select question yields `choices`, a single one `choice`. */
|
|
49
|
+
export declare function askAnswers(spec: AskSpec, state: AskRuntime): AskAnswer[];
|
|
50
|
+
/** The reducer's outcome: the next state, plus the RESULT when the walk
|
|
51
|
+
* ended (the last question answered, or the decline). */
|
|
52
|
+
export interface AskStep {
|
|
53
|
+
readonly state: AskRuntime;
|
|
54
|
+
readonly result?: AskResult;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The pure key reducer. `key` is a single logical key: a digit "1".."4",
|
|
58
|
+
* "space", "up"/"down", "left" (walk back), "enter", "t" (type your own),
|
|
59
|
+
* or "esc". The custom phase's TEXT is not routed here — the editor owns
|
|
60
|
+
* the buffer exactly as it does for the rule-input phase, and hands the
|
|
61
|
+
* committed line to `askCommitCustom`.
|
|
62
|
+
*/
|
|
63
|
+
export declare function askKey(spec: AskSpec, state: AskRuntime, key: string): AskStep;
|
|
64
|
+
/** The typed answer commits: it becomes THE answer for this question
|
|
65
|
+
* (clearing its picks) and the walk advances. An empty line is a
|
|
66
|
+
* no-op back to the options — nothing is recorded. */
|
|
67
|
+
export declare function askCommitCustom(spec: AskSpec, state: AskRuntime, text: string): AskStep;
|
|
68
|
+
/** The ask block's rows — the question as the rule line, the header (or
|
|
69
|
+
* the counter) as the title, the options as the body, and the
|
|
70
|
+
* type-your-own line last. The shape is the W21 block's: gutter, rule,
|
|
71
|
+
* title, divider, body, affordance, corner. */
|
|
72
|
+
export declare function askBlockRows(view: PanelView, state: AskRuntime, W: number, maxRows: number): string[];
|
|
73
|
+
/** The status row's right-hand hint — the phase's keys. */
|
|
74
|
+
export declare function askAffordance(state: AskRuntime): string;
|
|
75
|
+
/** The status row's left text — the ask's own line, with the walk. */
|
|
76
|
+
export declare function askStatus(view: PanelView, state: AskRuntime): string;
|
|
77
|
+
/** The input row's lead: the digit lead while picking, the typing lead
|
|
78
|
+
* in the custom phase (the rule-input phase's shape, reused). */
|
|
79
|
+
export declare function askLeadPlain(state: AskRuntime): string;
|
|
80
|
+
export declare function panelBlockRows(view: PanelView, phase: PanelPhase, sel: PanelSel, W: number, maxRows: number, ask?: AskRuntime): string[];
|
|
81
|
+
export declare function panelLead(view: PanelView, phase: PanelPhase, sel: PanelSel, ask?: AskRuntime): string;
|
|
82
|
+
export declare function panelLeadPlain(view: PanelView, phase: PanelPhase, sel: PanelSel, ask?: AskRuntime): string;
|
|
83
|
+
export declare function panelStatus(view: PanelView, phase: PanelPhase, sel: PanelSel, ask?: AskRuntime): string;
|
|
84
|
+
export declare function panelAffordance(view: PanelView, phase: PanelPhase, sel: PanelSel, ask?: AskRuntime): string;
|
|
85
|
+
/** The whole panel state in one call — the compositor's four reads share
|
|
86
|
+
* one source, so an ask can never render half as an approval. */
|
|
87
|
+
export declare const panelRowsOf: (s: PanelState, W: number, maxRows: number) => string[];
|
|
88
|
+
export declare const panelLeadOf: (s: PanelState) => string;
|
|
89
|
+
export declare const panelStatusOf: (s: PanelState) => string;
|
|
90
|
+
export declare const panelAffordanceOf: (s: PanelState) => string;
|
|
91
|
+
/** The ask's PanelView. The dock-less fallback question is HONEST: a
|
|
92
|
+
* terminal without a panel cannot walk options, so it says the ask is
|
|
93
|
+
* being declined rather than pretending y/n answered it. */
|
|
94
|
+
export declare function askView(spec: AskSpec): PanelView;
|
|
95
|
+
export type { AskAnswer, AskOption, AskQuestion, AskResult, AskRuntime, AskSpec };
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KC3.5 — the ask view: the model asks the human a real question.
|
|
3
|
+
*
|
|
4
|
+
* The W21 approval panel generalizes rather than duplicates. An ask is a
|
|
5
|
+
* PanelView carrying `ask`, so it rides the EXISTING panel slot: the
|
|
6
|
+
* compositor's live region, the input row's lead, the status/affordance
|
|
7
|
+
* pair, and — the part that matters — the editor's buffer stash/restore
|
|
8
|
+
* and its key precedence (the panel owns the keys; the menu, the history
|
|
9
|
+
* walk and the @ picker never open under it).
|
|
10
|
+
*
|
|
11
|
+
* This module owns three things and nothing else:
|
|
12
|
+
*
|
|
13
|
+
* 1. the ROWS — the question, the ‹ n/m › counter, the numbered
|
|
14
|
+
* options with their descriptions and selection marks, the
|
|
15
|
+
* type-your-own line;
|
|
16
|
+
* 2. the pure REDUCER — a key plus a state gives the next state (and,
|
|
17
|
+
* when the walk ends, the result). No I/O, no editor internals: the
|
|
18
|
+
* editor feeds it keys and renders what comes back, which is what
|
|
19
|
+
* makes the whole interaction unit-testable without a terminal;
|
|
20
|
+
* 3. the DISPATCHERS — panelBlockRows/panelLead/panelStatus/
|
|
21
|
+
* panelAffordance re-exported with the ask branch folded in, so the
|
|
22
|
+
* compositor and the editor change ONE import line between them and
|
|
23
|
+
* the panel slot itself stays exactly as W21 built it.
|
|
24
|
+
*
|
|
25
|
+
* What is deliberately NOT here (the round's stop clauses): no partial
|
|
26
|
+
* answer durability (a crash re-presents the WHOLE call — per-toggle
|
|
27
|
+
* durability would need a new durable mechanism), no "chat about this"
|
|
28
|
+
* hand-off, no timeout and no countdown.
|
|
29
|
+
*/
|
|
30
|
+
import { panelAffordance as basePanelAffordance, panelBlockRows as basePanelBlockRows, panelLead as basePanelLead, panelLeadPlain as basePanelLeadPlain, panelStatus as basePanelStatus, } from "./approval-panel.js";
|
|
31
|
+
import { cutLine } from "@vincemakes/kiso-tui-cells/components";
|
|
32
|
+
import { escapeTerminal, palette } from "./render.js";
|
|
33
|
+
/** The schema's own bounds — the registry refuses anything outside them
|
|
34
|
+
* (extensions/ask validates; these are the numbers it validates to). */
|
|
35
|
+
export const ASK_MAX_QUESTIONS = 4;
|
|
36
|
+
export const ASK_MIN_OPTIONS = 2;
|
|
37
|
+
export const ASK_MAX_OPTIONS = 4;
|
|
38
|
+
export const ASK_HEADER_CAP = 12;
|
|
39
|
+
/** The ask panel's opening state: nothing picked, the cursor on the
|
|
40
|
+
* first option of the first question. */
|
|
41
|
+
export function askStart(spec) {
|
|
42
|
+
return {
|
|
43
|
+
qIndex: 0,
|
|
44
|
+
cursor: 0,
|
|
45
|
+
picks: spec.questions.map(() => []),
|
|
46
|
+
custom: spec.questions.map(() => null),
|
|
47
|
+
phase: "options",
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/** The decline's honest record: every question with its options, so the
|
|
51
|
+
* model reads what it did NOT get an answer to (frame 4). The SAME
|
|
52
|
+
* list whether the human pressed esc on question one or question four
|
|
53
|
+
* — the round declines the CALL, never half of it. */
|
|
54
|
+
export function askDeclineList(spec) {
|
|
55
|
+
return spec.questions.map((q) => `${q.question} (${q.options.map((o) => o.label).join(", ")})`);
|
|
56
|
+
}
|
|
57
|
+
export function askDeclineAll(spec) {
|
|
58
|
+
return { declined: askDeclineList(spec) };
|
|
59
|
+
}
|
|
60
|
+
/** The answers collected so far, in the tool_result's own shapes: a
|
|
61
|
+
* typed answer wins over the picks (the human typed it last), a
|
|
62
|
+
* multi-select question yields `choices`, a single one `choice`. */
|
|
63
|
+
export function askAnswers(spec, state) {
|
|
64
|
+
return spec.questions.map((q, i) => {
|
|
65
|
+
const typed = state.custom[i];
|
|
66
|
+
if (typed !== null && typed !== undefined && typed !== "")
|
|
67
|
+
return { q: q.question, custom: typed };
|
|
68
|
+
const picked = (state.picks[i] ?? []).map((n) => q.options[n]?.label ?? "");
|
|
69
|
+
return q.multiSelect === true ? { q: q.question, choices: picked } : { q: q.question, choice: picked[0] ?? "" };
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
/** Whether the CURRENT question has something to submit — a pick or a
|
|
73
|
+
* typed answer. Enter on an empty question is a no-op: the panel never
|
|
74
|
+
* invents an answer, and never silently skips one. */
|
|
75
|
+
function answered(state, i) {
|
|
76
|
+
const typed = state.custom[i];
|
|
77
|
+
return (state.picks[i] ?? []).length > 0 || (typed !== null && typed !== undefined && typed !== "");
|
|
78
|
+
}
|
|
79
|
+
/** Advance past the current question — the next one, or the end. */
|
|
80
|
+
function advance(spec, state) {
|
|
81
|
+
const next = state.qIndex + 1;
|
|
82
|
+
if (next >= spec.questions.length)
|
|
83
|
+
return { state, result: { answers: askAnswers(spec, state) } };
|
|
84
|
+
return { state: { ...state, qIndex: next, cursor: 0, phase: "options" } };
|
|
85
|
+
}
|
|
86
|
+
function toggle(state, option, multi) {
|
|
87
|
+
const current = state.picks[state.qIndex] ?? [];
|
|
88
|
+
const next = multi
|
|
89
|
+
? current.includes(option)
|
|
90
|
+
? current.filter((n) => n !== option)
|
|
91
|
+
: [...current, option].sort((a, b) => a - b)
|
|
92
|
+
: [option];
|
|
93
|
+
const picks = state.picks.map((p, i) => (i === state.qIndex ? next : p));
|
|
94
|
+
// a pick supersedes a typed answer for the same question — one
|
|
95
|
+
// question, one answer, and the human's last gesture is the one.
|
|
96
|
+
const custom = state.custom.map((c, i) => (i === state.qIndex ? null : c));
|
|
97
|
+
return { ...state, picks, custom, cursor: option };
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* The pure key reducer. `key` is a single logical key: a digit "1".."4",
|
|
101
|
+
* "space", "up"/"down", "left" (walk back), "enter", "t" (type your own),
|
|
102
|
+
* or "esc". The custom phase's TEXT is not routed here — the editor owns
|
|
103
|
+
* the buffer exactly as it does for the rule-input phase, and hands the
|
|
104
|
+
* committed line to `askCommitCustom`.
|
|
105
|
+
*/
|
|
106
|
+
export function askKey(spec, state, key) {
|
|
107
|
+
const q = spec.questions[state.qIndex];
|
|
108
|
+
const multi = q.multiSelect === true;
|
|
109
|
+
if (state.phase === "custom") {
|
|
110
|
+
// esc backs out of the typing line; enter is the editor's (it
|
|
111
|
+
// carries the text and calls askCommitCustom).
|
|
112
|
+
if (key === "esc")
|
|
113
|
+
return { state: { ...state, phase: "options" } };
|
|
114
|
+
return { state };
|
|
115
|
+
}
|
|
116
|
+
if (key === "esc")
|
|
117
|
+
return { state, result: askDeclineAll(spec) };
|
|
118
|
+
if (key === "t")
|
|
119
|
+
return { state: { ...state, phase: "custom" } };
|
|
120
|
+
if (key === "left")
|
|
121
|
+
return { state: state.qIndex === 0 ? state : { ...state, qIndex: state.qIndex - 1, cursor: 0 } };
|
|
122
|
+
if (key === "up")
|
|
123
|
+
return { state: { ...state, cursor: Math.max(0, state.cursor - 1) } };
|
|
124
|
+
if (key === "down")
|
|
125
|
+
return { state: { ...state, cursor: Math.min(q.options.length - 1, state.cursor + 1) } };
|
|
126
|
+
if (key === "enter")
|
|
127
|
+
return answered(state, state.qIndex) ? advance(spec, state) : { state };
|
|
128
|
+
// SPACE selects at the cursor and NEVER commits — in either mode. It
|
|
129
|
+
// used to answer-and-advance a single-select question, which made a
|
|
130
|
+
// stray space (the most pressable key there is) an instant answer of
|
|
131
|
+
// whatever the cursor happened to be on. Enter and the digits are the
|
|
132
|
+
// only gestures that commit; space is how you point at something.
|
|
133
|
+
if (key === "space")
|
|
134
|
+
return { state: toggle(state, state.cursor, multi) };
|
|
135
|
+
const digit = Number.parseInt(key, 10);
|
|
136
|
+
if (Number.isInteger(digit) && digit >= 1 && digit <= q.options.length) {
|
|
137
|
+
const next = toggle(state, digit - 1, multi);
|
|
138
|
+
// single-select answers AND advances — the fast path a human
|
|
139
|
+
// expects; multi-select toggles and waits for enter.
|
|
140
|
+
return multi ? { state: next } : advance(spec, next);
|
|
141
|
+
}
|
|
142
|
+
return { state };
|
|
143
|
+
}
|
|
144
|
+
/** The typed answer commits: it becomes THE answer for this question
|
|
145
|
+
* (clearing its picks) and the walk advances. An empty line is a
|
|
146
|
+
* no-op back to the options — nothing is recorded. */
|
|
147
|
+
export function askCommitCustom(spec, state, text) {
|
|
148
|
+
const trimmed = text.trim();
|
|
149
|
+
if (trimmed === "")
|
|
150
|
+
return { state: { ...state, phase: "options" } };
|
|
151
|
+
const custom = state.custom.map((c, i) => (i === state.qIndex ? trimmed : c));
|
|
152
|
+
const picks = state.picks.map((p, i) => (i === state.qIndex ? [] : p));
|
|
153
|
+
return advance(spec, { ...state, custom, picks, phase: "options" });
|
|
154
|
+
}
|
|
155
|
+
// ── the rows ─────────────────────────────────────────────────────────
|
|
156
|
+
/** One option row: the number, the selection mark, the label, and the
|
|
157
|
+
* description after an em dash. The row CUTS (never folds) — the
|
|
158
|
+
* block's height is its row count, the W20 discipline. */
|
|
159
|
+
function optionRow(o, n, picked, cursor, multi, W) {
|
|
160
|
+
const p = palette();
|
|
161
|
+
const mark = multi ? (picked ? "◉" : "◯") : picked ? "◉" : " ";
|
|
162
|
+
const head = `${cursor ? p.bold : ""} ${n} ${mark} ${escapeTerminal(o.label)}${p.reset}`;
|
|
163
|
+
const body = o.description === undefined ? "" : `${p.dim} — ${escapeTerminal(o.description)}${p.reset}`;
|
|
164
|
+
return cutLine(`${head}${body}`, Math.max(1, W - 2));
|
|
165
|
+
}
|
|
166
|
+
/** The ask block's rows — the question as the rule line, the header (or
|
|
167
|
+
* the counter) as the title, the options as the body, and the
|
|
168
|
+
* type-your-own line last. The shape is the W21 block's: gutter, rule,
|
|
169
|
+
* title, divider, body, affordance, corner. */
|
|
170
|
+
export function askBlockRows(view, state, W, maxRows) {
|
|
171
|
+
const p = palette();
|
|
172
|
+
const spec = view.ask;
|
|
173
|
+
const q = spec.questions[state.qIndex];
|
|
174
|
+
const multi = q.multiSelect === true;
|
|
175
|
+
const gutter = `${p.dim}│${p.reset} `;
|
|
176
|
+
const counter = spec.questions.length > 1 ? `${p.dim} ‹ ${state.qIndex + 1}/${spec.questions.length} ›${p.reset}` : "";
|
|
177
|
+
const rows = [];
|
|
178
|
+
rows.push(`${gutter}${cutLine(`${p.bold}${escapeTerminal(q.question)}${p.reset}${counter}`, Math.max(1, W - 2))}`);
|
|
179
|
+
const header = q.header === undefined ? "the question" : escapeTerminal(q.header.slice(0, ASK_HEADER_CAP));
|
|
180
|
+
rows.push(`${gutter}${cutLine(`${p.dim}${header}${p.reset}`, Math.max(1, W - 2))}`);
|
|
181
|
+
rows.push(cutLine(`${p.dim}─ ${multi ? "pick any — space toggles" : "pick one"} ─${p.reset}`, Math.max(1, W - 2)));
|
|
182
|
+
const picks = state.picks[state.qIndex] ?? [];
|
|
183
|
+
const body = q.options.map((o, i) => `${gutter}${optionRow(o, i + 1, picks.includes(i), state.cursor === i, multi, W)}`);
|
|
184
|
+
const typed = state.custom[state.qIndex];
|
|
185
|
+
body.push(`${gutter}${cutLine(typed === null || typed === undefined
|
|
186
|
+
? `${p.dim} t type your own answer${p.reset}`
|
|
187
|
+
: ` t ◉ ${escapeTerminal(typed)}`, Math.max(1, W - 2))}`);
|
|
188
|
+
// the bounded block: the options fold nothing and cut individually,
|
|
189
|
+
// so the cap drops whole rows with the W21 notice row.
|
|
190
|
+
const budget = Math.max(1, maxRows - 5);
|
|
191
|
+
if (body.length > budget) {
|
|
192
|
+
const kept = Math.max(0, budget - 1);
|
|
193
|
+
rows.push(...body.slice(0, kept));
|
|
194
|
+
rows.push(cutLine(`${p.dim}└ +${body.length - kept} more rows — the full question is in the event log${p.reset}`, Math.max(1, W - 2)));
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
rows.push(...body);
|
|
198
|
+
}
|
|
199
|
+
rows.push(`${gutter}${p.dim}${askAffordance(state)}${p.reset}`);
|
|
200
|
+
rows.push(`${p.dim}└ ${p.reset}`);
|
|
201
|
+
return rows;
|
|
202
|
+
}
|
|
203
|
+
/** The status row's right-hand hint — the phase's keys. */
|
|
204
|
+
export function askAffordance(state) {
|
|
205
|
+
if (state.phase === "custom")
|
|
206
|
+
return "enter answers · esc backs out";
|
|
207
|
+
return state.qIndex > 0 ? "1-4 pick · t type · ← back · esc decline" : "1-4 pick · t type · esc decline";
|
|
208
|
+
}
|
|
209
|
+
/** The status row's left text — the ask's own line, with the walk. */
|
|
210
|
+
export function askStatus(view, state) {
|
|
211
|
+
const total = view.ask.questions.length;
|
|
212
|
+
return total > 1 ? `▸ question ${state.qIndex + 1} of ${total}` : "▸ a question for you";
|
|
213
|
+
}
|
|
214
|
+
/** The input row's lead: the digit lead while picking, the typing lead
|
|
215
|
+
* in the custom phase (the rule-input phase's shape, reused). */
|
|
216
|
+
export function askLeadPlain(state) {
|
|
217
|
+
return state.phase === "custom" ? "your answer: " : "1-4> ";
|
|
218
|
+
}
|
|
219
|
+
// ── the dispatchers: the panel slot, with the ask branch folded in ────
|
|
220
|
+
export function panelBlockRows(view, phase, sel, W, maxRows, ask) {
|
|
221
|
+
if (view.ask !== undefined && ask !== undefined)
|
|
222
|
+
return askBlockRows(view, ask, W, maxRows);
|
|
223
|
+
return basePanelBlockRows(view, phase, sel, W, maxRows);
|
|
224
|
+
}
|
|
225
|
+
export function panelLead(view, phase, sel, ask) {
|
|
226
|
+
const p = palette();
|
|
227
|
+
if (view.ask !== undefined && ask !== undefined)
|
|
228
|
+
return `${p.bold}${askLeadPlain(ask)}${p.reset}`;
|
|
229
|
+
return basePanelLead(view, phase, sel);
|
|
230
|
+
}
|
|
231
|
+
export function panelLeadPlain(view, phase, sel, ask) {
|
|
232
|
+
if (view.ask !== undefined && ask !== undefined)
|
|
233
|
+
return askLeadPlain(ask);
|
|
234
|
+
return basePanelLeadPlain(view, phase, sel);
|
|
235
|
+
}
|
|
236
|
+
export function panelStatus(view, phase, sel, ask) {
|
|
237
|
+
if (view.ask !== undefined && ask !== undefined)
|
|
238
|
+
return askStatus(view, ask);
|
|
239
|
+
return basePanelStatus(view, phase, sel);
|
|
240
|
+
}
|
|
241
|
+
export function panelAffordance(view, phase, sel, ask) {
|
|
242
|
+
if (view.ask !== undefined && ask !== undefined)
|
|
243
|
+
return askAffordance(ask);
|
|
244
|
+
return basePanelAffordance(view, phase, sel);
|
|
245
|
+
}
|
|
246
|
+
/** The whole panel state in one call — the compositor's four reads share
|
|
247
|
+
* one source, so an ask can never render half as an approval. */
|
|
248
|
+
export const panelRowsOf = (s, W, maxRows) => panelBlockRows(s.view, s.phase, s.sel, W, maxRows, s.ask);
|
|
249
|
+
export const panelLeadOf = (s) => panelLead(s.view, s.phase, s.sel, s.ask);
|
|
250
|
+
export const panelStatusOf = (s) => panelStatus(s.view, s.phase, s.sel, s.ask);
|
|
251
|
+
export const panelAffordanceOf = (s) => panelAffordance(s.view, s.phase, s.sel, s.ask);
|
|
252
|
+
// ── the view: what the human reads when the model asks ────────────────
|
|
253
|
+
/** The ask's PanelView. The dock-less fallback question is HONEST: a
|
|
254
|
+
* terminal without a panel cannot walk options, so it says the ask is
|
|
255
|
+
* being declined rather than pretending y/n answered it. */
|
|
256
|
+
export function askView(spec) {
|
|
257
|
+
const first = spec.questions[0];
|
|
258
|
+
return {
|
|
259
|
+
flavor: "simple",
|
|
260
|
+
name: "ask_user",
|
|
261
|
+
title: first.header ?? first.question,
|
|
262
|
+
speaker: "kiso",
|
|
263
|
+
statusText: "▸ a question for you",
|
|
264
|
+
args: { kind: "text", lines: askDeclineList(spec) },
|
|
265
|
+
fallbackQuestion: `⚠ ${escapeTerminal(first.question)} — this terminal cannot show the option panel; the question is declined `,
|
|
266
|
+
ask: spec,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KC3 §3 — the @ file picker's PURE half: the subsequence filter and
|
|
3
|
+
* the deterministic rank. No scoring library, no index, no disk. The
|
|
4
|
+
* file list is DATA the CLI feeds in (the tui purity rule: input is
|
|
5
|
+
* data, output is bytes); this module decides which of those paths a
|
|
6
|
+
* query matches, in what order, and which characters to embolden.
|
|
7
|
+
*
|
|
8
|
+
* Determinism is the whole design constraint. A fuzzy finder that
|
|
9
|
+
* reorders on a tie is unusable at speed — the row under the cursor
|
|
10
|
+
* must not move because two paths scored equal. Every comparison here
|
|
11
|
+
* ends in a total order: run length, then path length, then the raw
|
|
12
|
+
* lexical order of the path (never localeCompare, whose result depends
|
|
13
|
+
* on the machine's locale).
|
|
14
|
+
*/
|
|
15
|
+
/** The bound source's item — a repo-relative path and nothing else.
|
|
16
|
+
* Structural: the CLI passes whatever it likes as long as it has a
|
|
17
|
+
* path (slice 5 passes exactly this). */
|
|
18
|
+
export interface AtItem {
|
|
19
|
+
readonly path: string;
|
|
20
|
+
}
|
|
21
|
+
/** A matched path plus the indices the panel emboldens. */
|
|
22
|
+
export interface AtMatch {
|
|
23
|
+
readonly path: string;
|
|
24
|
+
/** the matched character positions, ascending — the panel renders
|
|
25
|
+
* these bold-white and the rest dim */
|
|
26
|
+
readonly hit: readonly number[];
|
|
27
|
+
/** the longest CONTIGUOUS run inside `hit` — the rank's first key,
|
|
28
|
+
* carried so the panel and the ranking can never disagree about
|
|
29
|
+
* why a row is where it is */
|
|
30
|
+
readonly run: number;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* KC3 §5 — the ONE cap. The file list is computed per open with no
|
|
34
|
+
* index and no watcher, so its cost is bounded here rather than
|
|
35
|
+
* amortized somewhere invisible. The source collects at most CAP + 1
|
|
36
|
+
* entries: the extra one is what makes "there were more" DISTINGUISHABLE
|
|
37
|
+
* from "there were exactly this many", so the counter row can say so
|
|
38
|
+
* honestly instead of guessing.
|
|
39
|
+
*/
|
|
40
|
+
export declare const AT_CAP = 2000;
|
|
41
|
+
/**
|
|
42
|
+
* KC3 §5 — the directories the picker never offers, and the other half
|
|
43
|
+
* of its contract with whatever host has to walk a tree to fill it.
|
|
44
|
+
*
|
|
45
|
+
* It lives here beside the cap because the two are the same kind of
|
|
46
|
+
* promise: a host that walks must prune these BEFORE descending (a
|
|
47
|
+
* post-filter would already have walked node_modules, which is the
|
|
48
|
+
* cost the pruning exists to avoid), and must stop at the cap. Hosts
|
|
49
|
+
* that get their list from a VCS ignore this set entirely — the VCS
|
|
50
|
+
* has already applied a better one.
|
|
51
|
+
*/
|
|
52
|
+
export declare const AT_SKIP: ReadonlySet<string>;
|
|
53
|
+
/** KC3 §4 — the panel's visible height. A ceiling, not a promise: the
|
|
54
|
+
* compositor clamps further when the terminal is short. */
|
|
55
|
+
export declare const AT_VISIBLE = 5;
|
|
56
|
+
/**
|
|
57
|
+
* The subsequence embedding, TIGHTENED — the two-pass walk every good
|
|
58
|
+
* fuzzy finder uses, and the reason `@ra` emboldens the "ra" of
|
|
59
|
+
* "src/range.js" rather than the r of "src" and the a of "range".
|
|
60
|
+
*
|
|
61
|
+
* Pass 1 walks forward and stops at the EARLIEST index that completes
|
|
62
|
+
* the query — this both answers "does it match at all" and fixes the
|
|
63
|
+
* right-hand edge. Pass 2 walks backward from that edge, taking the
|
|
64
|
+
* LATEST position for each query character in turn, which slides every
|
|
65
|
+
* matched character as far right as it can go without crossing the
|
|
66
|
+
* next one. The result is the most clustered embedding that ends where
|
|
67
|
+
* the earliest match ends.
|
|
68
|
+
*
|
|
69
|
+
* Case-insensitive: both sides are lowercased by the caller once per
|
|
70
|
+
* query rather than once per character.
|
|
71
|
+
*
|
|
72
|
+
* Returns the ascending match indices, or null when the query is not a
|
|
73
|
+
* subsequence of the path at all.
|
|
74
|
+
*/
|
|
75
|
+
export declare function atEmbed(lowerPath: string, lowerQuery: string): number[] | null;
|
|
76
|
+
/** The longest run of CONSECUTIVE indices in an ascending list. An
|
|
77
|
+
* empty query has no run — every path ties on it, and the rank falls
|
|
78
|
+
* through to path length. */
|
|
79
|
+
export declare function longestRun(hit: readonly number[]): number;
|
|
80
|
+
/**
|
|
81
|
+
* The filter + the rank. Case-insensitive SUBSEQUENCE over the FULL
|
|
82
|
+
* relative path (so `@tui/ed` finds packages/tui/src/editor.ts — the
|
|
83
|
+
* directory is part of what the user is typing at, not a separate
|
|
84
|
+
* field), ordered by:
|
|
85
|
+
*
|
|
86
|
+
* 1. contiguous-run length DESC — a path where the query appears as
|
|
87
|
+
* a solid stretch beats one where it is scattered across the
|
|
88
|
+
* whole string. This is the key that makes typing feel like
|
|
89
|
+
* aiming rather than fishing.
|
|
90
|
+
* 2. path length ASC — among equally solid hits, the shorter path is
|
|
91
|
+
* the more likely target (src/range.js over a deep vendored copy
|
|
92
|
+
* of the same name).
|
|
93
|
+
* 3. the path itself, lexically — the tiebreak of last resort, and
|
|
94
|
+
* the reason the order NEVER depends on the source's iteration
|
|
95
|
+
* order or on two runs of the same query disagreeing.
|
|
96
|
+
*
|
|
97
|
+
* An EMPTY query matches everything: rule 1 ties at 0 for all, so the
|
|
98
|
+
* listing is shortest-path-first, then lexical. The list is sliced to
|
|
99
|
+
* AT_CAP; `capped` reports whether anything was dropped.
|
|
100
|
+
*/
|
|
101
|
+
export declare function atFilter(items: readonly AtItem[], query: string): {
|
|
102
|
+
matches: AtMatch[];
|
|
103
|
+
capped: boolean;
|
|
104
|
+
};
|
|
105
|
+
/**
|
|
106
|
+
* KC3 §4 — the picker's WINDOW: which slice of the ranked list is on
|
|
107
|
+
* screen. The window TRAILS the selection exactly as the composer's
|
|
108
|
+
* own viewport trails the cursor (KC1 §5) — derived per read, never
|
|
109
|
+
* stored, so it can never disagree with the selection it is meant to
|
|
110
|
+
* follow.
|
|
111
|
+
*/
|
|
112
|
+
export declare function atWindow(total: number, selected: number, visible?: number): {
|
|
113
|
+
first: number;
|
|
114
|
+
count: number;
|
|
115
|
+
};
|
|
116
|
+
/**
|
|
117
|
+
* KC3 §4 — ONE row of the panel.
|
|
118
|
+
*
|
|
119
|
+
* Two columns: the file's NAME on the left with its matched characters
|
|
120
|
+
* bold, and the DIRECTORY dim on the right, pushed to the far edge.
|
|
121
|
+
* The name is what the user is aiming at; the directory is what tells
|
|
122
|
+
* two same-named files apart, which is why it is present but quiet.
|
|
123
|
+
*
|
|
124
|
+
* The selected row carries `→` on the inverse band (SGR 7, closed with
|
|
125
|
+
* 27 — never SGR 0, so it composes inside the row's own spans).
|
|
126
|
+
*
|
|
127
|
+
* The `hit` indices are over the FULL path, so they are shifted by the
|
|
128
|
+
* directory's length to land on the name. A hit that falls INSIDE the
|
|
129
|
+
* directory is simply not drawn bold — the directory column is
|
|
130
|
+
* uniformly dim by design (a bold fragment in a right-aligned dim
|
|
131
|
+
* column reads as damage, not as information).
|
|
132
|
+
*
|
|
133
|
+
* The row never exceeds W: the name cuts first (it is the flexible
|
|
134
|
+
* column), and the directory is dropped entirely before the name is
|
|
135
|
+
* cut to nothing.
|
|
136
|
+
*/
|
|
137
|
+
export declare function atRow(match: AtMatch, selected: boolean, W: number): string;
|
|
138
|
+
/**
|
|
139
|
+
* KC3 §4 — the counter row: `(n/total)`, where n is the 1-based
|
|
140
|
+
* position of the SELECTION in the whole ranked list, not in the
|
|
141
|
+
* visible window. The user needs to know where they are in the list,
|
|
142
|
+
* which the five visible rows cannot tell them.
|
|
143
|
+
*
|
|
144
|
+
* When the source list was truncated the row SAYS SO. A file picker
|
|
145
|
+
* that quietly lists 2,000 of 40,000 files and shows a confident
|
|
146
|
+
* "(3/1998)" is lying by omission; this one admits the horizon.
|
|
147
|
+
*/
|
|
148
|
+
export declare function atCounterRow(selected: number, total: number, capped: boolean, W: number): string;
|
|
149
|
+
/**
|
|
150
|
+
* KC3 §4 — the whole band: at most AT_VISIBLE windowed rows, then the
|
|
151
|
+
* counter. Returned as plain strings for the menu-rows channel, which
|
|
152
|
+
* already accounts them in chromeRows — the picker needs no geometry
|
|
153
|
+
* of its own, which is the entire reason it rides that channel.
|
|
154
|
+
*/
|
|
155
|
+
export declare function atPanelRows(state: {
|
|
156
|
+
matches: readonly AtMatch[];
|
|
157
|
+
selected: number;
|
|
158
|
+
capped: boolean;
|
|
159
|
+
}, W: number): string[];
|