@vincemakes/kiso-tui-cells 0.11.0 → 0.13.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,136 @@
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
+ /**
67
+ * R3v2-F1 — the same sentence, for the one failure the reply's own text
68
+ * can PROVE.
69
+ *
70
+ * The unqualified line is true of every failure the ask has, which is
71
+ * exactly why it explains nothing when the cause was knowable. A reply
72
+ * the token budget cut in half is knowable: the JSON opens and never
73
+ * closes. So that case gets the cause in a parenthesis and keeps
74
+ * everything else — one line, dim, still leading with what is still
75
+ * true — because the human is mid-approval and the shape of the sentence
76
+ * is what they have already learned to read.
77
+ */
78
+ export declare const SAFER_DEGRADED_TRUNCATED = "couldn't get safer options (the reply was cut short) \u2014 the original choices stand";
79
+ /**
80
+ * R3v2-F1 — a failure that knows why it failed.
81
+ *
82
+ * `truncated` is the only cause the caller can demonstrate from the
83
+ * text, and it is deliberately the only member: a diagnosis the product
84
+ * cannot prove is worse than no diagnosis, so every other failure stays
85
+ * the unqualified line rather than growing a guess.
86
+ */
87
+ export interface SaferFailure {
88
+ readonly reason: "truncated";
89
+ }
90
+ /**
91
+ * R3v2-F1 — what the safer-options provider hands back.
92
+ *
93
+ * A list is the answer. `null` is a failure with nothing to add, and is
94
+ * still the whole contract for any caller that has nothing to add — the
95
+ * widening is additive, so an existing provider's behaviour is
96
+ * byte-identical. A SaferFailure is a failure that can name its cause.
97
+ */
98
+ export type SaferAnswer = readonly SaferOption[] | SaferFailure | null;
99
+ /** R3v2-F1: the line a failed ask owes the human — the unqualified one,
100
+ * unless the answer named a cause. The panel asks this instead of
101
+ * reaching for a constant, so the choice of sentence lives with the
102
+ * sentences. */
103
+ export declare function saferDegradedNote(answer: SaferAnswer): string;
104
+ /** The row that returns to state 1. Rendered last, always present — an
105
+ * alternatives list you cannot back out of would be a trap. */
106
+ export declare const SAFER_BACK = "back to the original choices";
107
+ /** What an option DOES — the verdict channel it commits to. The label is
108
+ * what the human reads; the kind is what the editor routes on, so the
109
+ * copy can change without touching a single branch. */
110
+ export type PanelOptionKind = "allow" | "rule" | "safer" | "deny";
111
+ export interface PanelOption {
112
+ readonly kind: PanelOptionKind;
113
+ readonly label: string;
114
+ }
115
+ /**
116
+ * The options a view offers, IN ROW ORDER — and the order is the whole
117
+ * contract: the index is the digit, the digit is the row, and the row is
118
+ * what a mouse click lands on. One list, read by the renderer, the key
119
+ * router and the hit-test, so those three can never disagree about what
120
+ * option 3 is.
121
+ *
122
+ * The approval flavor's copy is the v4 frames', with ONE correction.
123
+ * The frame said "don't ask again for <tool> this session"; the rule
124
+ * machinery (addDontAskAgainRule) writes a generated extension file that
125
+ * outlives the process and matches on the TOOL NAME. "This session"
126
+ * would have understated a durable grant — the one direction a
127
+ * permission prompt must never be wrong in — so the scope claim is
128
+ * dropped rather than invented. The revocation path is the file itself,
129
+ * which the generated header documents.
130
+ */
131
+ export declare function panelOptions(view: PanelView): readonly PanelOption[];
132
+ /**
133
+ * TUI2-R3v2 ④ — the deletion-risk hint: four patterns, and nothing else.
134
+ *
135
+ * The owner's ruling narrowed this to commands where UNDO DOES NOT
136
+ * EXIST. That is the whole selection criterion, and it is what makes the
137
+ * line worth reading: a warning on every dangerous command teaches the
138
+ * eye to skip warnings, and the eye is the only thing standing between
139
+ * the human and the side effect.
140
+ *
141
+ * So `dd if=/dev/zero of=/dev/sda` gets nothing. It is more destructive
142
+ * than anything in this table and it is not in it, because the moment
143
+ * the rules start guessing they start being wrong in both directions —
144
+ * missing the real ones and crying wolf on `git checkout main`. Four
145
+ * shapes, matched exactly, no inference.
146
+ *
147
+ * The rm case NAMES ITS TARGETS. "This deletes files" is a sentence
148
+ * about the command's category; "(node_modules, dist)" is the thing the
149
+ * human is actually deciding about, and it is the difference between a
150
+ * hint and a label.
151
+ *
152
+ * Local string rules: zero requests, zero rent, and it never blocks —
153
+ * the hint is a sentence beside the command, never a gate in front of
154
+ * it. The mode moat and the safe-defaults moat are the teeth; this is
155
+ * the eyes.
156
+ */
157
+ export declare function deletionRiskHint(command: string): string | null;
30
158
  /** One option of a question: the label the human picks, plus an
31
159
  * optional one-line description (the model's own words). */
