@vincemakes/kiso-tui-cells 0.10.0 → 0.12.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.
@@ -25,8 +25,98 @@
25
25
  * FOLD (a bounded block's body — content folds, metadata cuts).
26
26
  */
27
27
  export type PanelFlavor = "approval" | "simple";
28
- export type PanelPhase = "options" | "rule" | "amend";
29
- export type PanelSel = 0 | 1 | 2 | 3;
28
+ /**
29
+ * TUI2-R3v2 two phases, not three.
30
+ *
31
+ * The "rule" phase is gone. It existed because option 2 used to hand the
32
+ * human a prefilled text box to edit the rule in, which implied the rule
33
+ * could be anything they typed. It could not: the generated extension
34
+ * matches on `call.name` and nothing else, so every character typed
35
+ * beyond the tool name either did nothing or silently produced a rule
36
+ * that never fired. Option 2 now grants exactly what the machinery
37
+ * supports, on the keypress, and the copy says exactly that.
38
+ */
39
+ export type PanelPhase = "options" | "amend" | "asking" | "safer";
40
+ /**
41
+ * TUI2-R3v2 ③ — one safer alternative the model proposed.
42
+ *
43
+ * Two fields and no more. The command is what would actually run, so it
44
+ * is the row's subject; `why` is the one-line plain-language reason,
45
+ * because a list of three shell commands with no explanation asks the
46
+ * human to diff them in their head — which is the work the feature
47
+ * exists to remove.
48
+ */
49
+ export interface SaferOption {
50
+ readonly command: string;
51
+ readonly why: string;
52
+ }
53
+ /** TUI2-R3v2 ③: the safer list's own walk — the options the model gave
54
+ * and the bar's place in them. The LAST row (the way back) is not an
55
+ * option and is not in this array; it is rendered after them and its
56
+ * index is `options.length`. */
57
+ export interface SaferRuntime {
58
+ readonly options: readonly SaferOption[];
59
+ readonly cursor: number;
60
+ }
61
+ /** The copy a failed ask owes the human. One line, dim, and it says what
62
+ * is still true rather than what went wrong: the original choices are
63
+ * all still there, which is the only thing they need to know to keep
64
+ * going. */
65
+ export declare const SAFER_DEGRADED = "couldn't get safer options \u2014 the original choices stand";
66
+ /** The row that returns to state 1. Rendered last, always present — an
67
+ * alternatives list you cannot back out of would be a trap. */
68
+ export declare const SAFER_BACK = "back to the original choices";
69
+ /** What an option DOES — the verdict channel it commits to. The label is
70
+ * what the human reads; the kind is what the editor routes on, so the
71
+ * copy can change without touching a single branch. */
72
+ export type PanelOptionKind = "allow" | "rule" | "safer" | "deny";
73
+ export interface PanelOption {
74
+ readonly kind: PanelOptionKind;
75
+ readonly label: string;
76
+ }
77
+ /**
78
+ * The options a view offers, IN ROW ORDER — and the order is the whole
79
+ * contract: the index is the digit, the digit is the row, and the row is
80
+ * what a mouse click lands on. One list, read by the renderer, the key
81
+ * router and the hit-test, so those three can never disagree about what
82
+ * option 3 is.
83
+ *
84
+ * The approval flavor's copy is the v4 frames', with ONE correction.
85
+ * The frame said "don't ask again for <tool> this session"; the rule
86
+ * machinery (addDontAskAgainRule) writes a generated extension file that
87
+ * outlives the process and matches on the TOOL NAME. "This session"
88
+ * would have understated a durable grant — the one direction a
89
+ * permission prompt must never be wrong in — so the scope claim is
90
+ * dropped rather than invented. The revocation path is the file itself,
91
+ * which the generated header documents.
92
+ */
93
+ export declare function panelOptions(view: PanelView): readonly PanelOption[];
94
+ /**
95
+ * TUI2-R3v2 ④ — the deletion-risk hint: four patterns, and nothing else.
96
+ *
97
+ * The owner's ruling narrowed this to commands where UNDO DOES NOT
98
+ * EXIST. That is the whole selection criterion, and it is what makes the
99
+ * line worth reading: a warning on every dangerous command teaches the
100
+ * eye to skip warnings, and the eye is the only thing standing between
101
+ * the human and the side effect.
102
+ *
103
+ * So `dd if=/dev/zero of=/dev/sda` gets nothing. It is more destructive
104
+ * than anything in this table and it is not in it, because the moment
105
+ * the rules start guessing they start being wrong in both directions —
106
+ * missing the real ones and crying wolf on `git checkout main`. Four
107
+ * shapes, matched exactly, no inference.
108
+ *
109
+ * The rm case NAMES ITS TARGETS. "This deletes files" is a sentence
110
+ * about the command's category; "(node_modules, dist)" is the thing the
111
+ * human is actually deciding about, and it is the difference between a
112
+ * hint and a label.
113
+ *
114
+ * Local string rules: zero requests, zero rent, and it never blocks —
115
+ * the hint is a sentence beside the command, never a gate in front of
116
+ * it. The mode moat and the safe-defaults moat are the teeth; this is
117
+ * the eyes.
118
+ */
119
+ export declare function deletionRiskHint(command: string): string | null;
30
120
  /** One option of a question: the label the human picks, plus an
31
121
  * optional one-line description (the model's own words). */
32
122
  export interface AskOption {
@@ -144,6 +234,23 @@ export interface PanelView {
144
234
  /** The fallback question — the y/n text for the dock-less path
145
235
  * (a TTY without a dock, or a pipe). */
146
236
  readonly fallbackQuestion: string;
237
+ /** TUI2-R3v2 ③: this call is the model's answer to a refusal — the v4
238
+ * frame's "(amended)" marker. It says WHY the call looks different
239
+ * from the one just refused; without it a second approval for the same
240
+ * tool reads as the product asking twice. */
241
+ readonly amended?: boolean;
242
+ /** TUI2-R3v2 ④: the deletion-risk line, when the command matches one
243
+ * of the four irreversible patterns. Composed by the CLI (which owns
244
+ * the tool input) from deletionRiskHint; absent for every other
245
+ * command, which is most of them. */
246
+ readonly riskHint?: string;
247
+ /** TUI2-R3v2 ①: the SIMPLE flavor's two labels. A trust gate answers
248
+ * "Yes / No", but an uncertain execution answers "rerun / abandon"
249
+ * and an unanswered ask "re-ask / drop" — those callers used to
250
+ * smuggle their labels into the rule line as "— 1 rerun · 3 abandon",
251
+ * which stated the digits as well, and the digits have moved. The
252
+ * labels belong on the rows that carry them. */
253
+ readonly simpleOptions?: readonly [string, string];
147
254
  /** KC3.5: the questions, when this view is an ASK. Present = the
148
255
  * panel renders the ask block and the editor routes the ask keys;
149
256
  * absent = the approval/simple panel, unchanged. */
@@ -184,34 +291,78 @@ export type PanelVerdict = {
184
291
  export interface PanelState {
185
292
  readonly view: PanelView;
186
293
  readonly phase: PanelPhase;
187
- readonly sel: PanelSel;
294
+ /** TUI2-R3v2 ①: the highlighted row, 0-based into panelOptions(view).
295
+ * There is no "nothing selected" value any more — the bar opens on
296
+ * the first option, which is what makes a bare ⏎ an approval. */
297
+ readonly cursor: number;
298
+ /** TUI2-R3v2 ①: the one dim line a failed gesture owes the human (the
299
+ * safer-options degradation). Absent when there is nothing to say. */
300
+ readonly note?: string;
301
+ /** TUI2-R3v2 ③: the safer list's walk — present exactly in the
302
+ * "safer" phase. */
303
+ readonly safer?: SaferRuntime;
188
304
  /** KC3.5: the ask's walk — present exactly when `view.ask` is. */
189
305
  readonly ask?: AskRuntime;
190
306
  /** TUI2-R2 \u2463: the pick's walk — present exactly when `view.pick` is. */
191
307
  readonly pick?: PickRuntime;
192
308
  }
309
+ /**
310
+ * TUI2-R3v2 ② — the block's rows AND where its option rows landed.
311
+ *
312
+ * The click hit-test needs to answer "which option is at screen row N",
313
+ * and the only honest source for that is the arithmetic that placed the
314
+ * rows. Computing it a second time — in the compositor, or in a helper
315
+ * that mirrors the budget — is how a hit-test comes to disagree with the
316
+ * picture: the args cap, the note row and the option window all move the
317
+ * list, and a mirror that misses one sends the click to the wrong
318
+ * verdict. So the renderer reports it, and there is exactly one copy of
319
+ * the sum.
320
+ *
321
+ * `offset` is the index of the first option row INSIDE the returned
322
+ * rows; `first` is which option that row shows (the window's start, non-
323
+ * zero only on a short block).
324
+ */
325
+ export interface PanelBlockLayout {
326
+ readonly rows: readonly string[];
327
+ readonly offset: number;
328
+ readonly count: number;
329
+ readonly first: number;
330
+ }
193
331
  /** The block's rows — EXACTLY the preview's frame shape, the gutter at
194
332
  * the left edge (the preview's two-space mock indent is its own
195
333
  * styling; the real rows sit at column 1, like every tool cell).
196
334
  * maxRows caps the TOTAL (the args fold; the single-row lines cut). */
197
- export declare function panelBlockRows(view: PanelView, phase: PanelPhase, sel: PanelSel, W: number, maxRows: number): string[];
198
- /** The input row's lead for the panel's phase (the preview's chrome
199
- * rows): the digit leads bold, the rule/feedback leads dim. The rule
200
- * lead names the tool (the option-2 prefill), the amend lead the
201
- * denial/allowance feedback ("the words ride the verdict"). */
202
- export declare function panelLead(view: PanelView, phase: PanelPhase, sel: PanelSel): string;
335
+ export declare function panelBlockRows(view: PanelView, phase: PanelPhase, cursor: number, W: number, maxRows: number, note?: string, safer?: SaferRuntime): string[];
336
+ export declare function panelBlockLayout(view: PanelView, phase: PanelPhase, cursor: number, W: number, maxRows: number, note?: string, safer?: SaferRuntime): PanelBlockLayout;
337
+ /**
338
+ * The input row's lead. In the options phase there is NOTHING to type,
339
+ * so the lead stops pretending there is.
340
+ *
341
+ * "1-3> " was a prompt: it told the human to enter something and press
342
+ * return, which is exactly the interaction this round removed. The row
343
+ * keeps the composer's own quiet lead while the list is up (the keys are
344
+ * on the list and in the hint line), and the typed phase — the one place
345
+ * a human really is writing — leads with the word for what they are
346
+ * writing.
347
+ */
348
+ export declare function panelLead(view: PanelView, phase: PanelPhase, cursor: number): string;
203
349
  /** The lead's plain text — the editor's reflow width (the line must
204
350
  * fit the lead + the box's walls). */
205
- export declare function panelLeadPlain(view: PanelView, phase: PanelPhase, sel: PanelSel): string;
206
- export declare function panelLeadWidth(view: PanelView, phase: PanelPhase, sel: PanelSel): number;
351
+ export declare function panelLeadPlain(view: PanelView, phase: PanelPhase, cursor: number): string;
352
+ export declare function panelLeadWidth(view: PanelView, phase: PanelPhase, cursor: number): number;
207
353
  /** The status row's left text while the panel is up — the phase, not
208
354
  * the CLI's painting status (the compositor derives it from the panel
209
355
  * state; the "▸ run paused" etc. ride the options phase). */
210
- export declare function panelStatus(view: PanelView, phase: PanelPhase, sel: PanelSel): string;
211
- /** The status row's right-aligned hint while the panel is up — the
212
- * phase's keys. The approval flavor gains the tab-amend path; the
213
- * simple flavor (the trust/uncertain gates) never does. */
214
- export declare function panelAffordance(view: PanelView, phase: PanelPhase, sel: PanelSel): string;
356
+ export declare function panelStatus(view: PanelView, phase: PanelPhase, cursor: number): string;
357
+ /**
358
+ * The status row's right-aligned hint the v4 frame's line, verbatim.
359
+ *
360
+ * It names all four gestures because all four now exist at once and the
361
+ * digit range is the only part that varies: "1-4" on an approval, "1-2"
362
+ * on the simple flavors. The click is advertised for the same reason the
363
+ * arrows are — an affordance nobody is told about is one nobody uses.
364
+ */
365
+ export declare function panelAffordance(view: PanelView, phase: PanelPhase, cursor: number, safer?: SaferRuntime): string;
215
366
  /**
216
367
  * The pick block's rows — the prototype's C frame.
217
368
  *
@@ -25,11 +25,124 @@
25
25
  * FOLD (a bounded block's body — content folds, metadata cuts).
26
26
  */
27
27
  import { displayWidth } from "./width.js";
28
- import { cutLine, diffBody, gutterFold, visibleWidth, widthCut } from "./components.js";
28
+ import { cutLine, diffBody, gutterFold, selectionBar, visibleWidth, widthCut } from "./components.js";
29
29
  // TUI2-R2pre ④: strings.js takes only a TYPE from this module, so the
30
30
  // import is erased at compile time and no runtime cycle exists.
31
31
  import { displayVerb } from "./strings.js";
32
32
  import { escapeTerminal, palette } from "./render.js";
33
+ /** The copy a failed ask owes the human. One line, dim, and it says what
34
+ * is still true rather than what went wrong: the original choices are
35
+ * all still there, which is the only thing they need to know to keep
36
+ * going. */
37
+ export const SAFER_DEGRADED = "couldn't get safer options — the original choices stand";
38
+ /** The row that returns to state 1. Rendered last, always present — an
39
+ * alternatives list you cannot back out of would be a trap. */
40
+ export const SAFER_BACK = "back to the original choices";
41
+ /**
42
+ * The options a view offers, IN ROW ORDER — and the order is the whole
43
+ * contract: the index is the digit, the digit is the row, and the row is
44
+ * what a mouse click lands on. One list, read by the renderer, the key
45
+ * router and the hit-test, so those three can never disagree about what
46
+ * option 3 is.
47
+ *
48
+ * The approval flavor's copy is the v4 frames', with ONE correction.
49
+ * The frame said "don't ask again for <tool> this session"; the rule
50
+ * machinery (addDontAskAgainRule) writes a generated extension file that
51
+ * outlives the process and matches on the TOOL NAME. "This session"
52
+ * would have understated a durable grant — the one direction a
53
+ * permission prompt must never be wrong in — so the scope claim is
54
+ * dropped rather than invented. The revocation path is the file itself,
55
+ * which the generated header documents.
56
+ */
57
+ export function panelOptions(view) {
58
+ if (view.flavor === "simple") {
59
+ const [yes, no] = view.simpleOptions ?? ["Yes", "No"];
60
+ return [
61
+ { kind: "allow", label: yes },
62
+ { kind: "deny", label: no },
63
+ ];
64
+ }
65
+ return [
66
+ { kind: "allow", label: "Yes, run it" },
67
+ { kind: "rule", label: `Yes, and don't ask again for ${displayVerb(view.name)}` },
68
+ { kind: "safer", label: "Show me safer ways to do this" },
69
+ { kind: "deny", label: "No — let me tell it what to do instead" },
70
+ ];
71
+ }
72
+ /**
73
+ * TUI2-R3v2 ④ — the deletion-risk hint: four patterns, and nothing else.
74
+ *
75
+ * The owner's ruling narrowed this to commands where UNDO DOES NOT
76
+ * EXIST. That is the whole selection criterion, and it is what makes the
77
+ * line worth reading: a warning on every dangerous command teaches the
78
+ * eye to skip warnings, and the eye is the only thing standing between
79
+ * the human and the side effect.
80
+ *
81
+ * So `dd if=/dev/zero of=/dev/sda` gets nothing. It is more destructive
82
+ * than anything in this table and it is not in it, because the moment
83
+ * the rules start guessing they start being wrong in both directions —
84
+ * missing the real ones and crying wolf on `git checkout main`. Four
85
+ * shapes, matched exactly, no inference.
86
+ *
87
+ * The rm case NAMES ITS TARGETS. "This deletes files" is a sentence
88
+ * about the command's category; "(node_modules, dist)" is the thing the
89
+ * human is actually deciding about, and it is the difference between a
90
+ * hint and a label.
91
+ *
92
+ * Local string rules: zero requests, zero rent, and it never blocks —
93
+ * the hint is a sentence beside the command, never a gate in front of
94
+ * it. The mode moat and the safe-defaults moat are the teeth; this is
95
+ * the eyes.
96
+ */
97
+ export function deletionRiskHint(command) {
98
+ // a compound command's risk can be its SECOND half ("npm run clean &&
99
+ // git clean -fd"), so the segments are scanned in order and the FIRST
100
+ // match wins: one line, never a stack of them.
101
+ for (const raw of command.split(/&&|\|\||[;|]/)) {
102
+ const segment = raw.trim();
103
+ if (segment === "")
104
+ continue;
105
+ const hint = segmentRisk(segment);
106
+ if (hint !== null)
107
+ return hint;
108
+ }
109
+ return null;
110
+ }
111
+ function segmentRisk(segment) {
112
+ const words = segment.split(/\s+/);
113
+ const verb = words[0];
114
+ if (verb === "rm") {
115
+ // -rf in any spelling or order (-rf, -fr, -r -f), because the shell
116
+ // accepts all of them and the human meant the same thing by each.
117
+ const flags = words.slice(1).filter((w) => /^-[a-zA-Z]+$/.test(w));
118
+ const letters = flags.join("");
119
+ if (!letters.includes("r") || !letters.includes("f"))
120
+ return null;
121
+ const targets = words.slice(1).filter((w) => !/^-/.test(w));
122
+ return targets.length === 0 ? "⚠ deletes files permanently" : `⚠ deletes files permanently (${targets.join(", ")})`;
123
+ }
124
+ if (verb !== "git")
125
+ return null;
126
+ const sub = words[1];
127
+ // `git checkout -- <paths>` discards; `git checkout <branch>` does not,
128
+ // and conflating them would put a red line on the most ordinary command
129
+ // in the product.
130
+ if (sub === "checkout" && words.includes("--"))
131
+ return "⚠ discards your uncommitted changes — unrecoverable";
132
+ if (sub === "reset" && words.includes("--hard"))
133
+ return "⚠ throws away commits and working changes";
134
+ // `git clean -n` is a DRY RUN and is the reason this checks for the f
135
+ // rather than for the command.
136
+ if (sub === "clean") {
137
+ const letters = words
138
+ .slice(2)
139
+ .filter((w) => /^-[a-zA-Z]+$/.test(w))
140
+ .join("");
141
+ if (letters.includes("f"))
142
+ return "⚠ deletes untracked files permanently";
143
+ }
144
+ return null;
145
+ }
33
146
  /** The rule line's text — the why-asked line (the R3 chain): the tool
34
147
  * name, the first non-abstain speaker, the fix hint (the §3.5 table,
35
148
  * code-accented). The simple flavor carries the CLI's own question
@@ -44,53 +157,55 @@ function panelRuleText(view) {
44
157
  // ("edit needs approval"). view.name keeps the RAW tool name, which is
45
158
  // what the option-2 rule prefill and the fallbackQuestion (the
46
159
  // dock-less/pipe path — byte-identical by ruling) still read.
47
- const base = `${p.bold}${escapeTerminal(displayVerb(view.name))}${p.reset} ${p.dim}needs approval asked by${p.reset} ${p.bold}${escapeTerminal(view.speaker)}${p.reset}`;
160
+ // TUI2-R3v2 ③: the marker is SPLICED, and the un-amended line's bytes
161
+ // are left exactly as they were.
162
+ //
163
+ // The first version composed one template for both cases, closing and
164
+ // reopening the dim run around the marker slot. That is invisible on
165
+ // screen and it broke the RAW BYTE run "needs approval — asked by",
166
+ // which four PTY gates use as a frame needle — the driver matches on
167
+ // the byte stream, so the needle stopped matching, the approval was
168
+ // never answered, and the panel hung. An ordinary approval must be
169
+ // byte-identical to what it was; only the amended one differs.
170
+ const head = `${p.bold}${escapeTerminal(displayVerb(view.name))}${p.reset} `;
171
+ const tail = ` ${p.bold}${escapeTerminal(view.speaker)}${p.reset}`;
172
+ const base = view.amended === true
173
+ ? `${head}${p.dim}needs approval · (amended) — asked by${p.reset}${tail}`
174
+ : `${head}${p.dim}needs approval — asked by${p.reset}${tail}`;
48
175
  return hint ? `${base}${p.dim} ·${p.reset} ${p.code}${escapeTerminal(hint)}${p.reset}` : base;
49
176
  }
50
- /** The numbered options row — "1 Yes 2 Yes, don't ask again for
51
- * <rule> 3 No" (approval) or "1 Yes 3 No" (simple). The row is a
52
- * pure SPAN: the block prepends the gutter (the invariant ① test
53
- * caught the double-gutter the block and the row both emitted it).
54
- * The option-2 rule name is the ONLY cuttable span: the row's fixed
55
- * part (the 1/3 options + the separators + the option-2 prefix) is 45
56
- * cells, so the name fits W−45 and cuts with a "…" at W−46 (the
57
- * single-row discipline the row never folds). */
58
- function panelOptionsRow(view, sel, W) {
177
+ /**
178
+ * TUI2-R3v2 ONE ROW PER OPTION, and the cursor's row is a bar.
179
+ *
180
+ * The retired form packed every option onto one line and, below W=47,
181
+ * DROPPED the middle one to make the line fit a narrow terminal
182
+ * silently lost the ability to grant a durable rule. A list has no such
183
+ * trade to make: each option owns a row, a narrow window cuts LABELS,
184
+ * and every choice stays reachable at every width the product survives.
185
+ *
186
+ * The unselected row carries the block's gutter and a two-space indent;
187
+ * the selected row is the shared selectionBar, which spends its own two
188
+ * cells of frame. Both build their span against W−2, so the digit column
189
+ * does not shift as the bar walks — a column that moves per row reads as
190
+ * damage, which is the R2 picker's finding, inherited.
191
+ */
192
+ function panelOptionRow(option, n, selected, W) {
59
193
  const p = palette();
60
- // TUI2-R1.5 (VD-13): ONE separator grammar. The options were
61
- // two-space separated while every other metadata group in the product
62
- // uses `·`, and at 80 columns that put `3 No` far enough from its
63
- // neighbours to read as detached rather than as the third option.
64
- const o1 = sel === 1 ? `${p.bold} 1 Yes${p.reset}` : ` 1 Yes`;
65
- const o3 = sel === 3 ? `${p.bold}3 No${p.reset}` : `3 No`;
66
- if (view.flavor === "simple")
67
- return `${o1} · ${o3}`;
68
- // the option-2 span: " 2 Yes, don't ask again for <name>" — the
69
- // fixed part is 45 (the gutter + the 1/3 options + the separators +
70
- // the 28-cell prefix); the name cuts to W−46 + "…". The "…" needs
71
- // its own cell, so the span fits only when W − 46 ≥ 1; below that
72
- // (W < 47 — incl. the 0.1.42 release-smoke's 40-col winch) the span
73
- // DROPS: the rule name is the cuttable span, the 1/3 options are
74
- // the semantics — the approval decision must survive a narrow
75
- // winch, and invariant ① must never fire on the options row.
76
- // TUI2-R1.5 ⑪: option 2 states what it DOES; the tool it would do it
77
- // for is the panel's title, one row above, and repeating it here was
78
- // what made this row the widest thing in the block. The fixed part is
79
- // now 33 cells, so the whole row survives far narrower windows than the
80
- // 47 the rule name used to demand.
81
- const o2 = sel === 2 ? `${p.bold}2 Yes, don't ask again${p.reset}` : `2 Yes, don't ask again`;
82
- const full = `${o1} · ${o2} · ${o3}`;
83
- if (visibleWidth(full) <= W - 2)
84
- return full;
85
- // too narrow for the middle option: the 1/3 decision is the semantics
86
- // and must survive any winch (invariant ① never fires on this row).
87
- return cutLine(`${o1} · ${o3}`, Math.max(1, W - 2));
194
+ const room = Math.max(1, W - 2);
195
+ const plain = ` ${n} ${option.label}`;
196
+ const text = cutLine(`${selected ? p.bold : ""}${escapeTerminal(plain)}${p.reset}`, room);
197
+ if (!selected)
198
+ return `${p.dim}│${p.reset} ${text}`;
199
+ return selectionBar(text, visibleWidth(text), W);
88
200
  }
89
201
  /** The block's rows — EXACTLY the preview's frame shape, the gutter at
90
202
  * the left edge (the preview's two-space mock indent is its own
91
203
  * styling; the real rows sit at column 1, like every tool cell).
92
204
  * maxRows caps the TOTAL (the args fold; the single-row lines cut). */
93
- export function panelBlockRows(view, phase, sel, W, maxRows) {
205
+ export function panelBlockRows(view, phase, cursor, W, maxRows, note, safer) {
206
+ return panelBlockLayout(view, phase, cursor, W, maxRows, note, safer).rows;
207
+ }
208
+ export function panelBlockLayout(view, phase, cursor, W, maxRows, note, safer) {
94
209
  const p = palette();
95
210
  const gutter = `${p.dim}│${p.reset} `;
96
211
  const rows = [];
@@ -107,7 +222,23 @@ export function panelBlockRows(view, phase, sel, W, maxRows) {
107
222
  const args = view.args.kind === "diff"
108
223
  ? diffBody(view.args.diff, W, true) // the expanded path — never the tool cell's capped copy
109
224
  : view.args.lines.flatMap((line) => gutterFold(`${p.dim}│${p.reset} `, escapeTerminal(line), W));
110
- const argsBudget = Math.max(1, maxRows - 6);
225
+ // TUI2-R3v2 ①: the block now spends N rows on options instead of one,
226
+ // so the args and the list SHARE what is left after the chrome (the
227
+ // rule, the title, the divider, the affordance, the corner — five
228
+ // rows, plus the note when there is one). The list wins the tie: a
229
+ // human at an approval is choosing, and one more line of a command
230
+ // they can also read in the event log is worth less than the row that
231
+ // carries the choice. The args keep a floor of one row so the block
232
+ // never claims to show what it is asking about and then shows nothing.
233
+ const chrome = 5 +
234
+ (phase === "options" && note !== undefined ? 1 : 0) +
235
+ (view.riskHint !== undefined && view.riskHint !== "" ? 1 : 0) +
236
+ (phase === "asking" ? 1 : 0) +
237
+ // the safer list's rows + its way-back row
238
+ (phase === "safer" && safer !== undefined ? safer.options.length + 1 : 0);
239
+ const optionCount = phase === "options" ? panelOptions(view).length : 0;
240
+ const optionsShown = Math.min(optionCount, Math.max(1, maxRows - chrome - 1));
241
+ const argsBudget = Math.max(1, maxRows - chrome - optionsShown);
111
242
  let shown;
112
243
  if (args.length > argsBudget) {
113
244
  const kept = Math.max(0, argsBudget - 1);
@@ -118,8 +249,63 @@ export function panelBlockRows(view, phase, sel, W, maxRows) {
118
249
  shown = args;
119
250
  }
120
251
  rows.push(...shown);
121
- rows.push(`${gutter}${panelOptionsRow(view, sel, W)}`);
122
- rows.push(`${gutter}${p.dim}${panelAffordance(view, phase, sel)}${p.reset}`);
252
+ // TUI2-R3v2 ④: the risk hint sits directly under the args, because it
253
+ // is a sentence ABOUT those args — the v4 frame's placement. The warn
254
+ // tint is the palette's existing functional yellow (no new colour),
255
+ // and under NO_COLOR the ⚠ still carries it.
256
+ const risk = view.riskHint;
257
+ if (risk !== undefined && risk !== "")
258
+ rows.push(`${gutter}${cutLine(`${p.warn}${escapeTerminal(risk)}${p.reset}`, Math.max(1, W - 2))}`);
259
+ // TUI2-R3v2 ①: the option LIST. While the typed phase is open the list
260
+ // stands down — the human is writing prose to the model, and a bar
261
+ // hovering over "Yes, run it" while they do it claims a choice is still
262
+ // live that their next keystroke is not addressing.
263
+ let offset = 0;
264
+ let first = 0;
265
+ // TUI2-R3v2 ③: the in-flight line. A button that goes quiet for two
266
+ // seconds reads as broken, and this one is making a network call —
267
+ // so the panel says what it is doing, and says that esc still works.
268
+ if (phase === "asking") {
269
+ rows.push(`${gutter}${cutLine(`${p.dim}asking the model for safer options…${p.reset}`, Math.max(1, W - 2))}`);
270
+ }
271
+ // TUI2-R3v2 ③: the alternatives, as a list in the SAME shape as the
272
+ // approval's own — the round's one interaction model, applied to the
273
+ // one new surface rather than excepted from it. The way back is the
274
+ // last row and is always present: an alternatives list you cannot back
275
+ // out of would be a trap.
276
+ if (phase === "safer" && safer !== undefined) {
277
+ offset = rows.length;
278
+ for (let i = 0; i < safer.options.length; i += 1) {
279
+ const o = safer.options[i];
280
+ rows.push(panelOptionRow({ kind: "allow", label: `${o.command} — ${o.why}` }, i + 1, i === safer.cursor, W));
281
+ }
282
+ rows.push(panelOptionRow({ kind: "deny", label: SAFER_BACK }, safer.options.length + 1, safer.cursor === safer.options.length, W));
283
+ }
284
+ if (phase === "options") {
285
+ if (note !== undefined)
286
+ rows.push(`${gutter}${cutLine(`${p.dim}${escapeTerminal(note)}${p.reset}`, Math.max(1, W - 2))}`);
287
+ offset = rows.length;
288
+ const options = panelOptions(view);
289
+ // A window, never a truncation. On a screen too short for the whole
290
+ // list the options SCROLL under the bar — the cursor's row is always
291
+ // in view, ↑↓ still reach every option and the digits still address
292
+ // the full list (the affordance says "1-4" whether four rows fit or
293
+ // two do). Dropping the tail instead would make an option that the
294
+ // key still takes invisible, which is the one failure a permission
295
+ // list must not have.
296
+ first = Math.max(0, Math.min(cursor - optionsShown + 1, options.length - optionsShown));
297
+ for (let i = first; i < first + optionsShown; i += 1)
298
+ rows.push(panelOptionRow(options[i], i + 1, i === cursor, W));
299
+ }
300
+ const layout = {
301
+ offset,
302
+ // the safer list is clickable by the same rule the option list is —
303
+ // one interaction model means the click works on every list, and its
304
+ // rows include the way back (hence +1)
305
+ count: phase === "options" ? optionsShown : phase === "safer" && safer !== undefined ? safer.options.length + 1 : 0,
306
+ first,
307
+ };
308
+ rows.push(`${gutter}${p.dim}${cutLine(panelAffordance(view, phase, cursor, safer), Math.max(1, W - 2))}${p.reset}`);
123
309
  // TUI2-R1.5 11 (VD-13): a real bottom RULE, in the block's own edge
124
310
  // vocabulary — the same box-drawing run its divider already uses —
125
311
  // anchored at the gutter column. It used to be `\u2514 `: a two-cell stub
@@ -129,53 +315,75 @@ export function panelBlockRows(view, phase, sel, W, maxRows) {
129
315
  // row meaning entirely different things. The rule reads as an edge,
130
316
  // and the cut notice above it reads as a notice.
131
317
  rows.push(`${p.dim}\u2514${"\u2500".repeat(Math.max(0, W - 1))}${p.reset}`);
132
- return rows;
318
+ return { rows, ...layout };
133
319
  }
134
- /** The input row's lead for the panel's phase (the preview's chrome
135
- * rows): the digit leads bold, the rule/feedback leads dim. The rule
136
- * lead names the tool (the option-2 prefill), the amend lead the
137
- * denial/allowance feedback ("the words ride the verdict"). */
138
- export function panelLead(view, phase, sel) {
320
+ /**
321
+ * The input row's lead. In the options phase there is NOTHING to type,
322
+ * so the lead stops pretending there is.
323
+ *
324
+ * "1-3> " was a prompt: it told the human to enter something and press
325
+ * return, which is exactly the interaction this round removed. The row
326
+ * keeps the composer's own quiet lead while the list is up (the keys are
327
+ * on the list and in the hint line), and the typed phase — the one place
328
+ * a human really is writing — leads with the word for what they are
329
+ * writing.
330
+ */
331
+ export function panelLead(view, phase, cursor) {
139
332
  const p = palette();
140
- if (phase === "rule")
141
- return `${p.dim}2 Yes, don't ask again for ${p.reset}`;
142
333
  if (phase === "amend")
143
- return `${p.dim}${sel === 3 ? "feedback (deny): " : "feedback (amend): "}${p.reset}`;
144
- return `${p.bold}${view.flavor === "approval" ? "1-3> " : "1/3> "}${p.reset}`;
334
+ return `${p.dim}amend ${p.reset}`;
335
+ return `${p.dim}${PANEL_IDLE_LEAD}${p.reset}`;
145
336
  }
337
+ /** The composer's lead while a selection list owns the keys — the quiet
338
+ * chevron, not a prompt for input that is not being asked for. */
339
+ const PANEL_IDLE_LEAD = "› ";
146
340
  /** The lead's plain text — the editor's reflow width (the line must
147
341
  * fit the lead + the box's walls). */
148
- export function panelLeadPlain(view, phase, sel) {
149
- if (phase === "rule")
150
- return "2 Yes, don't ask again for ";
151
- if (phase === "amend")
152
- return sel === 3 ? "feedback (deny): " : "feedback (amend): ";
153
- return view.flavor === "approval" ? "1-3> " : "1/3> ";
342
+ export function panelLeadPlain(view, phase, cursor) {
343
+ return phase === "amend" ? "amend› " : PANEL_IDLE_LEAD;
154
344
  }
155
- export function panelLeadWidth(view, phase, sel) {
156
- return displayWidth(panelLeadPlain(view, phase, sel));
345
+ export function panelLeadWidth(view, phase, cursor) {
346
+ return displayWidth(panelLeadPlain(view, phase, cursor));
157
347
  }
158
348
  /** The status row's left text while the panel is up — the phase, not
159
349
  * the CLI's painting status (the compositor derives it from the panel
160
350
  * state; the "▸ run paused" etc. ride the options phase). */
161
- export function panelStatus(view, phase, sel) {
162
- if (phase === "rule")
163
- return "▸ rule input";
351
+ export function panelStatus(view, phase, cursor) {
352
+ // TUI2-R3v2 ③: the frames' own words — what the panel is doing, and
353
+ // (in the safer list) what it did.
354
+ if (phase === "asking")
355
+ return "\u25b8 asked the model for safer options";
356
+ if (phase === "safer")
357
+ return "\u25b8 asked the model for safer options";
358
+ // TUI2-R3v2 ①: the typed phase says where the words GO. "the words ride
359
+ // the verdict" described the plumbing to whoever wrote it; the human
360
+ // typing needs to know the model will read this and answer with a new
361
+ // call — which is what the v4 frame says, in those words.
164
362
  if (phase === "amend")
165
- return sel === 3 ? "▸ deny · the words become the tool_result" : "▸ amend · the words ride the verdict";
363
+ return "▸ your note goes to the model it will propose a new call";
166
364
  return view.statusText;
167
365
  }
168
- /** The status row's right-aligned hint while the panel is up — the
169
- * phase's keys. The approval flavor gains the tab-amend path; the
170
- * simple flavor (the trust/uncertain gates) never does. */
171
- export function panelAffordance(view, phase, sel) {
172
- if (phase === "rule")
173
- return "enter commits · esc backs out";
366
+ /**
367
+ * The status row's right-aligned hint the v4 frame's line, verbatim.
368
+ *
369
+ * It names all four gestures because all four now exist at once and the
370
+ * digit range is the only part that varies: "1-4" on an approval, "1-2"
371
+ * on the simple flavors. The click is advertised for the same reason the
372
+ * arrows are — an affordance nobody is told about is one nobody uses.
373
+ */
374
+ export function panelAffordance(view, phase, cursor, safer) {
174
375
  if (phase === "amend")
175
- return "enter sends";
176
- if (view.flavor === "approval")
177
- return sel === 0 ? "tab amend · esc cancel" : "enter sends · esc backs out";
178
- return sel === 0 ? "esc cancel" : "enter sends";
376
+ return " send · esc back";
377
+ // TUI2-R3v2 ③: the ask is in flight — the ONE key that still means
378
+ // something is the one that gets you out of it.
379
+ if (phase === "asking")
380
+ return "esc cancels";
381
+ // the same sentence the approval list carries, counting the rows THIS
382
+ // list has (the alternatives plus the way back)
383
+ if (phase === "safer" && safer !== undefined) {
384
+ return `↑↓ move · ⏎ or click confirms · 1-${safer.options.length + 1} instant · esc`;
385
+ }
386
+ return `↑↓ move · ⏎ or click confirms · 1-${panelOptions(view).length} instant · esc`;
179
387
  }
180
388
  // ── TUI2-R2 ④: the pick block, its lead, its status, its affordance ──
181
389
  /**