@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.
- package/dist/approval-panel.d.ts +167 -16
- package/dist/approval-panel.js +283 -75
- package/dist/components.d.ts +48 -2
- package/dist/components.js +61 -19
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/md.d.ts +137 -0
- package/dist/md.js +720 -0
- package/dist/render.d.ts +10 -0
- package/dist/render.js +2 -2
- package/dist/strings.d.ts +22 -22
- package/dist/strings.js +26 -24
- package/dist/width.d.ts +14 -0
- package/dist/width.js +74 -27
- package/package.json +1 -1
package/dist/approval-panel.d.ts
CHANGED
|
@@ -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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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,
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
|
|
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,
|
|
206
|
-
export declare function panelLeadWidth(view: PanelView, phase: PanelPhase,
|
|
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,
|
|
211
|
-
/**
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
|
|
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
|
*
|
package/dist/approval-panel.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
122
|
-
|
|
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
|
-
/**
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
|
|
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}
|
|
144
|
-
return `${p.
|
|
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,
|
|
149
|
-
|
|
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,
|
|
156
|
-
return displayWidth(panelLeadPlain(view, phase,
|
|
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,
|
|
162
|
-
|
|
163
|
-
|
|
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
|
|
363
|
+
return "▸ your note goes to the model — it will propose a new call";
|
|
166
364
|
return view.statusText;
|
|
167
365
|
}
|
|
168
|
-
/**
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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 "
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
/**
|