32
160
  export interface AskOption {
@@ -144,6 +272,23 @@ export interface PanelView {
144
272
  /** The fallback question — the y/n text for the dock-less path
145
273
  * (a TTY without a dock, or a pipe). */
146
274
  readonly fallbackQuestion: string;
275
+ /** TUI2-R3v2 ③: this call is the model's answer to a refusal — the v4
276
+ * frame's "(amended)" marker. It says WHY the call looks different
277
+ * from the one just refused; without it a second approval for the same
278
+ * tool reads as the product asking twice. */
279
+ readonly amended?: boolean;
280
+ /** TUI2-R3v2 ④: the deletion-risk line, when the command matches one
281
+ * of the four irreversible patterns. Composed by the CLI (which owns
282
+ * the tool input) from deletionRiskHint; absent for every other
283
+ * command, which is most of them. */
284
+ readonly riskHint?: string;
285
+ /** TUI2-R3v2 ①: the SIMPLE flavor's two labels. A trust gate answers
286
+ * "Yes / No", but an uncertain execution answers "rerun / abandon"
287
+ * and an unanswered ask "re-ask / drop" — those callers used to
288
+ * smuggle their labels into the rule line as "— 1 rerun · 3 abandon",
289
+ * which stated the digits as well, and the digits have moved. The
290
+ * labels belong on the rows that carry them. */
291
+ readonly simpleOptions?: readonly [string, string];
147
292
  /** KC3.5: the questions, when this view is an ASK. Present = the
148
293
  * panel renders the ask block and the editor routes the ask keys;
149
294
  * absent = the approval/simple panel, unchanged. */
@@ -184,34 +329,78 @@ export type PanelVerdict = {
184
329
  export interface PanelState {
185
330
  readonly view: PanelView;
186
331
  readonly phase: PanelPhase;
187
- readonly sel: PanelSel;
332
+ /** TUI2-R3v2 ①: the highlighted row, 0-based into panelOptions(view).
333
+ * There is no "nothing selected" value any more — the bar opens on
334
+ * the first option, which is what makes a bare ⏎ an approval. */
335
+ readonly cursor: number;
336
+ /** TUI2-R3v2 ①: the one dim line a failed gesture owes the human (the
337
+ * safer-options degradation). Absent when there is nothing to say. */
338
+ readonly note?: string;
339
+ /** TUI2-R3v2 ③: the safer list's walk — present exactly in the
340
+ * "safer" phase. */
341
+ readonly safer?: SaferRuntime;
188
342
  /** KC3.5: the ask's walk — present exactly when `view.ask` is. */
189
343
  readonly ask?: AskRuntime;
190
344
  /** TUI2-R2 \u2463: the pick's walk — present exactly when `view.pick` is. */
191
345
  readonly pick?: PickRuntime;
192
346
  }
347
+ /**
348
+ * TUI2-R3v2 ② — the block's rows AND where its option rows landed.
349
+ *
350
+ * The click hit-test needs to answer "which option is at screen row N",
351
+ * and the only honest source for that is the arithmetic that placed the
352
+ * rows. Computing it a second time — in the compositor, or in a helper
353
+ * that mirrors the budget — is how a hit-test comes to disagree with the
354
+ * picture: the args cap, the note row and the option window all move the
355
+ * list, and a mirror that misses one sends the click to the wrong
356
+ * verdict. So the renderer reports it, and there is exactly one copy of
357
+ * the sum.
358
+ *
359
+ * `offset` is the index of the first option row INSIDE the returned
360
+ * rows; `first` is which option that row shows (the window's start, non-
361
+ * zero only on a short block).
362
+ */
363
+ export interface PanelBlockLayout {
364
+ readonly rows: readonly string[];
365
+ readonly offset: number;
366
+ readonly count: number;
367
+ readonly first: number;
368
+ }
193
369
  /** The block's rows — EXACTLY the preview's frame shape, the gutter at
194
370
  * the left edge (the preview's two-space mock indent is its own
195
371
  * styling; the real rows sit at column 1, like every tool cell).
196
372
  * 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;
373
+ export declare function panelBlockRows(view: PanelView, phase: PanelPhase, cursor: number, W: number, maxRows: number, note?: string, safer?: SaferRuntime): string[];
374
+ export declare function panelBlockLayout(view: PanelView, phase: PanelPhase, cursor: number, W: number, maxRows: number, note?: string, safer?: SaferRuntime): PanelBlockLayout;
375
+ /**
376
+ * The input row's lead. In the options phase there is NOTHING to type,
377
+ * so the lead stops pretending there is.
378
+ *
379
+ * "1-3> " was a prompt: it told the human to enter something and press
380
+ * return, which is exactly the interaction this round removed. The row
381
+ * keeps the composer's own quiet lead while the list is up (the keys are
382
+ * on the list and in the hint line), and the typed phase — the one place
383
+ * a human really is writing — leads with the word for what they are
384
+ * writing.
385
+ */
386
+ export declare function panelLead(view: PanelView, phase: PanelPhase, cursor: number): string;
203
387
  /** The lead's plain text — the editor's reflow width (the line must
204
388
  * 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;
389
+ export declare function panelLeadPlain(view: PanelView, phase: PanelPhase, cursor: number): string;
390
+ export declare function panelLeadWidth(view: PanelView, phase: PanelPhase, cursor: number): number;
207
391
  /** The status row's left text while the panel is up — the phase, not
208
392
  * the CLI's painting status (the compositor derives it from the panel
209
393
  * 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;
394
+ export declare function panelStatus(view: PanelView, phase: PanelPhase, cursor: number): string;
395
+ /**
396
+ * The status row's right-aligned hint the v4 frame's line, verbatim.
397
+ *
398
+ * It names all four gestures because all four now exist at once and the
399
+ * digit range is the only part that varies: "1-4" on an approval, "1-2"
400
+ * on the simple flavors. The click is advertised for the same reason the
401
+ * arrows are — an affordance nobody is told about is one nobody uses.
402
+ */
403
+ export declare function panelAffordance(view: PanelView, phase: PanelPhase, cursor: number, safer?: SaferRuntime): string;
215
404
  /**
216
405
  * The pick block's rows — the prototype's C frame.
217
406
  *
@@ -25,11 +25,145 @@
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
+ /**
39
+ * R3v2-F1 — the same sentence, for the one failure the reply's own text
40
+ * can PROVE.
41
+ *
42
+ * The unqualified line is true of every failure the ask has, which is
43
+ * exactly why it explains nothing when the cause was knowable. A reply
44
+ * the token budget cut in half is knowable: the JSON opens and never
45
+ * closes. So that case gets the cause in a parenthesis and keeps
46
+ * everything else — one line, dim, still leading with what is still
47
+ * true — because the human is mid-approval and the shape of the sentence
48
+ * is what they have already learned to read.
49
+ */
50
+ export const SAFER_DEGRADED_TRUNCATED = "couldn't get safer options (the reply was cut short) — the original choices stand";
51
+ /** R3v2-F1: the line a failed ask owes the human — the unqualified one,
52
+ * unless the answer named a cause. The panel asks this instead of
53
+ * reaching for a constant, so the choice of sentence lives with the
54
+ * sentences. */
55
+ export function saferDegradedNote(answer) {
56
+ const failure = answer === null || Array.isArray(answer) ? null : answer;
57
+ return failure?.reason === "truncated" ? SAFER_DEGRADED_TRUNCATED : SAFER_DEGRADED;
58
+ }
59
+ /** The row that returns to state 1. Rendered last, always present — an
60
+ * alternatives list you cannot back out of would be a trap. */
61
+ export const SAFER_BACK = "back to the original choices";
62
+ /**
63
+ * The options a view offers, IN ROW ORDER — and the order is the whole
64
+ * contract: the index is the digit, the digit is the row, and the row is
65
+ * what a mouse click lands on. One list, read by the renderer, the key
66
+ * router and the hit-test, so those three can never disagree about what
67
+ * option 3 is.
68
+ *
69
+ * The approval flavor's copy is the v4 frames', with ONE correction.
70
+ * The frame said "don't ask again for <tool> this session"; the rule
71
+ * machinery (addDontAskAgainRule) writes a generated extension file that
72
+ * outlives the process and matches on the TOOL NAME. "This session"
73
+ * would have understated a durable grant — the one direction a
74
+ * permission prompt must never be wrong in — so the scope claim is
75
+ * dropped rather than invented. The revocation path is the file itself,
76
+ * which the generated header documents.
77
+ */
78
+ export function panelOptions(view) {
79
+ if (view.flavor === "simple") {
80
+ const [yes, no] = view.simpleOptions ?? ["Yes", "No"];
81
+ return [
82
+ { kind: "allow", label: yes },
83
+ { kind: "deny", label: no },
84
+ ];
85
+ }
86
+ return [
87
+ { kind: "allow", label: "Yes, run it" },
88
+ { kind: "rule", label: `Yes, and don't ask again for ${displayVerb(view.name)}` },
89
+ { kind: "safer", label: "Show me safer ways to do this" },
90
+ { kind: "deny", label: "No — let me tell it what to do instead" },
91
+ ];
92
+ }
93
+ /**
94
+ * TUI2-R3v2 ④ — the deletion-risk hint: four patterns, and nothing else.
95
+ *
96
+ * The owner's ruling narrowed this to commands where UNDO DOES NOT
97
+ * EXIST. That is the whole selection criterion, and it is what makes the
98
+ * line worth reading: a warning on every dangerous command teaches the
99
+ * eye to skip warnings, and the eye is the only thing standing between
100
+ * the human and the side effect.
101
+ *
102
+ * So `dd if=/dev/zero of=/dev/sda` gets nothing. It is more destructive
103
+ * than anything in this table and it is not in it, because the moment
104
+ * the rules start guessing they start being wrong in both directions —
105
+ * missing the real ones and crying wolf on `git checkout main`. Four
106
+ * shapes, matched exactly, no inference.
107
+ *
108
+ * The rm case NAMES ITS TARGETS. "This deletes files" is a sentence
109
+ * about the command's category; "(node_modules, dist)" is the thing the
110
+ * human is actually deciding about, and it is the difference between a
111
+ * hint and a label.
112
+ *
113
+ * Local string rules: zero requests, zero rent, and it never blocks —
114
+ * the hint is a sentence beside the command, never a gate in front of
115
+ * it. The mode moat and the safe-defaults moat are the teeth; this is
116
+ * the eyes.
117
+ */
118
+ export function deletionRiskHint(command) {
119
+ // a compound command's risk can be its SECOND half ("npm run clean &&
120
+ // git clean -fd"), so the segments are scanned in order and the FIRST
121
+ // match wins: one line, never a stack of them.
122
+ for (const raw of command.split(/&&|\|\||[;|]/)) {
123
+ const segment = raw.trim();
124
+ if (segment === "")
125
+ continue;
126
+ const hint = segmentRisk(segment);
127
+ if (hint !== null)
128
+ return hint;
129
+ }
130
+ return null;
131
+ }
132
+ function segmentRisk(segment) {
133
+ const words = segment.split(/\s+/);
134
+ const verb = words[0];
135
+ if (verb === "rm") {
136
+ // -rf in any spelling or order (-rf, -fr, -r -f), because the shell
137
+ // accepts all of them and the human meant the same thing by each.
138
+ const flags = words.slice(1).filter((w) => /^-[a-zA-Z]+$/.test(w));
139
+ const letters = flags.join("");
140
+ if (!letters.includes("r") || !letters.includes("f"))
141
+ return null;
142
+ const targets = words.slice(1).filter((w) => !/^-/.test(w));
143
+ return targets.length === 0 ? "⚠ deletes files permanently" : `⚠ deletes files permanently (${targets.join(", ")})`;
144
+ }
145
+ if (verb !== "git")
146
+ return null;
147
+ const sub = words[1];
148
+ // `git checkout -- <paths>` discards; `git checkout <branch>` does not,
149
+ // and conflating them would put a red line on the most ordinary command
150
+ // in the product.
151
+ if (sub === "checkout" && words.includes("--"))
152
+ return "⚠ discards your uncommitted changes — unrecoverable";
153
+ if (sub === "reset" && words.includes("--hard"))
154
+ return "⚠ throws away commits and working changes";
155
+ // `git clean -n` is a DRY RUN and is the reason this checks for the f
156
+ // rather than for the command.
157
+ if (sub === "clean") {
158
+ const letters = words
159
+ .slice(2)
160
+ .filter((w) => /^-[a-zA-Z]+$/.test(w))
161
+ .join("");
162
+ if (letters.includes("f"))
163
+ return "⚠ deletes untracked files permanently";
164
+ }
165
+ return null;
166
+ }
33
167
  /** The rule line's text — the why-asked line (the R3 chain): the tool
34
168
  * name, the first non-abstain speaker, the fix hint (the §3.5 table,
35
169
  * code-accented). The simple flavor carries the CLI's own question
@@ -44,53 +178,55 @@ function panelRuleText(view) {
44
178
  // ("edit needs approval"). view.name keeps the RAW tool name, which is
45
179
  // what the option-2 rule prefill and the fallbackQuestion (the
46
180
  // 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}`;
181
+ // TUI2-R3v2 ③: the marker is SPLICED, and the un-amended line's bytes
182
+ // are left exactly as they were.
183
+ //
184
+ // The first version composed one template for both cases, closing and
185
+ // reopening the dim run around the marker slot. That is invisible on
186
+ // screen and it broke the RAW BYTE run "needs approval — asked by",
187
+ // which four PTY gates use as a frame needle — the driver matches on
188
+ // the byte stream, so the needle stopped matching, the approval was
189
+ // never answered, and the panel hung. An ordinary approval must be
190
+ // byte-identical to what it was; only the amended one differs.
191
+ const head = `${p.bold}${escapeTerminal(displayVerb(view.name))}${p.reset} `;
192
+ const tail = ` ${p.bold}${escapeTerminal(view.speaker)}${p.reset}`;
193
+ const base = view.amended === true
194
+ ? `${head}${p.dim}needs approval · (amended) — asked by${p.reset}${tail}`
195
+ : `${head}${p.dim}needs approval — asked by${p.reset}${tail}`;
48
196
  return hint ? `${base}${p.dim} ·${p.reset} ${p.code}${escapeTerminal(hint)}${p.reset}` : base;
49
197
  }
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) {
198
+ /**
199
+ * TUI2-R3v2 ONE ROW PER OPTION, and the cursor's row is a bar.
200
+ *
201
+ * The retired form packed every option onto one line and, below W=47,
202
+ * DROPPED the middle one to make the line fit a narrow terminal
203
+ * silently lost the ability to grant a durable rule. A list has no such
204
+ * trade to make: each option owns a row, a narrow window cuts LABELS,
205
+ * and every choice stays reachable at every width the product survives.
206
+ *
207
+ * The unselected row carries the block's gutter and a two-space indent;
208
+ * the selected row is the shared selectionBar, which spends its own two
209
+ * cells of frame. Both build their span against W−2, so the digit column
210
+ * does not shift as the bar walks — a column that moves per row reads as
211
+ * damage, which is the R2 picker's finding, inherited.
212
+ */
213
+ function panelOptionRow(option, n, selected, W) {
59
214
  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));
215
+ const room = Math.max(1, W - 2);
216
+ const plain = ` ${n} ${option.label}`;
217
+ const text = cutLine(`${selected ? p.bold : ""}${escapeTerminal(plain)}${p.reset}`, room);
218
+ if (!selected)
219
+ return `${p.dim}│${p.reset} ${text}`;
220
+ return selectionBar(text, visibleWidth(text), W);
88
221
  }
89
222
  /** The block's rows — EXACTLY the preview's frame shape, the gutter at
90
223
  * the left edge (the preview's two-space mock indent is its own
91
224
  * styling; the real rows sit at column 1, like every tool cell).
92
225
  * maxRows caps the TOTAL (the args fold; the single-row lines cut). */
93
- export function panelBlockRows(view, phase, sel, W, maxRows) {
226
+ export function panelBlockRows(view, phase, cursor, W, maxRows, note, safer) {
227
+ return panelBlockLayout(view, phase, cursor, W, maxRows, note, safer).rows;
228
+ }
229
+ export function panelBlockLayout(view, phase, cursor, W, maxRows, note, safer) {
94
230
  const p = palette();
95
231
  const gutter = `${p.dim}│${p.reset} `;
96
232
  const rows = [];
@@ -107,7 +243,23 @@ export function panelBlockRows(view, phase, sel, W, maxRows) {
107
243
  const args = view.args.kind === "diff"
108
244
  ? diffBody(view.args.diff, W, true) // the expanded path — never the tool cell's capped copy
109
245
  : view.args.lines.flatMap((line) => gutterFold(`${p.dim}│${p.reset} `, escapeTerminal(line), W));
110
- const argsBudget = Math.max(1, maxRows - 6);
246
+ // TUI2-R3v2 ①: the block now spends N rows on options instead of one,
247
+ // so the args and the list SHARE what is left after the chrome (the
248
+ // rule, the title, the divider, the affordance, the corner — five
249
+ // rows, plus the note when there is one). The list wins the tie: a
250
+ // human at an approval is choosing, and one more line of a command
251
+ // they can also read in the event log is worth less than the row that
252
+ // carries the choice. The args keep a floor of one row so the block
253
+ // never claims to show what it is asking about and then shows nothing.
254
+ const chrome = 5 +
255
+ (phase === "options" && note !== undefined ? 1 : 0) +
256
+ (view.riskHint !== undefined && view.riskHint !== "" ? 1 : 0) +
257
+ (phase === "asking" ? 1 : 0) +
258
+ // the safer list's rows + its way-back row
259
+ (phase === "safer" && safer !== undefined ? safer.options.length + 1 : 0);
260
+ const optionCount = phase === "options" ? panelOptions(view).length : 0;
261
+ const optionsShown = Math.min(optionCount, Math.max(1, maxRows - chrome - 1));
262
+ const argsBudget = Math.max(1, maxRows - chrome - optionsShown);
111
263
  let shown;
112
264
  if (args.length > argsBudget) {
113
265
  const kept = Math.max(0, argsBudget - 1);
@@ -118,8 +270,63 @@ export function panelBlockRows(view, phase, sel, W, maxRows) {
118
270
  shown = args;
119
271
  }
120
272
  rows.push(...shown);
121
- rows.push(`${gutter}${panelOptionsRow(view, sel, W)}`);
122
- rows.push(`${gutter}${p.dim}${panelAffordance(view, phase, sel)}${p.reset}`);
273
+ // TUI2-R3v2 ④: the risk hint sits directly under the args, because it
274
+ // is a sentence ABOUT those args — the v4 frame's placement. The warn
275
+ // tint is the palette's existing functional yellow (no new colour),
276
+ // and under NO_COLOR the ⚠ still carries it.
277
+ const risk = view.riskHint;
278
+ if (risk !== undefined && risk !== "")
279
+ rows.push(`${gutter}${cutLine(`${p.warn}${escapeTerminal(risk)}${p.reset}`, Math.max(1, W - 2))}`);
280
+ // TUI2-R3v2 ①: the option LIST. While the typed phase is open the list
281
+ // stands down — the human is writing prose to the model, and a bar
282
+ // hovering over "Yes, run it" while they do it claims a choice is still
283
+ // live that their next keystroke is not addressing.
284
+ let offset = 0;
285
+ let first = 0;
286
+ // TUI2-R3v2 ③: the in-flight line. A button that goes quiet for two
287
+ // seconds reads as broken, and this one is making a network call —
288
+ // so the panel says what it is doing, and says that esc still works.
289
+ if (phase === "asking") {
290
+ rows.push(`${gutter}${cutLine(`${p.dim}asking the model for safer options…${p.reset}`, Math.max(1, W - 2))}`);
291
+ }
292
+ // TUI2-R3v2 ③: the alternatives, as a list in the SAME shape as the
293
+ // approval's own — the round's one interaction model, applied to the
294
+ // one new surface rather than excepted from it. The way back is the
295
+ // last row and is always present: an alternatives list you cannot back
296
+ // out of would be a trap.
297
+ if (phase === "safer" && safer !== undefined) {
298
+ offset = rows.length;
299
+ for (let i = 0; i < safer.options.length; i += 1) {
300
+ const o = safer.options[i];
301
+ rows.push(panelOptionRow({ kind: "allow", label: `${o.command} — ${o.why}` }, i + 1, i === safer.cursor, W));
302
+ }
303
+ rows.push(panelOptionRow({ kind: "deny", label: SAFER_BACK }, safer.options.length + 1, safer.cursor === safer.options.length, W));
304
+ }
305
+ if (phase === "options") {
306
+ if (note !== undefined)
307
+ rows.push(`${gutter}${cutLine(`${p.dim}${escapeTerminal(note)}${p.reset}`, Math.max(1, W - 2))}`);
308
+ offset = rows.length;
309
+ const options = panelOptions(view);
310
+ // A window, never a truncation. On a screen too short for the whole
311
+ // list the options SCROLL under the bar — the cursor's row is always
312
+ // in view, ↑↓ still reach every option and the digits still address
313
+ // the full list (the affordance says "1-4" whether four rows fit or
314
+ // two do). Dropping the tail instead would make an option that the
315
+ // key still takes invisible, which is the one failure a permission
316
+ // list must not have.
317
+ first = Math.max(0, Math.min(cursor - optionsShown + 1, options.length - optionsShown));
318
+ for (let i = first; i < first + optionsShown; i += 1)
319
+ rows.push(panelOptionRow(options[i], i + 1, i === cursor, W));
320
+ }
321
+ const layout = {
322
+ offset,
323
+ // the safer list is clickable by the same rule the option list is —
324
+ // one interaction model means the click works on every list, and its
325
+ // rows include the way back (hence +1)
326
+ count: phase === "options" ? optionsShown : phase === "safer" && safer !== undefined ? safer.options.length + 1 : 0,
327
+ first,
328
+ };
329
+ rows.push(`${gutter}${p.dim}${cutLine(panelAffordance(view, phase, cursor, safer), Math.max(1, W - 2))}${p.reset}`);
123
330
  // TUI2-R1.5 11 (VD-13): a real bottom RULE, in the block's own edge
124
331
  // vocabulary — the same box-drawing run its divider already uses —
125
332
  // anchored at the gutter column. It used to be `\u2514 `: a two-cell stub
@@ -129,53 +336,75 @@ export function panelBlockRows(view, phase, sel, W, maxRows) {
129
336
  // row meaning entirely different things. The rule reads as an edge,
130
337
  // and the cut notice above it reads as a notice.
131
338
  rows.push(`${p.dim}\u2514${"\u2500".repeat(Math.max(0, W - 1))}${p.reset}`);
132
- return rows;
339
+ return { rows, ...layout };
133
340
  }
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) {
341
+ /**
342
+ * The input row's lead. In the options phase there is NOTHING to type,
343
+ * so the lead stops pretending there is.
344
+ *
345
+ * "1-3> " was a prompt: it told the human to enter something and press
346
+ * return, which is exactly the interaction this round removed. The row
347
+ * keeps the composer's own quiet lead while the list is up (the keys are
348
+ * on the list and in the hint line), and the typed phase — the one place
349
+ * a human really is writing — leads with the word for what they are
350
+ * writing.
351
+ */
352
+ export function panelLead(view, phase, cursor) {
139
353
  const p = palette();
140
- if (phase === "rule")
141
- return `${p.dim}2 Yes, don't ask again for ${p.reset}`;
142
354
  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}`;
355
+ return `${p.dim}amend ${p.reset}`;
356
+ return `${p.dim}${PANEL_IDLE_LEAD}${p.reset}`;
145
357
  }
358
+ /** The composer's lead while a selection list owns the keys — the quiet
359
+ * chevron, not a prompt for input that is not being asked for. */
360
+ const PANEL_IDLE_LEAD = "› ";
146
361
  /** The lead's plain text — the editor's reflow width (the line must
147
362
  * 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> ";
363
+ export function panelLeadPlain(view, phase, cursor) {
364
+ return phase === "amend" ? "amend› " : PANEL_IDLE_LEAD;
154
365
  }
155
- export function panelLeadWidth(view, phase, sel) {
156
- return displayWidth(panelLeadPlain(view, phase, sel));
366
+ export function panelLeadWidth(view, phase, cursor) {
367
+ return displayWidth(panelLeadPlain(view, phase, cursor));
157
368
  }
158
369
  /** The status row's left text while the panel is up — the phase, not
159
370
  * the CLI's painting status (the compositor derives it from the panel
160
371
  * state; the "▸ run paused" etc. ride the options phase). */
161
- export function panelStatus(view, phase, sel) {
162
- if (phase === "rule")
163
- return "▸ rule input";
372
+ export function panelStatus(view, phase, cursor) {
373
+ // TUI2-R3v2 ③: the frames' own words — what the panel is doing, and
374
+ // (in the safer list) what it did.
375
+ if (phase === "asking")
376
+ return "\u25b8 asked the model for safer options";
377
+ if (phase === "safer")
378
+ return "\u25b8 asked the model for safer options";
379
+ // TUI2-R3v2 ①: the typed phase says where the words GO. "the words ride
380
+ // the verdict" described the plumbing to whoever wrote it; the human
381
+ // typing needs to know the model will read this and answer with a new
382
+ // call — which is what the v4 frame says, in those words.
164
383
  if (phase === "amend")
165
- return sel === 3 ? "▸ deny · the words become the tool_result" : "▸ amend · the words ride the verdict";
384
+ return "▸ your note goes to the model it will propose a new call";
166
385
  return view.statusText;
167
386
  }
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";
387
+ /**
388
+ * The status row's right-aligned hint the v4 frame's line, verbatim.
389
+ *
390
+ * It names all four gestures because all four now exist at once and the
391
+ * digit range is the only part that varies: "1-4" on an approval, "1-2"
392
+ * on the simple flavors. The click is advertised for the same reason the
393
+ * arrows are — an affordance nobody is told about is one nobody uses.
394
+ */
395
+ export function panelAffordance(view, phase, cursor, safer) {
174
396
  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";
397
+ return " send · esc back";
398
+ // TUI2-R3v2 ③: the ask is in flight — the ONE key that still means
399
+ // something is the one that gets you out of it.
400
+ if (phase === "asking")
401
+ return "esc cancels";
402
+ // the same sentence the approval list carries, counting the rows THIS
403
+ // list has (the alternatives plus the way back)
404
+ if (phase === "safer" && safer !== undefined) {
405
+ return `↑↓ move · ⏎ or click confirms · 1-${safer.options.length + 1} instant · esc`;
406
+ }
407
+ return `↑↓ move · ⏎ or click confirms · 1-${panelOptions(view).length} instant · esc`;
179
408
  }
180
409
  // ── TUI2-R2 ④: the pick block, its lead, its status, its affordance ──
181
410
  /**
@@ -350,6 +350,34 @@ export declare function statusLine(status: string, tail: string, W: number, hint
350
350
  /** The display-width prefix of a plain (SGR-free) text. W21: exported
351
351
  * for the approval panel's option-2 rule-name cut. */
352
352
  export declare function widthCut(text: string, max: number): string;
353
+ /**
354
+ * TUI2-R3v2 ① — THE selection bar. One engine, every selection surface.
355
+ *
356
+ * The R1.5 ⑧ ruling settled the shape (a full-row reverse bar, not a
357
+ * two-cell marker you have to hunt for in eighty columns) and the @
358
+ * picker, the user chip and the R2 session picker each grew their own
359
+ * copy of the composition. The approval panel would have been the
360
+ * fourth, so the composition moves HERE and the surfaces call it.
361
+ *
362
+ * Two details are the whole reason this is a function and not four
363
+ * inlined string templates:
364
+ *
365
+ * - the inner `reset`s are rewritten to reset-then-reverse. A plain SGR
366
+ * 0 inside the bar punches a hole in it: the row goes back to normal
367
+ * video mid-span and the bar reads as two bars with a gap. The close
368
+ * is SGR 27 (rvEnd), never SGR 0, for the same reason — the bar
369
+ * composes INSIDE whatever span surrounds it.
370
+ * - the pad is computed from the caller's measured VISIBLE width, never
371
+ * from the styled string's length. A bar that stops short is not a
372
+ * bar, and one that runs past W crashes the compositor's invariant ①
373
+ * rather than truncating quietly — so the arithmetic is stated once,
374
+ * here, and proven once, in the sweep gates.
375
+ *
376
+ * The bar spends one cell of frame at each end, so callers build their
377
+ * spans against W−2 whether the row is selected or not — which is what
378
+ * keeps the columns from moving as the bar walks the list.
379
+ */
380
+ export declare function selectionBar(styled: string, visible: number, W: number): string;
353
381
  /** W6 — the box: the chrome's top rail. The two ╌ dotted rows become
354
382
  * a rounded box (the box already says "input lives here"); the rails
355
383
  * stay dim, the width is still the full W (the box is a rail with
@@ -1411,6 +1411,38 @@ export function widthCut(text, max) {
1411
1411
  }
1412
1412
  return text.slice(0, i);
1413
1413
  }
1414
+ /**
1415
+ * TUI2-R3v2 ① — THE selection bar. One engine, every selection surface.
1416
+ *
1417
+ * The R1.5 ⑧ ruling settled the shape (a full-row reverse bar, not a
1418
+ * two-cell marker you have to hunt for in eighty columns) and the @
1419
+ * picker, the user chip and the R2 session picker each grew their own
1420
+ * copy of the composition. The approval panel would have been the
1421
+ * fourth, so the composition moves HERE and the surfaces call it.
1422
+ *
1423
+ * Two details are the whole reason this is a function and not four
1424
+ * inlined string templates:
1425
+ *
1426
+ * - the inner `reset`s are rewritten to reset-then-reverse. A plain SGR
1427
+ * 0 inside the bar punches a hole in it: the row goes back to normal
1428
+ * video mid-span and the bar reads as two bars with a gap. The close
1429
+ * is SGR 27 (rvEnd), never SGR 0, for the same reason — the bar
1430
+ * composes INSIDE whatever span surrounds it.
1431
+ * - the pad is computed from the caller's measured VISIBLE width, never
1432
+ * from the styled string's length. A bar that stops short is not a
1433
+ * bar, and one that runs past W crashes the compositor's invariant ①
1434
+ * rather than truncating quietly — so the arithmetic is stated once,
1435
+ * here, and proven once, in the sweep gates.
1436
+ *
1437
+ * The bar spends one cell of frame at each end, so callers build their
1438
+ * spans against W−2 whether the row is selected or not — which is what
1439
+ * keeps the columns from moving as the bar walks the list.
1440
+ */
1441
+ export function selectionBar(styled, visible, W) {
1442
+ const p = palette();
1443
+ const inner = styled.replaceAll(p.reset, `${p.reset}${p.rv}`);
1444
+ return `${p.rv} ${inner}${" ".repeat(Math.max(0, W - visible - 2))} ${p.rvEnd}`;
1445
+ }
1414
1446
  /** W6 — the box: the chrome's top rail. The two ╌ dotted rows become
1415
1447
  * a rounded box (the box already says "input lives here"); the rails
1416
1448
  * stay dim, the width is still the full W (the box is a rail with
package/dist/index.d.ts CHANGED
@@ -10,7 +10,7 @@ export { SPINNER, foldLine, foldWords, visibleWidth, bodySpacing, Container, cel
10
10
  export { editFileDiff, truncateDiff, writeFileDiff, type DiffLine, type DiffResult } from "./diff.js";
11
11
  export { pendingQueueRows } from "./components.js";
12
12
  export { charWidth, displayWidth, leadWidth, widthOf } from "./width.js";
13
- export { panelAffordance, panelBlockRows, panelLead, panelLeadPlain, panelLeadWidth, panelStatus, type PanelArgs, type PanelFlavor, type PanelPhase, type PanelSel, type PanelState, type PanelVerdict, type PanelView, type AskAnswer, type AskOption, type AskQuestion, type AskResult, type AskRuntime, type AskSpec, } from "./approval-panel.js";
13
+ export { panelAffordance, panelBlockRows, panelLead, panelLeadPlain, panelLeadWidth, panelStatus, type PanelArgs, type PanelFlavor, type PanelPhase, deletionRiskHint, SAFER_BACK, SAFER_DEGRADED, type SaferOption, type SaferRuntime, panelOptions, type PanelOption, type PanelOptionKind, type PanelState, type PanelVerdict, type PanelView, type AskAnswer, type AskOption, type AskQuestion, type AskResult, type AskRuntime, type AskSpec, } from "./approval-panel.js";
14
14
  export { interactivePrompt, projectTrustRows, projectTrustView, projectUntrustedNote, uncertainView, type TrustArtifact, } from "./strings.js";
15
15
  export { extensionsBannerText, helpRows, unansweredAskView, type BannerExtension } from "./strings.js";
16
16
  export { displayVerb } from "./strings.js";
package/dist/index.js CHANGED
@@ -17,7 +17,7 @@ export { charWidth, displayWidth, leadWidth, widthOf } from "./width.js";
17
17
  // that replaces the running tool's live window while a human-chain
18
18
  // approval is pending. Types + the row/lead/status renderers; the
19
19
  // verdict mapping lives in the cli, never here.
20
- export { panelAffordance, panelBlockRows, panelLead, panelLeadPlain, panelLeadWidth, panelStatus, } from "./approval-panel.js";
20
+ export { panelAffordance, panelBlockRows, panelLead, panelLeadPlain, panelLeadWidth, panelStatus, deletionRiskHint, SAFER_BACK, SAFER_DEGRADED, panelOptions, } from "./approval-panel.js";
21
21
  // KC3 slice 1 (the extraction): the human-facing strings the CLI's
22
22
  // trust-ui.ts used to build inline — the prompt, the project-trust
23
23
  // listing/view/note, the uncertain execution's view. The flow stays in
package/dist/strings.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
- * tui-cells — the human-facing STRINGS (KC3 slice 1, the ADR-0041
3
- * escape hatch: extraction, never a raise): the readline prompt, the
2
+ * tui-cells — the human-facing STRINGS (KC3 slice 1, the escape hatch
3
+ * of ADR-0043, which supersedes ADR-0041): the readline prompt, the
4
4
  * project-trust listing rows and its panel view, the uncertain
5
5
  * execution's panel view, and the non-TTY not-trusted note. All five
6
6
  * were built inline in the CLI's trust-ui.ts before the move.
@@ -53,10 +53,14 @@ export declare function projectTrustView(root: string, files: readonly TrustArti
53
53
  * stderr by the caller; never silent. */
54
54
  export declare function projectUntrustedNote(count: number, root: string): string;
55
55
  /** rounds 8/10 — the uncertain execution's panel: the SIMPLE flavor
56
- * again, with the options re-labelled in the rule line (1 rerun · 3
57
- * abandon). The tool name is escaped for the dock-less fallback
58
- * question because it reaches the terminal as raw text there; the
59
- * panel's own rows are escaped by the panel renderer. */
56
+ * again. TUI2-R3v2 ①: the option labels used to be smuggled into the
57
+ * rule line ("— 1 rerun · 3 abandon") because the panel only ever
58
+ * rendered "Yes" and "No"; they ride `simpleOptions` now and land on the
59
+ * rows themselves, which is both where they belong and the only way they
60
+ * stay true — the digits moved, and copy naming a digit it does not own
61
+ * goes stale silently. The tool name is escaped for the dock-less
62
+ * fallback question because it reaches the terminal as raw text there;
63
+ * the panel's own rows are escaped by the panel renderer. */
60
64
  export declare function uncertainView(name: string, executionId: string): PanelView;
61
65
  /**
62
66
  * KC3.5 — the SAME uncertainty gate, said honestly for an ask_user call.
@@ -70,7 +74,7 @@ export declare function uncertainView(name: string, executionId: string): PanelV
70
74
  * (The round's ① probe pinned why this surface exists at all: the
71
75
  * shipped recovery blocks on a started-unreported execution regardless
72
76
  * of idempotency, so an interrupted ask meets this gate on the way
73
- * back. Re-asking is safe — that is what "1 re-ask" says out loud.)
77
+ * back. Re-asking is safe — that is what the first option says out loud.)
74
78
  */
75
79
  export declare function unansweredAskView(executionId: string): PanelView;
76
80
  /** An extension as the banner names it — the live `connecting` flag is
@@ -119,24 +123,20 @@ export declare function displayVerb(name: string): string;
119
123
  * one dim line rather than four table rows, because they apply only
120
124
  * while a panel is up. */
121
125
  /**
122
- * TUI2-R1.5 pin 6 — this row has to be true of BOTH panel flavors, and
123
- * "digits select" was wrong in both directions at once.
126
+ * TUI2-R1.5 pin 6 — this row has to be true of BOTH panel flavors.
124
127
  *
125
- * On an APPROVAL a digit only moves the selection (editor's #panelSelect
126
- * sets `sel` and renders); ENTER is what resolves it. A reader who
127
- * pressed 1 and walked away had approved nothing the worst kind of
128
- * false affordance, on the surface where the stakes are a side effect.
128
+ * The R1.5 wording ("digits pick · confirms") was the sentence that
129
+ * covered an approval where a digit only SELECTED and an ask where a
130
+ * digit ANSWERED. TUI2-R3v2 removed the disagreement it was papering
131
+ * over: every panel is a list with a bar on it, ↑↓ move the bar, (or a
132
+ * click) takes the row under it, and a digit takes its row outright.
133
+ * One sentence, now true of every flavor for the same reason rather than
134
+ * by careful omission.
129
135
  *
130
- * On an ASK the opposite: a digit on a SINGLE-choice question answers it
131
- * and advances the walk (ask-panel's askKey advance), so "select"
132
- * undersold it. A multi-select question toggles and waits for enter,
133
- * like the approval.
134
- *
135
- * "digits pick · ⏎ confirms" is the sentence both flavors satisfy. The
136
- * single-choice fast path — where the confirm is implicit — is the one
137
- * thing a single row cannot also carry; it is an omission, never a lie.
136
+ * The two ask-only gestures keep their clauses because the ask is the
137
+ * only flavor with a set to build and an answer to write.
138
138
  */
139
- export declare const PANEL_KEYS_ROW = "panels: digits pick \u00B7 \u23CE confirms \u00B7 space toggles \u00B7 t types an answer";
139
+ export declare const PANEL_KEYS_ROW = "panels: \u2191\u2193 move \u00B7 \u23CE or click confirms \u00B7 1-4 instant \u00B7 space toggles \u00B7 t types";
140
140
  /**
141
141
  * TUI2-R1 (D) — the sheet, one screen, static.
142
142
  *
package/dist/strings.js CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
- * tui-cells — the human-facing STRINGS (KC3 slice 1, the ADR-0041
3
- * escape hatch: extraction, never a raise): the readline prompt, the
2
+ * tui-cells — the human-facing STRINGS (KC3 slice 1, the escape hatch
3
+ * of ADR-0043, which supersedes ADR-0041): the readline prompt, the
4
4
  * project-trust listing rows and its panel view, the uncertain
5
5
  * execution's panel view, and the non-TTY not-trusted note. All five
6
6
  * were built inline in the CLI's trust-ui.ts before the move.
@@ -64,10 +64,14 @@ export function projectUntrustedNote(count, root) {
64
64
  return `[project .kiso] found ${count} artifact(s) in ${root} — not trusted, not loaded (run kiso interactively once to decide)`;
65
65
  }
66
66
  /** rounds 8/10 — the uncertain execution's panel: the SIMPLE flavor
67
- * again, with the options re-labelled in the rule line (1 rerun · 3
68
- * abandon). The tool name is escaped for the dock-less fallback
69
- * question because it reaches the terminal as raw text there; the
70
- * panel's own rows are escaped by the panel renderer. */
67
+ * again. TUI2-R3v2 ①: the option labels used to be smuggled into the
68
+ * rule line ("— 1 rerun · 3 abandon") because the panel only ever
69
+ * rendered "Yes" and "No"; they ride `simpleOptions` now and land on the
70
+ * rows themselves, which is both where they belong and the only way they
71
+ * stay true — the digits moved, and copy naming a digit it does not own
72
+ * goes stale silently. The tool name is escaped for the dock-less
73
+ * fallback question because it reaches the terminal as raw text there;
74
+ * the panel's own rows are escaped by the panel renderer. */
71
75
  export function uncertainView(name, executionId) {
72
76
  return {
73
77
  flavor: "simple",
@@ -76,7 +80,8 @@ export function uncertainView(name, executionId) {
76
80
  speaker: "kiso",
77
81
  statusText: "▸ uncertain execution",
78
82
  args: { kind: "text", lines: [executionId] },
79
- ruleOverride: "did the interrupted execution apply? — 1 rerun · 3 abandon",
83
+ ruleOverride: "did the interrupted execution apply?",
84
+ simpleOptions: ["rerun it", "abandon it"],
80
85
  fallbackQuestion: `⚠ interrupted execution: ${escapeTerminal(name)} (${executionId}) — did it apply? (y)es / (n)o `,
81
86
  };
82
87
  }
@@ -92,7 +97,7 @@ export function uncertainView(name, executionId) {
92
97
  * (The round's ① probe pinned why this surface exists at all: the
93
98
  * shipped recovery blocks on a started-unreported execution regardless
94
99
  * of idempotency, so an interrupted ask meets this gate on the way
95
- * back. Re-asking is safe — that is what "1 re-ask" says out loud.)
100
+ * back. Re-asking is safe — that is what the first option says out loud.)
96
101
  */
97
102
  export function unansweredAskView(executionId) {
98
103
  return {
@@ -102,7 +107,8 @@ export function unansweredAskView(executionId) {
102
107
  speaker: "kiso",
103
108
  statusText: "▸ unanswered question",
104
109
  args: { kind: "text", lines: [executionId] },
105
- ruleOverride: "an unanswered question was interrupted — ask it again? — 1 re-ask · 3 drop",
110
+ ruleOverride: "an unanswered question was interrupted — ask it again?",
111
+ simpleOptions: ["ask it again", "drop it"],
106
112
  fallbackQuestion: `⚠ an unanswered question was interrupted (${executionId}) — ask it again? (y)es / (n)o `,
107
113
  };
108
114
  }
@@ -193,24 +199,20 @@ export function displayVerb(name) {
193
199
  * one dim line rather than four table rows, because they apply only
194
200
  * while a panel is up. */
195
201
  /**
196
- * TUI2-R1.5 pin 6 — this row has to be true of BOTH panel flavors, and
197
- * "digits select" was wrong in both directions at once.
202
+ * TUI2-R1.5 pin 6 — this row has to be true of BOTH panel flavors.
198
203
  *
199
- * On an APPROVAL a digit only moves the selection (editor's #panelSelect
200
- * sets `sel` and renders); ENTER is what resolves it. A reader who
201
- * pressed 1 and walked away had approved nothing the worst kind of
202
- * false affordance, on the surface where the stakes are a side effect.
204
+ * The R1.5 wording ("digits pick · confirms") was the sentence that
205
+ * covered an approval where a digit only SELECTED and an ask where a
206
+ * digit ANSWERED. TUI2-R3v2 removed the disagreement it was papering
207
+ * over: every panel is a list with a bar on it, ↑↓ move the bar, (or a
208
+ * click) takes the row under it, and a digit takes its row outright.
209
+ * One sentence, now true of every flavor for the same reason rather than
210
+ * by careful omission.
203
211
  *
204
- * On an ASK the opposite: a digit on a SINGLE-choice question answers it
205
- * and advances the walk (ask-panel's askKey advance), so "select"
206
- * undersold it. A multi-select question toggles and waits for enter,
207
- * like the approval.
208
- *
209
- * "digits pick · ⏎ confirms" is the sentence both flavors satisfy. The
210
- * single-choice fast path — where the confirm is implicit — is the one
211
- * thing a single row cannot also carry; it is an omission, never a lie.
212
+ * The two ask-only gestures keep their clauses because the ask is the
213
+ * only flavor with a set to build and an answer to write.
212
214
  */
213
- export const PANEL_KEYS_ROW = "panels: digits pick · ⏎ confirms · space toggles · t types an answer";
215
+ export const PANEL_KEYS_ROW = "panels: ↑↓ move · ⏎ or click confirms · 1-4 instant · space toggles · t types";
214
216
  /** The sheet's grid: the first six bindings in two 3-column rows, the
215
217
  * last four in two 2-column rows (the wide entries get the room). The
216
218
  * COLUMN STOPS are the prototype's absolute positions, floored by the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-tui-cells",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "description": "kiso tui-cells — the components cell renderer (components, diff, width, the render slice). Zero runtime dependencies: input is data, output is bytes.",
5
5
  "type": "module",
6
6
  "license": "MIT",