@vincemakes/kiso-tui 0.7.0 → 0.9.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 +276 -0
- package/dist/at-picker.d.ts +4 -0
- package/dist/at-picker.js +41 -8
- package/dist/compositor.d.ts +21 -2
- package/dist/compositor.js +232 -35
- package/dist/context-ledger.d.ts +66 -0
- package/dist/context-ledger.js +87 -0
- package/dist/editor.d.ts +4 -1
- package/dist/editor.js +118 -6
- package/dist/index.d.ts +5 -2
- package/dist/index.js +14 -2
- package/dist/status.d.ts +32 -2
- package/dist/status.js +24 -3
- 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,276 @@
|
|
|
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
|
+
// TUI2-R1.5 11 (VD-13), shared with the approval panel: a real bottom RULE, in the block's own edge
|
|
201
|
+
// vocabulary — the same box-drawing run its divider already uses —
|
|
202
|
+
// anchored at the gutter column. It used to be `\u2514 `: a two-cell stub
|
|
203
|
+
// floating at column 1, with no rule running from it and no corner
|
|
204
|
+
// above it to answer. Worse, `\u2514 ` is the cut-notice prefix everywhere
|
|
205
|
+
// else in the product, so a CAPPED panel emitted two elbow rows in a
|
|
206
|
+
// row meaning entirely different things. The rule reads as an edge,
|
|
207
|
+
// and the cut notice above it reads as a notice.
|
|
208
|
+
rows.push(`${p.dim}\u2514${"\u2500".repeat(Math.max(0, W - 1))}${p.reset}`);
|
|
209
|
+
return rows;
|
|
210
|
+
}
|
|
211
|
+
/** The status row's right-hand hint — the phase's keys. */
|
|
212
|
+
export function askAffordance(state) {
|
|
213
|
+
if (state.phase === "custom")
|
|
214
|
+
return "enter answers · esc backs out";
|
|
215
|
+
return state.qIndex > 0 ? "1-4 pick · t type · ← back · esc decline" : "1-4 pick · t type · esc decline";
|
|
216
|
+
}
|
|
217
|
+
/** The status row's left text — the ask's own line, with the walk. */
|
|
218
|
+
export function askStatus(view, state) {
|
|
219
|
+
const total = view.ask.questions.length;
|
|
220
|
+
return total > 1 ? `▸ question ${state.qIndex + 1} of ${total}` : "▸ a question for you";
|
|
221
|
+
}
|
|
222
|
+
/** The input row's lead: the digit lead while picking, the typing lead
|
|
223
|
+
* in the custom phase (the rule-input phase's shape, reused). */
|
|
224
|
+
export function askLeadPlain(state) {
|
|
225
|
+
return state.phase === "custom" ? "your answer: " : "1-4> ";
|
|
226
|
+
}
|
|
227
|
+
// ── the dispatchers: the panel slot, with the ask branch folded in ────
|
|
228
|
+
export function panelBlockRows(view, phase, sel, W, maxRows, ask) {
|
|
229
|
+
if (view.ask !== undefined && ask !== undefined)
|
|
230
|
+
return askBlockRows(view, ask, W, maxRows);
|
|
231
|
+
return basePanelBlockRows(view, phase, sel, W, maxRows);
|
|
232
|
+
}
|
|
233
|
+
export function panelLead(view, phase, sel, ask) {
|
|
234
|
+
const p = palette();
|
|
235
|
+
if (view.ask !== undefined && ask !== undefined)
|
|
236
|
+
return `${p.bold}${askLeadPlain(ask)}${p.reset}`;
|
|
237
|
+
return basePanelLead(view, phase, sel);
|
|
238
|
+
}
|
|
239
|
+
export function panelLeadPlain(view, phase, sel, ask) {
|
|
240
|
+
if (view.ask !== undefined && ask !== undefined)
|
|
241
|
+
return askLeadPlain(ask);
|
|
242
|
+
return basePanelLeadPlain(view, phase, sel);
|
|
243
|
+
}
|
|
244
|
+
export function panelStatus(view, phase, sel, ask) {
|
|
245
|
+
if (view.ask !== undefined && ask !== undefined)
|
|
246
|
+
return askStatus(view, ask);
|
|
247
|
+
return basePanelStatus(view, phase, sel);
|
|
248
|
+
}
|
|
249
|
+
export function panelAffordance(view, phase, sel, ask) {
|
|
250
|
+
if (view.ask !== undefined && ask !== undefined)
|
|
251
|
+
return askAffordance(ask);
|
|
252
|
+
return basePanelAffordance(view, phase, sel);
|
|
253
|
+
}
|
|
254
|
+
/** The whole panel state in one call — the compositor's four reads share
|
|
255
|
+
* one source, so an ask can never render half as an approval. */
|
|
256
|
+
export const panelRowsOf = (s, W, maxRows) => panelBlockRows(s.view, s.phase, s.sel, W, maxRows, s.ask);
|
|
257
|
+
export const panelLeadOf = (s) => panelLead(s.view, s.phase, s.sel, s.ask);
|
|
258
|
+
export const panelStatusOf = (s) => panelStatus(s.view, s.phase, s.sel, s.ask);
|
|
259
|
+
export const panelAffordanceOf = (s) => panelAffordance(s.view, s.phase, s.sel, s.ask);
|
|
260
|
+
// ── the view: what the human reads when the model asks ────────────────
|
|
261
|
+
/** The ask's PanelView. The dock-less fallback question is HONEST: a
|
|
262
|
+
* terminal without a panel cannot walk options, so it says the ask is
|
|
263
|
+
* being declined rather than pretending y/n answered it. */
|
|
264
|
+
export function askView(spec) {
|
|
265
|
+
const first = spec.questions[0];
|
|
266
|
+
return {
|
|
267
|
+
flavor: "simple",
|
|
268
|
+
name: "ask_user",
|
|
269
|
+
title: first.header ?? first.question,
|
|
270
|
+
speaker: "kiso",
|
|
271
|
+
statusText: "▸ a question for you",
|
|
272
|
+
args: { kind: "text", lines: askDeclineList(spec) },
|
|
273
|
+
fallbackQuestion: `⚠ ${escapeTerminal(first.question)} — this terminal cannot show the option panel; the question is declined `,
|
|
274
|
+
ask: spec,
|
|
275
|
+
};
|
|
276
|
+
}
|
package/dist/at-picker.d.ts
CHANGED
|
@@ -157,3 +157,7 @@ export declare function atPanelRows(state: {
|
|
|
157
157
|
selected: number;
|
|
158
158
|
capped: boolean;
|
|
159
159
|
}, W: number): string[];
|
|
160
|
+
/** TUI2-R1.5 ⑦(b) — the one-row dim header that turns a band into a
|
|
161
|
+
* surface. Shared by the @ picker and the / menu so the two read the
|
|
162
|
+
* same way. */
|
|
163
|
+
export declare function bandHeader(label: string, W: number): string;
|
package/dist/at-picker.js
CHANGED
|
@@ -182,20 +182,41 @@ function splitPath(path) {
|
|
|
182
182
|
export function atRow(match, selected, W) {
|
|
183
183
|
const p = palette();
|
|
184
184
|
const { dir, name } = splitPath(escapeTerminal(match.path));
|
|
185
|
-
const lead = selected ? `${p.rv}→ ${p.rvEnd}` : " ";
|
|
186
185
|
// the name's own matched positions, mapped out of the full path
|
|
187
186
|
const marks = new Set(match.hit.filter((i) => i >= dir.length).map((i) => i - dir.length));
|
|
188
|
-
//
|
|
189
|
-
|
|
187
|
+
// TUI2-R1.5 ⑧ (VD-9): the directory rides NEXT TO the name it
|
|
188
|
+
// qualifies. It used to be right-aligned to the band's far edge, which
|
|
189
|
+
// on a 100-column terminal put `src/` some eighty columns from the
|
|
190
|
+
// `parser.ts` it belongs to: the eye had to cross the whole row to
|
|
191
|
+
// learn which parser.ts this was, and the column read as a second list.
|
|
192
|
+
// Adjacent and dim, it is what it always meant to be — a qualifier.
|
|
193
|
+
// the name is the flexible column and the qualifier gives way first: a
|
|
194
|
+
// narrow window keeps the thing being aimed at.
|
|
195
|
+
const room = Math.max(1, W - 2);
|
|
196
|
+
const suffix = dir === "" ? "" : widthCut(` — ${dir}`, Math.max(0, room - 4));
|
|
197
|
+
const nameRoom = Math.max(1, room - visibleWidth(suffix));
|
|
190
198
|
const shownName = widthCut(name, nameRoom);
|
|
191
199
|
let painted = "";
|
|
192
200
|
for (let i = 0; i < shownName.length; i += 1) {
|
|
193
201
|
painted += marks.has(i) ? `${p.bold}${shownName[i]}${p.reset}` : shownName[i];
|
|
194
202
|
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
203
|
+
const text = `${painted}${suffix === "" ? "" : `${p.dim}${suffix}${p.reset}`}`;
|
|
204
|
+
const width = visibleWidth(shownName) + visibleWidth(suffix);
|
|
205
|
+
if (!selected)
|
|
206
|
+
return ` ${text}`;
|
|
207
|
+
// TUI2-R1.5 ⑧ (VD-9): the selection is a FULL-ROW bar — the W16 chip
|
|
208
|
+
// mechanism, which the user chip and the turn fold already use. A
|
|
209
|
+
// two-cell `→ ` marker on the inverse band is one character of
|
|
210
|
+
// highlight in an eighty-column row, and the walkthrough could barely
|
|
211
|
+
// find it. The bar spans the row's whole width so the selection is
|
|
212
|
+
// visible from anywhere on the line. Mono discipline: reverse video,
|
|
213
|
+
// no new colours.
|
|
214
|
+
//
|
|
215
|
+
// The inner spans close with rvEnd (SGR 27), never SGR 0 — a reset
|
|
216
|
+
// inside the bar would punch a hole in it. `painted`'s bold marks and
|
|
217
|
+
// the dim suffix both end in SGR 0, so the bar is re-opened after each.
|
|
218
|
+
const inner = `${painted.replaceAll(p.reset, `${p.reset}${p.rv}`)}${suffix === "" ? "" : `${p.dim}${suffix}${p.reset}${p.rv}`}`;
|
|
219
|
+
return `${p.rv} ${inner}${" ".repeat(Math.max(0, W - width - 2))} ${p.rvEnd}`;
|
|
199
220
|
}
|
|
200
221
|
/**
|
|
201
222
|
* KC3 §4 — the counter row: `(n/total)`, where n is the 1-based
|
|
@@ -220,9 +241,21 @@ export function atCounterRow(selected, total, capped, W) {
|
|
|
220
241
|
*/
|
|
221
242
|
export function atPanelRows(state, W) {
|
|
222
243
|
const { first, count } = atWindow(state.matches.length, state.selected);
|
|
223
|
-
|
|
244
|
+
// TUI2-R1.5 ⑦(b) (VD-8): the band NAMES itself. It renders frameless
|
|
245
|
+
// directly above the composer, so with scrollback behind it there was
|
|
246
|
+
// nothing to say where the surface began — the rows read as more
|
|
247
|
+
// history. One dim row is enough to make it UI. Mono discipline: dim
|
|
248
|
+
// text, no rule, no colour.
|
|
249
|
+
const rows = [bandHeader("files", W)];
|
|
224
250
|
for (let i = first; i < first + count; i += 1)
|
|
225
251
|
rows.push(atRow(state.matches[i], i === state.selected, W));
|
|
226
252
|
rows.push(atCounterRow(state.selected, state.matches.length, state.capped, W));
|
|
227
253
|
return rows;
|
|
228
254
|
}
|
|
255
|
+
/** TUI2-R1.5 ⑦(b) — the one-row dim header that turns a band into a
|
|
256
|
+
* surface. Shared by the @ picker and the / menu so the two read the
|
|
257
|
+
* same way. */
|
|
258
|
+
export function bandHeader(label, W) {
|
|
259
|
+
const p = palette();
|
|
260
|
+
return `${p.dim}${widthCut(label, Math.max(1, W))}${p.reset}`;
|
|
261
|
+
}
|
package/dist/compositor.d.ts
CHANGED
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
* line-mode bytes byte-for-byte (the e2e guards them).
|
|
44
44
|
*/
|
|
45
45
|
import { type MenuItem } from "./editor.js";
|
|
46
|
-
import {
|
|
46
|
+
import type { PanelState } from "./approval-panel.js";
|
|
47
47
|
import { type AtMatch } from "./at-picker.js";
|
|
48
48
|
/** KC3 §4 — the @ picker's bound state (the editor's atState()). */
|
|
49
49
|
export interface AtPanelState {
|
|
@@ -109,6 +109,20 @@ export declare class Body {
|
|
|
109
109
|
* outcome. */
|
|
110
110
|
toolVerdict(callId: string, decision: "approved" | "denied", decidedBy?: string, reason?: string): void;
|
|
111
111
|
toolRunning(callId: string): void;
|
|
112
|
+
/**
|
|
113
|
+
* TUI2-R1 (C) — the RUNNING call's observed output.
|
|
114
|
+
*
|
|
115
|
+
* The CLI tails the shell tool's progress sidecar and hands what it
|
|
116
|
+
* read to the cell. Deliberately narrow: only a cell that is still
|
|
117
|
+
* RUNNING accepts it, so an observation can never overwrite a real
|
|
118
|
+
* result, and an unchanged read costs no frame at all (a poller
|
|
119
|
+
* fires far more often than the output changes).
|
|
120
|
+
*
|
|
121
|
+
* This adds no event and no durable state. The text lands in the
|
|
122
|
+
* cell's live rendering and is replaced wholesale by the tool's own
|
|
123
|
+
* result at settle — which is the only text anything else ever reads.
|
|
124
|
+
*/
|
|
125
|
+
toolProgress(callId: string, text: string): void;
|
|
112
126
|
toolSucceeded(callId: string): void;
|
|
113
127
|
toolFailed(callId: string, error: string): void;
|
|
114
128
|
toolResult(callId: string, result: {
|
|
@@ -152,7 +166,7 @@ export declare class Body {
|
|
|
152
166
|
* frame. The inactive path keeps the historical bytes (no resume —
|
|
153
167
|
* the pipe contract). */
|
|
154
168
|
banner(version: string, extensionsText: string, resume?: ResumeMeta[]): void;
|
|
155
|
-
raw(lines: string[]): void;
|
|
169
|
+
raw(lines: string[], wrap?: "words"): void;
|
|
156
170
|
/** The last COMPLETE thinking block, for /think. */
|
|
157
171
|
lastThinking(): string | null;
|
|
158
172
|
/** The last completed tool call, for /last. */
|
|
@@ -208,6 +222,8 @@ export declare class Body {
|
|
|
208
222
|
bindInput(state: () => InputState, prompt: string): void;
|
|
209
223
|
/** Bind the editor's slash-command menu state — the MenuSelect slot
|
|
210
224
|
* occupant (the menu replaces the editor's view while open). */
|
|
225
|
+
/** TUI2-R1 (D): bind the editor's keys-sheet flag. */
|
|
226
|
+
bindSheet(state: () => boolean): void;
|
|
211
227
|
bindMenu(state: () => {
|
|
212
228
|
items: readonly MenuItem[];
|
|
213
229
|
selected: number;
|
|
@@ -252,6 +268,9 @@ export declare class Dock {
|
|
|
252
268
|
* occupant (the panel replaces the live region + the input lead
|
|
253
269
|
* while up; the old ApprovalPrompt's question slot retires). */
|
|
254
270
|
bindApproval(state: () => PanelState | null): void;
|
|
271
|
+
/** TUI2-R1 (D): bind the editor's keys-sheet flag — the slot read for
|
|
272
|
+
* the ? overlay (the menu/picker binding pattern). */
|
|
273
|
+
bindSheet(state: () => boolean): void;
|
|
255
274
|
bindInput(state: () => InputState, prompt: string): void;
|
|
256
275
|
bindMenu(state: () => {
|
|
257
276
|
items: readonly MenuItem[];
|