@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.
@@ -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
+ }
@@ -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
- // room: W the 2-cell lead 1 separating space before the directory
189
- const nameRoom = Math.max(1, W - 2 - (dir === "" ? 0 : visibleWidth(dir) + 1));
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
- if (dir === "")
196
- return `${lead}${painted}`;
197
- const pad = Math.max(1, W - 2 - visibleWidth(shownName) - visibleWidth(dir));
198
- return `${lead}${painted}${" ".repeat(pad)}${p.dim}${dir}${p.reset}`;
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
- const rows = [];
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
+ }
@@ -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 { type PanelState } from "./approval-panel.js";
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[];