@vincemakes/kiso-tui-cells 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/approval-panel.d.ts +61 -0
- package/dist/approval-panel.js +32 -11
- package/dist/components.d.ts +30 -6
- package/dist/components.js +406 -18
- package/dist/diff.d.ts +24 -4
- package/dist/diff.js +30 -16
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -1
- package/dist/strings.d.ts +100 -0
- package/dist/strings.js +213 -0
- package/package.json +1 -1
package/dist/approval-panel.d.ts
CHANGED
|
@@ -27,6 +27,54 @@
|
|
|
27
27
|
export type PanelFlavor = "approval" | "simple";
|
|
28
28
|
export type PanelPhase = "options" | "rule" | "amend";
|
|
29
29
|
export type PanelSel = 0 | 1 | 2 | 3;
|
|
30
|
+
/** One option of a question: the label the human picks, plus an
|
|
31
|
+
* optional one-line description (the model's own words). */
|
|
32
|
+
export interface AskOption {
|
|
33
|
+
readonly label: string;
|
|
34
|
+
readonly description?: string;
|
|
35
|
+
}
|
|
36
|
+
/** One question: 2-4 options, single- or multi-select, and an optional
|
|
37
|
+
* ≤12-cell header (the panel's title when present — the schema caps
|
|
38
|
+
* it so the title never fights the counter for the row). */
|
|
39
|
+
export interface AskQuestion {
|
|
40
|
+
readonly question: string;
|
|
41
|
+
readonly header?: string;
|
|
42
|
+
readonly options: readonly AskOption[];
|
|
43
|
+
readonly multiSelect?: boolean;
|
|
44
|
+
}
|
|
45
|
+
/** The whole ask_user call: 1-4 questions, walked in order. */
|
|
46
|
+
export interface AskSpec {
|
|
47
|
+
readonly questions: readonly AskQuestion[];
|
|
48
|
+
}
|
|
49
|
+
/** One answered question — the three shapes the tool_result carries:
|
|
50
|
+
* a single choice, a multi-select list, or the typed-in answer. */
|
|
51
|
+
export type AskAnswer = {
|
|
52
|
+
readonly q: string;
|
|
53
|
+
readonly choice: string;
|
|
54
|
+
} | {
|
|
55
|
+
readonly q: string;
|
|
56
|
+
readonly choices: readonly string[];
|
|
57
|
+
} | {
|
|
58
|
+
readonly q: string;
|
|
59
|
+
readonly custom: string;
|
|
60
|
+
};
|
|
61
|
+
/** The ask's outcome: every question answered, or the decline — an
|
|
62
|
+
* HONEST recorded outcome that names what was skipped, never silence. */
|
|
63
|
+
export type AskResult = {
|
|
64
|
+
readonly answers: readonly AskAnswer[];
|
|
65
|
+
} | {
|
|
66
|
+
readonly declined: readonly string[];
|
|
67
|
+
};
|
|
68
|
+
/** The ask panel's runtime state — the editor owns and advances it,
|
|
69
|
+
* the compositor reads it. `picks` and `custom` are per question, so
|
|
70
|
+
* a walk back (←) shows what was already chosen. */
|
|
71
|
+
export interface AskRuntime {
|
|
72
|
+
readonly qIndex: number;
|
|
73
|
+
readonly cursor: number;
|
|
74
|
+
readonly picks: readonly (readonly number[])[];
|
|
75
|
+
readonly custom: readonly (string | null)[];
|
|
76
|
+
readonly phase: "options" | "custom";
|
|
77
|
+
}
|
|
30
78
|
/** The ALWAYS-verbose args (the panel's body): the untruncated diff
|
|
31
79
|
* (edit/write), or the full text (shell = the command line, other =
|
|
32
80
|
* the pretty-printed JSON). The CLI composes them UNTRUNCATED — the
|
|
@@ -64,6 +112,10 @@ export interface PanelView {
|
|
|
64
112
|
/** The fallback question — the y/n text for the dock-less path
|
|
65
113
|
* (a TTY without a dock, or a pipe). */
|
|
66
114
|
readonly fallbackQuestion: string;
|
|
115
|
+
/** KC3.5: the questions, when this view is an ASK. Present = the
|
|
116
|
+
* panel renders the ask block and the editor routes the ask keys;
|
|
117
|
+
* absent = the approval/simple panel, unchanged. */
|
|
118
|
+
readonly ask?: AskSpec;
|
|
67
119
|
}
|
|
68
120
|
export type PanelVerdict = {
|
|
69
121
|
readonly action: "allow";
|
|
@@ -76,6 +128,13 @@ export type PanelVerdict = {
|
|
|
76
128
|
readonly reason: string;
|
|
77
129
|
} | {
|
|
78
130
|
readonly action: "cancel";
|
|
131
|
+
}
|
|
132
|
+
/** KC3.5: the ask's own verdict — the answers (or the decline) the
|
|
133
|
+
* cli hands back to the tool. Only ask views ever produce it, so
|
|
134
|
+
* the approval path's switch is untouched. */
|
|
135
|
+
| {
|
|
136
|
+
readonly action: "answers";
|
|
137
|
+
readonly result: AskResult;
|
|
79
138
|
};
|
|
80
139
|
/** The bound panel state the compositor reads — the editor owns the
|
|
81
140
|
* phase/selection state machine and the key routing; the compositor
|
|
@@ -84,6 +143,8 @@ export interface PanelState {
|
|
|
84
143
|
readonly view: PanelView;
|
|
85
144
|
readonly phase: PanelPhase;
|
|
86
145
|
readonly sel: PanelSel;
|
|
146
|
+
/** KC3.5: the ask's walk — present exactly when `view.ask` is. */
|
|
147
|
+
readonly ask?: AskRuntime;
|
|
87
148
|
}
|
|
88
149
|
/** The block's rows — EXACTLY the preview's frame shape, the gutter at
|
|
89
150
|
* the left edge (the preview's two-space mock indent is its own
|
package/dist/approval-panel.js
CHANGED
|
@@ -50,10 +50,14 @@ function panelRuleText(view) {
|
|
|
50
50
|
* single-row discipline — the row never folds). */
|
|
51
51
|
function panelOptionsRow(view, sel, W) {
|
|
52
52
|
const p = palette();
|
|
53
|
+
// TUI2-R1.5 ⑪ (VD-13): ONE separator grammar. The options were
|
|
54
|
+
// two-space separated while every other metadata group in the product
|
|
55
|
+
// uses `·`, and at 80 columns that put `3 No` far enough from its
|
|
56
|
+
// neighbours to read as detached rather than as the third option.
|
|
53
57
|
const o1 = sel === 1 ? `${p.bold} 1 Yes${p.reset}` : ` 1 Yes`;
|
|
54
|
-
const o3 = sel === 3 ? `${p.bold}
|
|
58
|
+
const o3 = sel === 3 ? `${p.bold}3 No${p.reset}` : `3 No`;
|
|
55
59
|
if (view.flavor === "simple")
|
|
56
|
-
return `${o1}
|
|
60
|
+
return `${o1} · ${o3}`;
|
|
57
61
|
// the option-2 span: " 2 Yes, don't ask again for <name>" — the
|
|
58
62
|
// fixed part is 45 (the gutter + the 1/3 options + the separators +
|
|
59
63
|
// the 28-cell prefix); the name cuts to W−46 + "…". The "…" needs
|
|
@@ -62,13 +66,18 @@ function panelOptionsRow(view, sel, W) {
|
|
|
62
66
|
// DROPS: the rule name is the cuttable span, the 1/3 options are
|
|
63
67
|
// the semantics — the approval decision must survive a narrow
|
|
64
68
|
// winch, and invariant ① must never fire on the options row.
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const o2 = sel === 2 ? `${p.bold}
|
|
71
|
-
|
|
69
|
+
// TUI2-R1.5 ⑪: option 2 states what it DOES; the tool it would do it
|
|
70
|
+
// for is the panel's title, one row above, and repeating it here was
|
|
71
|
+
// what made this row the widest thing in the block. The fixed part is
|
|
72
|
+
// now 33 cells, so the whole row survives far narrower windows than the
|
|
73
|
+
// 47 the rule name used to demand.
|
|
74
|
+
const o2 = sel === 2 ? `${p.bold}2 Yes, don't ask again${p.reset}` : `2 Yes, don't ask again`;
|
|
75
|
+
const full = `${o1} · ${o2} · ${o3}`;
|
|
76
|
+
if (visibleWidth(full) <= W - 2)
|
|
77
|
+
return full;
|
|
78
|
+
// too narrow for the middle option: the 1/3 decision is the semantics
|
|
79
|
+
// and must survive any winch (invariant ① never fires on this row).
|
|
80
|
+
return cutLine(`${o1} · ${o3}`, Math.max(1, W - 2));
|
|
72
81
|
}
|
|
73
82
|
/** The block's rows — EXACTLY the preview's frame shape, the gutter at
|
|
74
83
|
* the left edge (the preview's two-space mock indent is its own
|
|
@@ -80,7 +89,11 @@ export function panelBlockRows(view, phase, sel, W, maxRows) {
|
|
|
80
89
|
const rows = [];
|
|
81
90
|
rows.push(`${gutter}${cutLine(panelRuleText(view), Math.max(1, W - 2))}`);
|
|
82
91
|
rows.push(`${gutter}${cutLine(`${p.bold}${escapeTerminal(view.title)}${p.reset}`, Math.max(1, W - 2))}`);
|
|
83
|
-
|
|
92
|
+
// TUI2-R1.5 ⑤ (VD-11): the divider is a LABEL, not a design note. "the
|
|
93
|
+
// full args — never truncated" is a sentence about the implementation,
|
|
94
|
+
// addressed to whoever was building the panel; the human reading it
|
|
95
|
+
// during an approval wants to know what the block below is.
|
|
96
|
+
rows.push(`${cutLine(`${p.dim}─ args (full) ─${p.reset}`, Math.max(1, W - 2))}`);
|
|
84
97
|
// the args — the bounded block's body: fold, then cap. The └ cut is
|
|
85
98
|
// ONE row (the W20 discipline): when the args exceed the budget, one
|
|
86
99
|
// notice row carries the count and where the rest is (the event log).
|
|
@@ -100,7 +113,15 @@ export function panelBlockRows(view, phase, sel, W, maxRows) {
|
|
|
100
113
|
rows.push(...shown);
|
|
101
114
|
rows.push(`${gutter}${panelOptionsRow(view, sel, W)}`);
|
|
102
115
|
rows.push(`${gutter}${p.dim}${panelAffordance(view, phase, sel)}${p.reset}`);
|
|
103
|
-
|
|
116
|
+
// TUI2-R1.5 11 (VD-13): a real bottom RULE, in the block's own edge
|
|
117
|
+
// vocabulary — the same box-drawing run its divider already uses —
|
|
118
|
+
// anchored at the gutter column. It used to be `\u2514 `: a two-cell stub
|
|
119
|
+
// floating at column 1, with no rule running from it and no corner
|
|
120
|
+
// above it to answer. Worse, `\u2514 ` is the cut-notice prefix everywhere
|
|
121
|
+
// else in the product, so a CAPPED panel emitted two elbow rows in a
|
|
122
|
+
// row meaning entirely different things. The rule reads as an edge,
|
|
123
|
+
// and the cut notice above it reads as a notice.
|
|
124
|
+
rows.push(`${p.dim}\u2514${"\u2500".repeat(Math.max(0, W - 1))}${p.reset}`);
|
|
104
125
|
return rows;
|
|
105
126
|
}
|
|
106
127
|
/** The input row's lead for the panel's phase (the preview's chrome
|
package/dist/components.d.ts
CHANGED
|
@@ -108,12 +108,19 @@ export type BodyCell = {
|
|
|
108
108
|
* the head of an N > 2 same-tool run renders the group (the
|
|
109
109
|
* work order's claimed shape: "✓ read 5 files (2.4k lines,
|
|
110
110
|
* 1.1s)" + the target children). The members carry null — the
|
|
111
|
-
* compositor's rolled-heads bookkeeping renders them [].
|
|
111
|
+
* compositor's rolled-heads bookkeeping renders them [].
|
|
112
|
+
* TUI2-R1 (B): `parts` is set when the run spans MORE THAN ONE
|
|
113
|
+
* read-only tool — the same mechanism, the exploration row.
|
|
114
|
+
* Absent (a single-name run) keeps W13's row byte for byte. */
|
|
112
115
|
rolled: null | {
|
|
113
116
|
count: number;
|
|
114
117
|
lines: number;
|
|
115
118
|
elapsed: string;
|
|
116
119
|
targets: string[];
|
|
120
|
+
parts?: readonly {
|
|
121
|
+
name: string;
|
|
122
|
+
subjects: readonly string[];
|
|
123
|
+
}[];
|
|
117
124
|
};
|
|
118
125
|
/** W19: a DENIED call's reason (the CLI extracted it from the
|
|
119
126
|
* result's "[Permission denied] " prefix, keyed on the "denied"
|
|
@@ -152,6 +159,7 @@ export type BodyCell = {
|
|
|
152
159
|
kind: "raw";
|
|
153
160
|
lines: string[];
|
|
154
161
|
done: true;
|
|
162
|
+
wrap?: "words";
|
|
155
163
|
} | {
|
|
156
164
|
kind: "terminal";
|
|
157
165
|
label: string;
|
|
@@ -196,11 +204,7 @@ export declare function cellComponent(cell: BodyCell): Component;
|
|
|
196
204
|
* (the gutter's 2 cells), so a long line hard-folds INSIDE the chip
|
|
197
205
|
* and invariant ① holds on the band. */
|
|
198
206
|
export declare function pendingQueueRows(lines: readonly string[], W: number): string[];
|
|
199
|
-
|
|
200
|
-
* (W2: a wrapped tool row keeps its state mark — the left edge alone
|
|
201
|
-
* distinguishes the states at --plain; the UserMessage rail precedent,
|
|
202
|
-
* v5 #16f). The gutter carries its own SGR (e.g. the bold ✓). W21:
|
|
203
|
-
* exported for the approval panel's text args (the same │ gutter). */
|
|
207
|
+
export declare function foldWords(line: string, W: number): string[];
|
|
204
208
|
export declare function gutterFold(gutter: string, line: string, W: number): string[];
|
|
205
209
|
/** A6: the tool-header variant — ONE cut row, never a fold. A wide
|
|
206
210
|
* header (a long target path, a wordy denial reason) used to wrap
|
|
@@ -210,12 +214,32 @@ export declare function gutterFold(gutter: string, line: string, W: number): str
|
|
|
210
214
|
* full content. The budget: the gutter's own visible width + the
|
|
211
215
|
* ellipsis ride the row (the invariant ① cap holds). */
|
|
212
216
|
export declare function gutterCut(gutter: string, line: string, W: number): string[];
|
|
217
|
+
export declare function expandSuffix(lines: number | null, room: number): string;
|
|
213
218
|
/** W13 — the rollup opt-in table: which tools collapse, and the count
|
|
214
219
|
* NOUN (read_file calls → "5 files", list_dir → "5 dirs", search_text
|
|
215
220
|
* → "5 matches"). Only these tools opt in — a shell burst is never
|
|
216
221
|
* rolled up (its rows carry meaning). The folded-turn line (W14) reuses
|
|
217
222
|
* the plurals for its other-tool terms ("2 dirs", "1 match"). */
|
|
218
223
|
export declare const ROLLUP_NOUN: Readonly<Record<string, string>>;
|
|
224
|
+
/** Whether a tool joins an exploration run. Exactly the read-only set —
|
|
225
|
+
* writes, edits, shells and extension tools never group (a burst of
|
|
226
|
+
* side effects is a list of things that HAPPENED, and every row of it
|
|
227
|
+
* carries meaning). */
|
|
228
|
+
export declare function isExploreTool(name: string): boolean;
|
|
229
|
+
/** "8 files · 14 searches" — the per-tool counts in first-call order. */
|
|
230
|
+
export declare function exploreCounts(parts: readonly {
|
|
231
|
+
name: string;
|
|
232
|
+
subjects: readonly string[];
|
|
233
|
+
}[]): string;
|
|
234
|
+
/** TUI2-R1 (B) — the expanded list: ONE row per tool, the verb column
|
|
235
|
+
* then the distinct subjects in first-call order, a repeated subject
|
|
236
|
+
* carrying its ×count, the first three shown and the rest counted.
|
|
237
|
+
* A search's subject is its PATTERN (quoted — the thing that was
|
|
238
|
+
* looked for); a read's or a list's is its path. */
|
|
239
|
+
export declare function exploreRows(parts: readonly {
|
|
240
|
+
name: string;
|
|
241
|
+
subjects: readonly string[];
|
|
242
|
+
}[], W: number): string[];
|
|
219
243
|
/** W14 — the folded-turn line: a whole QUIET turn (no text), once it is
|
|
220
244
|
* scrollback, becomes ONE line — the work order's claimed shape
|
|
221
245
|
* (`▞ thought 19s · 5 reads · no edits`), the counts accumulated at
|
package/dist/components.js
CHANGED
|
@@ -251,6 +251,66 @@ class ThinkingFold {
|
|
|
251
251
|
* distinguishes the states at --plain; the UserMessage rail precedent,
|
|
252
252
|
* v5 #16f). The gutter carries its own SGR (e.g. the bold ✓). W21:
|
|
253
253
|
* exported for the approval panel's text args (the same │ gutter). */
|
|
254
|
+
/**
|
|
255
|
+
* TUI2-R1.5 ⑨ (VD-10) — the WORD-aware fold, for text a human reads.
|
|
256
|
+
*
|
|
257
|
+
* foldLine is a hard character fold at the width. That is exactly right
|
|
258
|
+
* for verbatim tool output, where a byte is a byte and a break is a
|
|
259
|
+
* display artefact the reader knows to ignore; it is exactly wrong for
|
|
260
|
+
* prose, where the reader's eye has to reassemble "ex" + "pected" into a
|
|
261
|
+
* word it already knew. The walkthrough read three of those off one
|
|
262
|
+
* screen.
|
|
263
|
+
*
|
|
264
|
+
* The implementation is a wrapper, not a second engine: the text is cut
|
|
265
|
+
* at the last space that fits and each resulting segment is handed to
|
|
266
|
+
* foldLine, which keeps the SGR close/reopen discipline, the display-
|
|
267
|
+
* width arithmetic and the newline handling in ONE place. A word longer
|
|
268
|
+
* than the width falls through to foldLine's hard break — an
|
|
269
|
+
* overflowing row would violate invariant ①, and a word that cannot fit
|
|
270
|
+
* has to be broken somewhere.
|
|
271
|
+
*/
|
|
272
|
+
/** The SGR spans still open at the end of `text`, given those open at
|
|
273
|
+
* its start. A reset closes everything; anything else stacks. */
|
|
274
|
+
function spansOpenAfter(text, before) {
|
|
275
|
+
let open = [...before];
|
|
276
|
+
for (const m of text.matchAll(/\x1b\[[0-9;]*m/g)) {
|
|
277
|
+
if (m[0] === "\x1b[0m")
|
|
278
|
+
open = [];
|
|
279
|
+
else
|
|
280
|
+
open.push(m[0]);
|
|
281
|
+
}
|
|
282
|
+
return open;
|
|
283
|
+
}
|
|
284
|
+
export function foldWords(line, W) {
|
|
285
|
+
if (W < 1)
|
|
286
|
+
return [line];
|
|
287
|
+
const out = [];
|
|
288
|
+
for (const para of line.split("\n")) {
|
|
289
|
+
if (visibleWidth(para) <= W) {
|
|
290
|
+
out.push(para);
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
let rest = para;
|
|
294
|
+
// the spans open at the cut point, so each emitted row closes them
|
|
295
|
+
// and the next row reopens them — foldLine's own discipline, applied
|
|
296
|
+
// across the segments this function creates.
|
|
297
|
+
let open = [];
|
|
298
|
+
while (visibleWidth(rest) > W) {
|
|
299
|
+
// the widest prefix that fits, then back up to the last space in
|
|
300
|
+
// it — the SGR-aware cut keeps the spans intact
|
|
301
|
+
const head = widthCut(rest, W);
|
|
302
|
+
const at = head.lastIndexOf(" ");
|
|
303
|
+
if (at <= 0)
|
|
304
|
+
break; // one long word (or no space at all) — hard-break it
|
|
305
|
+
const cut = head.slice(0, at);
|
|
306
|
+
out.push(`${cut}${open.length > 0 || /\x1b\[[0-9;]*m/.test(cut) ? "\x1b[0m" : ""}`);
|
|
307
|
+
open = spansOpenAfter(cut, open);
|
|
308
|
+
rest = `${open.join("")}${rest.slice(cut.length + 1)}`;
|
|
309
|
+
}
|
|
310
|
+
out.push(...foldLine(rest, W));
|
|
311
|
+
}
|
|
312
|
+
return out.length > 0 ? out : [""];
|
|
313
|
+
}
|
|
254
314
|
export function gutterFold(gutter, line, W) {
|
|
255
315
|
const textW = Math.max(1, W - 2);
|
|
256
316
|
return foldLine(line, textW).map((r) => `${gutter}${r}`);
|
|
@@ -340,6 +400,21 @@ function settledMeta(c) {
|
|
|
340
400
|
* read/write/edit → the path, shell → the command, list_dir → path ??
|
|
341
401
|
* "(root)". Parsed from the FULL input — the folded summary is a
|
|
342
402
|
* truncated slice. */
|
|
403
|
+
/** TUI2-R1.5 ④(a) (VD-4) — the header text for a cell that has NOT
|
|
404
|
+
* settled yet (queued, awaiting approval, running).
|
|
405
|
+
*
|
|
406
|
+
* These three states printed `c.input`: a 60-char slice of the call's
|
|
407
|
+
* JSON, escapes and all. The done card printed the plain command
|
|
408
|
+
* through toolTarget, so the SAME call read as
|
|
409
|
+
* `shell {"command":"for i in 1 2 3 4 5 6; do echo \"step $i · compil`
|
|
410
|
+
* while it ran and as `shell for i in 1 2 3 4 5 6; …` a second later.
|
|
411
|
+
* One formatter now, for every state. A cell whose full input somehow
|
|
412
|
+
* will not parse keeps the old slice — the header always says
|
|
413
|
+
* something. */
|
|
414
|
+
function liveTarget(c) {
|
|
415
|
+
const target = toolTargetOf(c);
|
|
416
|
+
return escapeTerminal(target === "?" ? c.input : target);
|
|
417
|
+
}
|
|
343
418
|
function toolTargetOf(c) {
|
|
344
419
|
let input = {};
|
|
345
420
|
try {
|
|
@@ -381,7 +456,22 @@ class ToolExecution {
|
|
|
381
456
|
const c = this.cell;
|
|
382
457
|
const verb = escapeTerminal(c.name.replace("_file", ""));
|
|
383
458
|
const verbCol = verb.length < 5 ? `${verb}${" ".repeat(5 - verb.length)}` : verb;
|
|
384
|
-
const
|
|
459
|
+
const parts = c.rolled?.parts;
|
|
460
|
+
if (c.rolled !== null && parts !== undefined) {
|
|
461
|
+
// TUI2-R1 (B) — the exploration row: a run that spans more than
|
|
462
|
+
// one read-only tool. The counts are BOLD (what the reader is
|
|
463
|
+
// being told), the timing and the affordance dim — the
|
|
464
|
+
// prototype's placement. The affordance names what the key
|
|
465
|
+
// SHOWS here ("lists them"), because a group row's expand is a
|
|
466
|
+
// list of calls, not a body of output.
|
|
467
|
+
const r = c.rolled;
|
|
468
|
+
const counts = exploreCounts(parts);
|
|
469
|
+
const head = `${p.bold}✓${p.reset} explored ${p.bold}${counts}${p.reset}`;
|
|
470
|
+
const tail = ` (${r.elapsed}s)`;
|
|
471
|
+
const room = W - visibleWidth(head) - tail.length;
|
|
472
|
+
const affordance = " · ctrl+r lists them";
|
|
473
|
+
return [cutLine(`${head}${p.dim}${tail}${affordance.length <= room ? affordance : ""}${p.reset}`, W)];
|
|
474
|
+
}
|
|
385
475
|
if (c.rolled !== null) {
|
|
386
476
|
// W13 — the rolled-up group's ONE row + the target children:
|
|
387
477
|
// the work order's claimed shape, verbatim — the verbCol's
|
|
@@ -408,45 +498,223 @@ class ToolExecution {
|
|
|
408
498
|
// denial appends `· by <decidedBy>` — the aggregated head row
|
|
409
499
|
// names the decider; a human denial (no decidedBy) has no tail.
|
|
410
500
|
if (c.reason !== null) {
|
|
411
|
-
const by =
|
|
501
|
+
const by = attribution(c);
|
|
412
502
|
const out = gutterCut(`${p.red}✗${p.reset} `, `${p.red}${escapeTerminal(`${c.name} ${toolTargetOf(c)}`)} (${escapeTerminal(c.reason)}${by})${p.reset}`, W);
|
|
413
503
|
out.push(...toolBlockBody(c, W));
|
|
414
504
|
return out;
|
|
415
505
|
}
|
|
416
506
|
const elapsed = c.startedAt !== null && c.doneAt !== null ? ((c.doneAt - c.startedAt) / 1000).toFixed(1) : "?";
|
|
417
|
-
|
|
507
|
+
// TUI2-R1.5 ⑤ (VD-6): the line count is stated EXACTLY ONCE. Every
|
|
508
|
+
// read card carried it twice — `(2 lines, 0.0s) · 2 lines · ctrl+r
|
|
509
|
+
// expands` — because the parens and the suffix were written by
|
|
510
|
+
// different rounds, each unaware the other was counting. The
|
|
511
|
+
// SUFFIX keeps it (it is the one that also names the key), so a
|
|
512
|
+
// meta that says only "<n> lines" drops out when a suffix will
|
|
513
|
+
// carry it. A meta that says something else — read_file's
|
|
514
|
+
// "200 of 250 lines", a diff's "+1 -1", a shell's "exit 0" — is a
|
|
515
|
+
// different fact and stays.
|
|
516
|
+
const rawMeta = settledMeta(c);
|
|
517
|
+
const dup = hiddenLines(c, W) !== null && new RegExp(`^${hiddenLines(c, W)} lines?$`).test(rawMeta);
|
|
518
|
+
const meta = dup ? "" : escapeTerminal(rawMeta);
|
|
418
519
|
// A4: the target rides the settled head row — the verb's
|
|
419
520
|
// summary column (W3's 5-char pad keeps the paths lined up).
|
|
420
521
|
// A5: an extension's auto-approval appends `· approved by
|
|
421
522
|
// <decidedBy>` — the "why wasn't I asked" answer; the human
|
|
422
523
|
// approval (no decidedBy) leaves the row unchanged.
|
|
423
|
-
const approvedBy =
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
524
|
+
const approvedBy = attribution(c);
|
|
525
|
+
// TUI2-R1 (A): the card names its own key — the suffix rides the
|
|
526
|
+
// settled head row.
|
|
527
|
+
// TUI2-R1.5 ④(c): the suffix is now RESERVED rather than given
|
|
528
|
+
// the leftovers. It used to take the width that happened to be
|
|
529
|
+
// left, so a long command spent it all and the row said nothing
|
|
530
|
+
// about the seven lines behind the key — tolerable while the
|
|
531
|
+
// body was on screen, a silence now that the body is not. The
|
|
532
|
+
// command is the cuttable span (the approval panel's option-2
|
|
533
|
+
// rule name is the same idea); the affordance is the semantics.
|
|
534
|
+
const hidden = hiddenLines(c, W);
|
|
535
|
+
// TUI2-R1.5 ⑤: the shortest tier is RESERVED — the affordance is
|
|
536
|
+
// the semantics. TUI2-R1.5 pin 4: and the parts give way in a
|
|
537
|
+
// PINNED ORDER, rather than whichever happened to be last.
|
|
538
|
+
const text = settledHeadText(verbCol, escapeTerminal(toolTargetOf(c)), meta, approvedBy, elapsed, W - 2 - (hidden === null ? 0 : SUFFIX_MIN));
|
|
539
|
+
const out = c.isError ? [`${p.red}✗${p.reset} ${p.red}${text}${p.reset}`] : [`${p.bold}✓${p.reset} ${text}`];
|
|
540
|
+
out[0] = appendSuffix(out[0], expandSuffix(hidden, W - visibleWidth(out[0])));
|
|
427
541
|
out.push(...toolBlockBody(c, W));
|
|
428
542
|
return out;
|
|
429
543
|
}
|
|
430
544
|
if (c.state === "approval") {
|
|
431
545
|
// W2: the ⏸ is the GUTTER (the left edge), never the line's tail
|
|
432
|
-
const out = gutterCut(`${p.bold}⏸${p.reset} `, `${verbCol} ${
|
|
546
|
+
const out = gutterCut(`${p.bold}⏸${p.reset} `, `${verbCol} ${liveTarget(c)}`, W);
|
|
433
547
|
out.push(...toolBlockBody(c, W));
|
|
434
548
|
return out;
|
|
435
549
|
}
|
|
436
550
|
if (c.state === "running") {
|
|
437
551
|
// W2: the spinner IS the gutter (the left edge); the elapsed
|
|
438
|
-
// rides the summary's tail
|
|
552
|
+
// rides the summary's tail.
|
|
553
|
+
// TUI2-R1.5 ④(a) (VD-4): the duration is its OWN trailing segment.
|
|
554
|
+
// It used to be concatenated into the text BEFORE the cut, so a
|
|
555
|
+
// header wider than the row lost it entirely or, worse, kept it
|
|
556
|
+
// welded to the last surviving characters of a cut word
|
|
557
|
+
// ("compil 1s"). The head is cut against the room the duration
|
|
558
|
+
// leaves; the duration then rides the row, always legible.
|
|
439
559
|
const elapsed = c.startedAt !== null ? Math.max(1, Math.round((ctx.now - c.startedAt) / 1000)) : 1;
|
|
440
|
-
const
|
|
560
|
+
const dur = ` · ${elapsed}s`;
|
|
561
|
+
const out = gutterCut(`${p.bold}${SPINNER[ctx.spinnerI % SPINNER.length]}${p.reset} `, `${verbCol} ${liveTarget(c)}`, Math.max(4, W - dur.length));
|
|
562
|
+
out[0] = `${out[0]}${p.dim}${dur}${p.reset}`;
|
|
441
563
|
out.push(...toolBlockBody(c, W));
|
|
442
564
|
return out;
|
|
443
565
|
}
|
|
444
566
|
// W2: ◦ replaces → for QUEUED — · is the separator inside every
|
|
445
567
|
// metadata group; a queued marker that is also the separator
|
|
446
568
|
// glyph reads as noise
|
|
447
|
-
return gutterCut(`${p.dim}◦${p.reset} `, `${verbCol} ${
|
|
569
|
+
return gutterCut(`${p.dim}◦${p.reset} `, `${verbCol} ${liveTarget(c)}`, W);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
// ---- TUI2-R1 (A): the self-naming expand affordance ----
|
|
573
|
+
/**
|
|
574
|
+
* TUI2-R1 (A) — how many lines a COLLAPSED settled cell is hiding, or
|
|
575
|
+
* null when it hides nothing.
|
|
576
|
+
*
|
|
577
|
+
* The affordance is a statement about hidden content: a cell whose body
|
|
578
|
+
* is already whole on screen must not advertise a key that would show it
|
|
579
|
+
* the same thing, and a cell that already carries its own renderer cut
|
|
580
|
+
* (`└ +N earlier rows · ctrl+r`, `└ +N more · ctrl+r`) already teaches
|
|
581
|
+
* the key at the place the content stops. What is LEFT — and it is the
|
|
582
|
+
* common case — is every settled non-shell call, whose collapsed body is
|
|
583
|
+
* empty: the whole result sits behind the key with nothing on screen
|
|
584
|
+
* saying so.
|
|
585
|
+
*
|
|
586
|
+
* The count is the RESULT's own line count (the tool's truncation note
|
|
587
|
+
* included — it is a line the expand will show), never a row count and
|
|
588
|
+
* never a cap.
|
|
589
|
+
*/
|
|
590
|
+
function hiddenLines(c, W) {
|
|
591
|
+
if (c.expanded || c.state !== "done" || c.rolled !== null || c.reason !== null)
|
|
592
|
+
return null;
|
|
593
|
+
if (c.name === "delegate")
|
|
594
|
+
return null; // its body is the one-line summary, always whole
|
|
595
|
+
const n = countLines(c.resultText);
|
|
596
|
+
if (n === 0)
|
|
597
|
+
return null;
|
|
598
|
+
if (c.isError)
|
|
599
|
+
return null; // errorBody's own cut row is the affordance there
|
|
600
|
+
// TUI2-R1.5 ④(c) (VD-5): a settled shell renders NO body, so its whole
|
|
601
|
+
// output is behind the key exactly like every other settled call. The
|
|
602
|
+
// retired branch only claimed a suffix once the output passed the
|
|
603
|
+
// five-row cap, because below that the tail was on screen; there is no
|
|
604
|
+
// tail now, and a card hiding four lines while saying nothing is the
|
|
605
|
+
// silence TUI2-R1 (A) set out to remove.
|
|
606
|
+
return n; // every other settled call renders NO body — all of it is behind the key
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* TUI2-R1 (A) — the suffix, in the width that is LEFT.
|
|
610
|
+
*
|
|
611
|
+
* Three tiers, degrading: the full form teaches the key AND what it
|
|
612
|
+
* does, the terse form keeps the count and the key, the bare form keeps
|
|
613
|
+
* the key alone. Below that the row is left exactly as it is today — the
|
|
614
|
+
* affordance is worth a suffix, never worth cutting the path the row
|
|
615
|
+
* exists to name (invariant ① holds by construction: the tier is chosen
|
|
616
|
+
* against the room the row actually has).
|
|
617
|
+
*/
|
|
618
|
+
/** TUI2-R1.5 ⑤ — the cells a settled head row reserves for its
|
|
619
|
+
* affordance: exactly the shortest tier, " · ctrl+r". The suffix used
|
|
620
|
+
* to take whatever width happened to be left, so a long target spent it
|
|
621
|
+
* all and the card said nothing about the lines behind the key. Every
|
|
622
|
+
* row that fitted its head before still fits it; only a head that would
|
|
623
|
+
* have eaten the whole row gives up its last nine cells. */
|
|
624
|
+
const SUFFIX_MIN = " · ctrl+r".length;
|
|
625
|
+
/**
|
|
626
|
+
* TUI2-R1.5 pin 4 — the settled head row's text, with a PINNED cut
|
|
627
|
+
* order.
|
|
628
|
+
*
|
|
629
|
+
* The row carries four things of very different value, and until now a
|
|
630
|
+
* single trailing widthCut decided between them by position: the parens
|
|
631
|
+
* came last, so the parens were what got cut. The walkthrough caught
|
|
632
|
+
* both consequences —
|
|
633
|
+
*
|
|
634
|
+
* ✓ shell printf '…' 1 2 … 12 (exit 0 · approv… · ctrl+r
|
|
635
|
+
* ✓ shell for i in 1 2 3 … … · ctrl+r
|
|
636
|
+
*
|
|
637
|
+
* — an UNCLOSED parenthesis, and a row that lost the exit code and the
|
|
638
|
+
* duration to a command string that had no claim on them. A cut that
|
|
639
|
+
* leaves `(exit 0 · approv…` has not shortened a fact, it has broken
|
|
640
|
+
* one: the reader is left holding the beginning of a sentence.
|
|
641
|
+
*
|
|
642
|
+
* The order, tightest last:
|
|
643
|
+
* 1. the affordance is already reserved by the caller (⑤);
|
|
644
|
+
* 2. the RESULT CORE — `(exit 0, 3.0s)`, `(+1 -1, 0.2s)` — renders
|
|
645
|
+
* whole, closing paren included, or not at all;
|
|
646
|
+
* 3. the ATTRIBUTION segment drops before the core is touched: it is
|
|
647
|
+
* a note about who decided, and the result is what happened;
|
|
648
|
+
* 4. the COMMAND/target truncates with `…`. It is the most
|
|
649
|
+
* compressible thing on the row — a reader recognises a command
|
|
650
|
+
* from its head — and it is the only part with a natural ellipsis.
|
|
651
|
+
*/
|
|
652
|
+
function settledHeadText(verbCol, target, meta, attr, elapsed, room) {
|
|
653
|
+
const core = `(${meta === "" ? "" : `${meta}, `}${elapsed}s)`;
|
|
654
|
+
const withAttr = `(${[meta, attr.replace(" · ", "")].filter((x) => x !== "").join(" · ")}${meta === "" && attr === "" ? "" : ", "}${elapsed}s)`;
|
|
655
|
+
const lead = `${verbCol} `;
|
|
656
|
+
const fit = (t, parens) => {
|
|
657
|
+
const line = `${lead}${t}${parens === "" ? "" : ` ${parens}`}`;
|
|
658
|
+
return visibleWidth(line) <= room ? line : null;
|
|
659
|
+
};
|
|
660
|
+
// 1. everything
|
|
661
|
+
const full = fit(target, withAttr);
|
|
662
|
+
if (full !== null)
|
|
663
|
+
return full;
|
|
664
|
+
// 2. the attribution gives way
|
|
665
|
+
const bare = fit(target, core);
|
|
666
|
+
if (bare !== null)
|
|
667
|
+
return bare;
|
|
668
|
+
// 3. the target truncates, the core stays whole
|
|
669
|
+
const budget = room - visibleWidth(lead) - visibleWidth(core) - 2; // the space + the ellipsis
|
|
670
|
+
if (budget >= 1)
|
|
671
|
+
return `${lead}${widthCut(target, budget)}… ${core}`;
|
|
672
|
+
// 4. below that even the core cannot ride: the row is the call's
|
|
673
|
+
// identity and its affordance, and no half-open parenthesis.
|
|
674
|
+
return `${lead}${widthCut(target, Math.max(1, room - visibleWidth(lead)))}`;
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
677
|
+
* TUI2-R1.5 ⑤ (VD-11) — approval attribution, about humans.
|
|
678
|
+
*
|
|
679
|
+
* A5 put the DECIDER on the settled head row to answer "why wasn't I
|
|
680
|
+
* asked". The walkthrough found the answer being given nine times in a
|
|
681
|
+
* row as `approved by mode:default` — and `mode:default` is not an
|
|
682
|
+
* answer. It is the runtime's own backfill (run.ts stamps it when no
|
|
683
|
+
* policy expressed an opinion at all), so the row was announcing the
|
|
684
|
+
* ambient default as though something had decided.
|
|
685
|
+
*
|
|
686
|
+
* The signal is inverted and reduced to the fact worth a human's eye:
|
|
687
|
+
* `decidedBy` PRESENT means a policy handled it — ambient, unremarkable,
|
|
688
|
+
* silent. `decidedBy` ABSENT means the human was asked and answered, and
|
|
689
|
+
* that is worth recording on the row: ` · approved`, ` · denied`.
|
|
690
|
+
*/
|
|
691
|
+
function attribution(c) {
|
|
692
|
+
if (c.verdict === null || c.verdict.decidedBy !== undefined)
|
|
693
|
+
return "";
|
|
694
|
+
return c.verdict.decision === "denied" ? " · denied" : " · approved";
|
|
695
|
+
}
|
|
696
|
+
export function expandSuffix(lines, room) {
|
|
697
|
+
if (lines === null)
|
|
698
|
+
return "";
|
|
699
|
+
const count = `${lines} line${lines === 1 ? "" : "s"}`;
|
|
700
|
+
for (const tier of [` · ${count} · ctrl+r expands`, ` · ${count} · ctrl+r`, " · ctrl+r"]) {
|
|
701
|
+
if (tier.length <= room)
|
|
702
|
+
return tier;
|
|
448
703
|
}
|
|
704
|
+
return "";
|
|
449
705
|
}
|
|
706
|
+
/** The suffix as the row's dim tail (the empty suffix leaves the row's
|
|
707
|
+
* bytes untouched — a caller never has to branch). */
|
|
708
|
+
function appendSuffix(row, suffix) {
|
|
709
|
+
if (suffix === "")
|
|
710
|
+
return row;
|
|
711
|
+
const p = palette();
|
|
712
|
+
return `${row}${p.dim}${suffix}${p.reset}`;
|
|
713
|
+
}
|
|
714
|
+
/** TUI2-R1 (A) — the expanded block's last row: the way back. The
|
|
715
|
+
* rollup's expanded list carries a second clause (its members' full
|
|
716
|
+
* outputs live in /last, which the group row cannot show). */
|
|
717
|
+
const COLLAPSE_ROW = "ctrl+r collapses";
|
|
450
718
|
/** W13 — the rollup opt-in table: which tools collapse, and the count
|
|
451
719
|
* NOUN (read_file calls → "5 files", list_dir → "5 dirs", search_text
|
|
452
720
|
* → "5 matches"). Only these tools opt in — a shell burst is never
|
|
@@ -457,6 +725,61 @@ export const ROLLUP_NOUN = {
|
|
|
457
725
|
list_dir: "dirs",
|
|
458
726
|
search_text: "matches",
|
|
459
727
|
};
|
|
728
|
+
// ---- TUI2-R1 (B): the exploration rollup ----
|
|
729
|
+
/** TUI2-R1 (B) — the exploration row's nouns. Deliberately NOT
|
|
730
|
+
* ROLLUP_NOUN: that table says what a SINGLE-tool rollup counts
|
|
731
|
+
* ("5 matches"), and this row counts CALLS across tools, where
|
|
732
|
+
* "14 searches" is what happened. Both tables stay — changing the
|
|
733
|
+
* older one would move an assertion this round did not declare. */
|
|
734
|
+
const EXPLORE_NOUN = {
|
|
735
|
+
read_file: ["file", "files"],
|
|
736
|
+
list_dir: ["dir", "dirs"],
|
|
737
|
+
search_text: ["search", "searches"],
|
|
738
|
+
};
|
|
739
|
+
/** TUI2-R1 (B) — the verb column of the expanded list. `search_text`
|
|
740
|
+
* reads as "search" there: the column names the ACT, and the raw tool
|
|
741
|
+
* name is what the cut notes carry (they name what the model calls). */
|
|
742
|
+
const EXPLORE_VERB = { read_file: "read", list_dir: "list", search_text: "search" };
|
|
743
|
+
/** Whether a tool joins an exploration run. Exactly the read-only set —
|
|
744
|
+
* writes, edits, shells and extension tools never group (a burst of
|
|
745
|
+
* side effects is a list of things that HAPPENED, and every row of it
|
|
746
|
+
* carries meaning). */
|
|
747
|
+
export function isExploreTool(name) {
|
|
748
|
+
return EXPLORE_NOUN[name] !== undefined;
|
|
749
|
+
}
|
|
750
|
+
/** "8 files · 14 searches" — the per-tool counts in first-call order. */
|
|
751
|
+
export function exploreCounts(parts) {
|
|
752
|
+
return parts
|
|
753
|
+
.map((part) => {
|
|
754
|
+
const [singular, plural] = EXPLORE_NOUN[part.name] ?? ["call", "calls"];
|
|
755
|
+
return `${part.subjects.length} ${part.subjects.length === 1 ? singular : plural}`;
|
|
756
|
+
})
|
|
757
|
+
.join(" · ");
|
|
758
|
+
}
|
|
759
|
+
/** TUI2-R1 (B) — the expanded list: ONE row per tool, the verb column
|
|
760
|
+
* then the distinct subjects in first-call order, a repeated subject
|
|
761
|
+
* carrying its ×count, the first three shown and the rest counted.
|
|
762
|
+
* A search's subject is its PATTERN (quoted — the thing that was
|
|
763
|
+
* looked for); a read's or a list's is its path. */
|
|
764
|
+
export function exploreRows(parts, W) {
|
|
765
|
+
const p = palette();
|
|
766
|
+
const rows = [];
|
|
767
|
+
for (const part of parts) {
|
|
768
|
+
const counts = new Map();
|
|
769
|
+
for (const s of part.subjects)
|
|
770
|
+
counts.set(s, (counts.get(s) ?? 0) + 1);
|
|
771
|
+
const shown = [...counts.entries()].slice(0, 3).map(([s, n]) => (n > 1 ? `${s} ×${n}` : s));
|
|
772
|
+
const more = counts.size > 3 ? ` (+${counts.size - 3})` : "";
|
|
773
|
+
const verb = EXPLORE_VERB[part.name] ?? part.name;
|
|
774
|
+
rows.push(cutLine(`${p.dim}${BODY_ROW}${escapeTerminal(`${verb.padEnd(6)} ${shown.join(" · ")}${more}`)}${p.reset}`, W));
|
|
775
|
+
}
|
|
776
|
+
// TUI2-R1.5 ① (VD-15): the footer used to promise "/last shows the full
|
|
777
|
+
// outputs". /last shows the LAST call only — for a nine-call burst that
|
|
778
|
+
// is one output out of nine, and a footer that sends the human to a
|
|
779
|
+
// place the content is not is worse than a footer that says nothing.
|
|
780
|
+
rows.push(cutLine(`${p.dim}${CUT_ROW}${COLLAPSE_ROW}${p.reset}`, W));
|
|
781
|
+
return rows;
|
|
782
|
+
}
|
|
460
783
|
/** The count term with the singular/plural forms — "no reads", "1 read",
|
|
461
784
|
* "5 reads". The noun's singular drops the plural suffix ("dirs" → "dir",
|
|
462
785
|
* "matches" → "match"). */
|
|
@@ -556,19 +879,33 @@ function toolBlockBody(c, W) {
|
|
|
556
879
|
? errorBody(c, W)
|
|
557
880
|
: c.name === "delegate"
|
|
558
881
|
? delegateSettled(c, W)
|
|
559
|
-
:
|
|
560
|
-
|
|
561
|
-
|
|
882
|
+
: // TUI2-R1.5 ④(c) (VD-5): a settled shell collapses like
|
|
883
|
+
// every other settled call. It used to keep its last
|
|
884
|
+
// rows plus a "+N earlier rows · ctrl+r" cut FOREVER —
|
|
885
|
+
// six rows per call, so three shells owned a screen. The
|
|
886
|
+
// approved R1 prototype's state 2 is one line; the head
|
|
887
|
+
// row's own suffix already names the count and the key,
|
|
888
|
+
// and ctrl+r shows the whole block, not a five-row window
|
|
889
|
+
// of it.
|
|
890
|
+
[]
|
|
562
891
|
: c.state === "running"
|
|
563
892
|
? c.name === "delegate"
|
|
564
893
|
? delegateRunning(c, W)
|
|
565
|
-
:
|
|
894
|
+
: c.name === "shell"
|
|
895
|
+
? shellLiveTail(c.resultText, W)
|
|
896
|
+
: liveWindow(c.resultText, W)
|
|
566
897
|
: c.state === "approval"
|
|
567
898
|
? diffBody(c.diff, W)
|
|
568
899
|
: [];
|
|
569
900
|
const note = c.expanded ? null : toolCutNote(c.name, c.resultText);
|
|
570
901
|
if (note !== null)
|
|
571
902
|
rows.push(...foldLine(`${p.dim}${CUT_ROW}${note}${p.reset}`, W));
|
|
903
|
+
// TUI2-R1 (A): an EXPANDED block says how to put it back. The footer
|
|
904
|
+
// rides a block that HAS rows — an expanded delegate whose summary
|
|
905
|
+
// marker is missing renders nothing, and a lone footer under a head
|
|
906
|
+
// row would be an affordance for an empty block.
|
|
907
|
+
if (c.expanded && rows.length > 0)
|
|
908
|
+
rows.push(...foldLine(`${p.dim}${CUT_ROW}${COLLAPSE_ROW}${p.reset}`, W));
|
|
572
909
|
blockMemo.set(c, { width: W, state, content, rows });
|
|
573
910
|
return rows;
|
|
574
911
|
}
|
|
@@ -639,6 +976,49 @@ function liveWindow(text, W) {
|
|
|
639
976
|
const cut = foldLine(`${p.dim}${CUT_ROW}+${rows.length - (CAP_LIVE_WINDOW - 1)} earlier rows · ctrl+r${p.reset}`, W);
|
|
640
977
|
return [...rows.slice(rows.length - (CAP_LIVE_WINDOW - 1)), ...cut];
|
|
641
978
|
}
|
|
979
|
+
/**
|
|
980
|
+
* TUI2-R1 (C) — the RUNNING shell's live tail.
|
|
981
|
+
*
|
|
982
|
+
* The rows are the sidecar's last lines, NEWEST AT THE BOTTOM (a tail
|
|
983
|
+
* grows downward, and the row nearest the footer is the newest thing the
|
|
984
|
+
* command said). The window is the SAME three rows W8 fixed: two tail
|
|
985
|
+
* rows and the footer, blank-padded before the output fills them, so the
|
|
986
|
+
* block's height still changes exactly once — at settle.
|
|
987
|
+
*
|
|
988
|
+
* With nothing observed the shape is exactly today's "waiting for
|
|
989
|
+
* output": a sidecar that never appeared, a command that has not
|
|
990
|
+
* printed, and a temp dir that refused the write are indistinguishable
|
|
991
|
+
* from here, and all three mean the same thing — nothing to show.
|
|
992
|
+
*
|
|
993
|
+
* The footer names the state AND the two gestures that apply while a
|
|
994
|
+
* command runs, because this is precisely when a human wants them.
|
|
995
|
+
*/
|
|
996
|
+
function shellLiveTail(text, W) {
|
|
997
|
+
if (text === "")
|
|
998
|
+
return liveWindow("", W);
|
|
999
|
+
const p = palette();
|
|
1000
|
+
// TUI2-R1.5 ④(b) (VD-4): the tail's first row is never a blank gutter.
|
|
1001
|
+
// Two sources, both fixed here, and the W8 fixed-window height is kept
|
|
1002
|
+
// by both fixes:
|
|
1003
|
+
// - leading empty lines in the sidecar (a 4096-byte tail can begin on
|
|
1004
|
+
// a line boundary, and the reader's .trimEnd only trims the other
|
|
1005
|
+
// end) are skipped;
|
|
1006
|
+
// - the short-output pad moved from the TOP to the BOTTOM. It exists
|
|
1007
|
+
// so the block's height never changes while the command runs (W8);
|
|
1008
|
+
// at the top it put an empty row above the command's very first
|
|
1009
|
+
// line, which is the frame the walkthrough filed. At the bottom the
|
|
1010
|
+
// output starts under its own header and grows downward, and the
|
|
1011
|
+
// height is just as fixed.
|
|
1012
|
+
const all = blockRows(text, W);
|
|
1013
|
+
const from = all.findIndex((r) => visibleWidth(r) > visibleWidth(BODY_ROW));
|
|
1014
|
+
const rows = from < 0 ? [] : all.slice(from);
|
|
1015
|
+
if (rows.length === 0)
|
|
1016
|
+
return liveWindow("", W);
|
|
1017
|
+
const kept = rows.slice(Math.max(0, rows.length - (CAP_LIVE_WINDOW - 1)));
|
|
1018
|
+
while (kept.length < CAP_LIVE_WINDOW - 1)
|
|
1019
|
+
kept.push(`${p.dim}${BODY_ROW}${p.reset}`);
|
|
1020
|
+
return [...kept, cutLine(`${p.dim}${CUT_ROW}live tail · esc stop · alt+⏎ redirect${p.reset}`, W)];
|
|
1021
|
+
}
|
|
642
1022
|
/** W12: the delegate's child sessions collapse to the tool row plus ONE
|
|
643
1023
|
* line — the height NEVER changes (running → settled replaces the row
|
|
644
1024
|
* in place). The running row derives from the INPUT: the parent has no
|
|
@@ -748,8 +1128,10 @@ class AssistantMessage {
|
|
|
748
1128
|
this.cell = cell;
|
|
749
1129
|
}
|
|
750
1130
|
render(W, _ctx) {
|
|
1131
|
+
// TUI2-R1.5 9 (VD-10): the model's prose is the clearest case of
|
|
1132
|
+
// text a human reads — it wraps at word boundaries.
|
|
751
1133
|
const text = escapeTerminal(this.cell.text);
|
|
752
|
-
const wrapped =
|
|
1134
|
+
const wrapped = foldWords(text, W);
|
|
753
1135
|
return wrapped.length > 0 ? wrapped.map((l) => colorInlineCode(l)) : [""];
|
|
754
1136
|
}
|
|
755
1137
|
}
|
|
@@ -760,7 +1142,8 @@ class ErrorLine {
|
|
|
760
1142
|
this.cell = cell;
|
|
761
1143
|
}
|
|
762
1144
|
render(W, _ctx) {
|
|
763
|
-
|
|
1145
|
+
// TUI2-R1.5 9 (VD-10): a notice is a sentence addressed to a human.
|
|
1146
|
+
return foldWords(escapeTerminal(this.cell.text), W);
|
|
764
1147
|
}
|
|
765
1148
|
}
|
|
766
1149
|
/** The CLI's pre-rendered blocks (the banner, the recap, slash-command
|
|
@@ -773,7 +1156,12 @@ class RawBlock {
|
|
|
773
1156
|
this.cell = cell;
|
|
774
1157
|
}
|
|
775
1158
|
render(W, _ctx) {
|
|
776
|
-
|
|
1159
|
+
// TUI2-R1.5 9 (VD-10): the raw channel carries BOTH kinds of text —
|
|
1160
|
+
// /help's sentences and /last's verbatim tool output — so the
|
|
1161
|
+
// CALLER says which it is. Verbatim is the default: a surface that
|
|
1162
|
+
// has not thought about it must not have its bytes reflowed.
|
|
1163
|
+
const fold = this.cell.wrap === "words" ? foldWords : foldLine;
|
|
1164
|
+
return this.cell.lines.flatMap((l) => fold(l, W));
|
|
777
1165
|
}
|
|
778
1166
|
}
|
|
779
1167
|
/** The terminal label + the status line. W11: the rhythm gap blank is
|
package/dist/diff.d.ts
CHANGED
|
@@ -20,13 +20,33 @@ export interface DiffResult {
|
|
|
20
20
|
lines: DiffLine[];
|
|
21
21
|
added: number;
|
|
22
22
|
removed: number;
|
|
23
|
+
/** TUI2-R1.5 ② (VD-2): the search is not in the file — the tool will
|
|
24
|
+
* ERROR, so the panel shows the honest note carried in `lines` and
|
|
25
|
+
* never a diff. Absent on every real diff. */
|
|
26
|
+
notFound?: true;
|
|
23
27
|
}
|
|
24
28
|
/** The RENDERER's truncation: head + "… N lines (/last for full)" + tail. */
|
|
25
29
|
export declare function truncateDiff(diff: DiffLine[]): DiffLine[];
|
|
26
|
-
/** edit_file: the
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
|
|
30
|
+
/** edit_file: the preview of a CHARACTER splice.
|
|
31
|
+
*
|
|
32
|
+
* TUI2-R1.5 ② (VD-2): the locator is the tool's own, verbatim — the
|
|
33
|
+
* workspace edit_file does `i = text.indexOf(search)` and writes
|
|
34
|
+
* `text.slice(0, i) + replace + text.slice(i + search.length)`. This
|
|
35
|
+
* function mirrors those two lines and diffs the result against the
|
|
36
|
+
* original; it does not model the edit, it reproduces it.
|
|
37
|
+
*
|
|
38
|
+
* The retired locator required the search to align to FULL LINES. A
|
|
39
|
+
* mid-line search ("// OLD" inside " // OLD") therefore missed, and
|
|
40
|
+
* the miss branch rendered the WHOLE FILE as the old side: a one-line
|
|
41
|
+
* edit was drawn as a catastrophic rewrite, on the approval panel, at
|
|
42
|
+
* the moment a human was deciding whether to allow it. A preview that
|
|
43
|
+
* can be that wrong is worse than no preview.
|
|
44
|
+
*
|
|
45
|
+
* A genuine miss is now reported as a miss: the tool will return
|
|
46
|
+
* `pattern not found in <path>` and change nothing, so the panel says
|
|
47
|
+
* exactly that instead of inventing a diff for an edit that will not
|
|
48
|
+
* happen. `path` names the file in that note. */
|
|
49
|
+
export declare function editFileDiff(oldContent: string, search: string, replace: string, path?: string): DiffResult;
|
|
30
50
|
/** write_file: a new file is all +; an existing file diffs row-level
|
|
31
51
|
* against its old content. */
|
|
32
52
|
export declare function writeFileDiff(oldContent: string | null, newContent: string): DiffResult;
|
package/dist/diff.js
CHANGED
|
@@ -91,23 +91,37 @@ function stats(diff) {
|
|
|
91
91
|
}
|
|
92
92
|
return { added, removed };
|
|
93
93
|
}
|
|
94
|
-
/** edit_file: the
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
94
|
+
/** edit_file: the preview of a CHARACTER splice.
|
|
95
|
+
*
|
|
96
|
+
* TUI2-R1.5 ② (VD-2): the locator is the tool's own, verbatim — the
|
|
97
|
+
* workspace edit_file does `i = text.indexOf(search)` and writes
|
|
98
|
+
* `text.slice(0, i) + replace + text.slice(i + search.length)`. This
|
|
99
|
+
* function mirrors those two lines and diffs the result against the
|
|
100
|
+
* original; it does not model the edit, it reproduces it.
|
|
101
|
+
*
|
|
102
|
+
* The retired locator required the search to align to FULL LINES. A
|
|
103
|
+
* mid-line search ("// OLD" inside " // OLD") therefore missed, and
|
|
104
|
+
* the miss branch rendered the WHOLE FILE as the old side: a one-line
|
|
105
|
+
* edit was drawn as a catastrophic rewrite, on the approval panel, at
|
|
106
|
+
* the moment a human was deciding whether to allow it. A preview that
|
|
107
|
+
* can be that wrong is worse than no preview.
|
|
108
|
+
*
|
|
109
|
+
* A genuine miss is now reported as a miss: the tool will return
|
|
110
|
+
* `pattern not found in <path>` and change nothing, so the panel says
|
|
111
|
+
* exactly that instead of inventing a diff for an edit that will not
|
|
112
|
+
* happen. `path` names the file in that note. */
|
|
113
|
+
export function editFileDiff(oldContent, search, replace, path) {
|
|
114
|
+
const at = oldContent.indexOf(search);
|
|
115
|
+
if (at < 0) {
|
|
116
|
+
return {
|
|
117
|
+
lines: [{ kind: " ", text: `pattern not found in ${path ?? "the file"}` }],
|
|
118
|
+
added: 0,
|
|
119
|
+
removed: 0,
|
|
120
|
+
notFound: true,
|
|
121
|
+
};
|
|
109
122
|
}
|
|
110
|
-
const
|
|
123
|
+
const result = oldContent.slice(0, at) + replace + oldContent.slice(at + search.length);
|
|
124
|
+
const lines = withContext(lcsDiff(oldContent.split("\n"), result.split("\n")));
|
|
111
125
|
return { lines, ...stats(lines) };
|
|
112
126
|
}
|
|
113
127
|
/** write_file: a new file is all +; an existing file diffs row-level
|
package/dist/index.d.ts
CHANGED
|
@@ -6,10 +6,11 @@
|
|
|
6
6
|
* package); the cli never imports it directly. Experimental — no
|
|
7
7
|
* API-stability promise yet.
|
|
8
8
|
*/
|
|
9
|
-
export { SPINNER, foldLine, visibleWidth, bodySpacing, Container, cellComponent, ROLLUP_NOUN, turnFold, CAP_TASK_LIVE, formatDuration, statusLine, boxTop, boxBottom, terminalPipe, type FrameCtx, type RenderLine, type Component, type BodyCell, } from "./components.js";
|
|
9
|
+
export { SPINNER, foldLine, foldWords, visibleWidth, bodySpacing, Container, cellComponent, ROLLUP_NOUN, turnFold, CAP_TASK_LIVE, formatDuration, statusLine, boxTop, boxBottom, terminalPipe, type FrameCtx, type RenderLine, type Component, type BodyCell, } from "./components.js";
|
|
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, } from "./approval-panel.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";
|
|
14
14
|
export { interactivePrompt, projectTrustRows, projectTrustView, projectUntrustedNote, uncertainView, type TrustArtifact, } from "./strings.js";
|
|
15
|
+
export { extensionsBannerText, helpRows, unansweredAskView, type BannerExtension } from "./strings.js";
|
|
15
16
|
export { bannerLines, COLOR_OFF, COLOR_ON, colorInlineCode, escapeTerminal, foldResult, foldThinking, kUnit, palette, relativeTime, renderResumeList, renderTerminalGap, renderToolSummary, TAGLINE, toolTarget, truncateRow, type Palette, type ResumeMeta, } from "./render.js";
|
package/dist/index.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* package); the cli never imports it directly. Experimental — no
|
|
7
7
|
* API-stability promise yet.
|
|
8
8
|
*/
|
|
9
|
-
export { SPINNER, foldLine, visibleWidth, bodySpacing, Container, cellComponent, ROLLUP_NOUN, turnFold, CAP_TASK_LIVE, formatDuration, statusLine, boxTop, boxBottom, terminalPipe, } from "./components.js";
|
|
9
|
+
export { SPINNER, foldLine, foldWords, visibleWidth, bodySpacing, Container, cellComponent, ROLLUP_NOUN, turnFold, CAP_TASK_LIVE, formatDuration, statusLine, boxTop, boxBottom, terminalPipe, } from "./components.js";
|
|
10
10
|
export { editFileDiff, truncateDiff, writeFileDiff } from "./diff.js";
|
|
11
11
|
// W22 (the v8 input round): the pending-queue chips — the SAME
|
|
12
12
|
// UserMessage chip with the □ gutter, pre-rendered above the input
|
|
@@ -23,4 +23,6 @@ export { panelAffordance, panelBlockRows, panelLead, panelLeadPlain, panelLeadWi
|
|
|
23
23
|
// listing/view/note, the uncertain execution's view. The flow stays in
|
|
24
24
|
// the cli; what the human reads is presentation.
|
|
25
25
|
export { interactivePrompt, projectTrustRows, projectTrustView, projectUntrustedNote, uncertainView, } from "./strings.js";
|
|
26
|
+
// KC3.5: the interrupted-ask copy and the extracted /help table.
|
|
27
|
+
export { extensionsBannerText, helpRows, unansweredAskView } from "./strings.js";
|
|
26
28
|
export { bannerLines, COLOR_OFF, COLOR_ON, colorInlineCode, escapeTerminal, foldResult, foldThinking, kUnit, palette, relativeTime, renderResumeList, renderTerminalGap, renderToolSummary, TAGLINE, toolTarget, truncateRow, } from "./render.js";
|
package/dist/strings.d.ts
CHANGED
|
@@ -58,3 +58,103 @@ export declare function projectUntrustedNote(count: number, root: string): strin
|
|
|
58
58
|
* question because it reaches the terminal as raw text there; the
|
|
59
59
|
* panel's own rows are escaped by the panel renderer. */
|
|
60
60
|
export declare function uncertainView(name: string, executionId: string): PanelView;
|
|
61
|
+
/**
|
|
62
|
+
* KC3.5 — the SAME uncertainty gate, said honestly for an ask_user call.
|
|
63
|
+
*
|
|
64
|
+
* "Did the interrupted execution apply?" is the right question for a
|
|
65
|
+
* side effect and the wrong one for a question: nothing applied, the
|
|
66
|
+
* human simply never answered. The COPY special-cases ask_user; the
|
|
67
|
+
* mechanism does not — the verdict still maps to the runtime's own
|
|
68
|
+
* rerun/abandoned resolution, whose error-fill text is untouched.
|
|
69
|
+
*
|
|
70
|
+
* (The round's ① probe pinned why this surface exists at all: the
|
|
71
|
+
* shipped recovery blocks on a started-unreported execution regardless
|
|
72
|
+
* 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.)
|
|
74
|
+
*/
|
|
75
|
+
export declare function unansweredAskView(executionId: string): PanelView;
|
|
76
|
+
/** An extension as the banner names it — the live `connecting` flag is
|
|
77
|
+
* the MCP bridge's in-flight state ("mcp (connecting…)"). Structural on
|
|
78
|
+
* purpose: the runtime's KisoExtension satisfies it without this
|
|
79
|
+
* package importing the runtime. */
|
|
80
|
+
export interface BannerExtension {
|
|
81
|
+
readonly name: string;
|
|
82
|
+
readonly connecting?: boolean;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* KC3.5 slice ⓪ (the extraction) — the `[N extensions: …]` banner text:
|
|
86
|
+
* the built-in column, then the user-level names, then the project-level
|
|
87
|
+
* ones marked `project:`.
|
|
88
|
+
*
|
|
89
|
+
* A pure function of three name lists, so what the banner SAYS is
|
|
90
|
+
* testable without a terminal — which matters this round, because the
|
|
91
|
+
* count is where the ask's TTY gate becomes visible: an interactive
|
|
92
|
+
* session reads `built-in: mcp, skills, subagent, ask` and a piped one
|
|
93
|
+
* reads `built-in: mcp, skills, subagent`, from this one composition.
|
|
94
|
+
*/
|
|
95
|
+
export declare function extensionsBannerText(builtIn: readonly BannerExtension[], user: readonly BannerExtension[], project: readonly BannerExtension[]): string;
|
|
96
|
+
/** One gesture: what you press, and what it does. */
|
|
97
|
+
export interface KeyBinding {
|
|
98
|
+
readonly keys: string;
|
|
99
|
+
readonly what: string;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* TUI2-R1 (D) — THE key table. Every reader derives from it: the `?`
|
|
103
|
+
* sheet and /help's keys row. A sheet that has drifted from the keys is
|
|
104
|
+
* worse than no sheet, and the only way to make drift impossible is to
|
|
105
|
+
* have one table and no second copy of it.
|
|
106
|
+
*
|
|
107
|
+
* The order is the sheet's reading order, which is why it is grouped by
|
|
108
|
+
* WHAT A HUMAN IS DOING rather than alphabetically: the three ways to
|
|
109
|
+
* put something in (send, newline, files), the three ways to change
|
|
110
|
+
* course (stop, redirect, commands), then the walks (history, expand),
|
|
111
|
+
* then the completions.
|
|
112
|
+
*/
|
|
113
|
+
export declare const KEY_BINDINGS: readonly KeyBinding[];
|
|
114
|
+
/** The panel keys, which belong to a panel rather than the composer —
|
|
115
|
+
* one dim line rather than four table rows, because they apply only
|
|
116
|
+
* while a panel is up. */
|
|
117
|
+
/**
|
|
118
|
+
* TUI2-R1.5 pin 6 — this row has to be true of BOTH panel flavors, and
|
|
119
|
+
* "digits select" was wrong in both directions at once.
|
|
120
|
+
*
|
|
121
|
+
* On an APPROVAL a digit only moves the selection (editor's #panelSelect
|
|
122
|
+
* sets `sel` and renders); ENTER is what resolves it. A reader who
|
|
123
|
+
* pressed 1 and walked away had approved nothing — the worst kind of
|
|
124
|
+
* false affordance, on the surface where the stakes are a side effect.
|
|
125
|
+
*
|
|
126
|
+
* On an ASK the opposite: a digit on a SINGLE-choice question answers it
|
|
127
|
+
* and advances the walk (ask-panel's askKey → advance), so "select"
|
|
128
|
+
* undersold it. A multi-select question toggles and waits for enter,
|
|
129
|
+
* like the approval.
|
|
130
|
+
*
|
|
131
|
+
* "digits pick · ⏎ confirms" is the sentence both flavors satisfy. The
|
|
132
|
+
* single-choice fast path — where the confirm is implicit — is the one
|
|
133
|
+
* thing a single row cannot also carry; it is an omission, never a lie.
|
|
134
|
+
*/
|
|
135
|
+
export declare const PANEL_KEYS_ROW = "panels: digits pick \u00B7 \u23CE confirms \u00B7 space toggles \u00B7 t types an answer";
|
|
136
|
+
/**
|
|
137
|
+
* TUI2-R1 (D) — the sheet, one screen, static.
|
|
138
|
+
*
|
|
139
|
+
* Rows CUT at the width rather than folding: the sheet's contract with
|
|
140
|
+
* the reader is "one screen", and a folded grid at 40 columns is two
|
|
141
|
+
* screens pretending to be one. A narrow terminal shows fewer columns
|
|
142
|
+
* of the same truth, which is the honest degradation.
|
|
143
|
+
*/
|
|
144
|
+
export declare function keysSheetRows(W: number): string[];
|
|
145
|
+
/** TUI2-R1 (D) — the keys as ONE line, for /help. The same table the
|
|
146
|
+
* sheet renders, joined — so the two can disagree only by deleting a
|
|
147
|
+
* test. The sheet is the readable form; this is the greppable one. */
|
|
148
|
+
export declare function keysHelpRow(): string;
|
|
149
|
+
/**
|
|
150
|
+
* KC3.5 slice ⓪ (the extraction) — the /help command table.
|
|
151
|
+
*
|
|
152
|
+
* The rows were eight bodyLog calls in the CLI's dispatcher; they are
|
|
153
|
+
* presentation, and presentation belongs here (the KC3 §1 pattern —
|
|
154
|
+
* the FLOW, which is "print these on the chain, then re-prompt", stays
|
|
155
|
+
* in dispatch.ts). The last row carries its own newline exactly as it
|
|
156
|
+
* did inline: bodyLog splits on \n, so `exit` and `keys` land as two
|
|
157
|
+
* rows from one call — the shape the KC1/KC2/KC3 gestures were added
|
|
158
|
+
* to, unchanged.
|
|
159
|
+
*/
|
|
160
|
+
export declare function helpRows(): string[];
|
package/dist/strings.js
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* looked up here.
|
|
19
19
|
*/
|
|
20
20
|
import { escapeTerminal, palette } from "./render.js";
|
|
21
|
+
import { displayWidth } from "./width.js";
|
|
21
22
|
/** v2a: the interactive prompt — the identity accent. readline owns the
|
|
22
23
|
* echo of what the user types; we own the prompt's color. (v2c: the
|
|
23
24
|
* readline prompt keeps "you> " — the brick ▌ is the dock's row only;
|
|
@@ -79,3 +80,215 @@ export function uncertainView(name, executionId) {
|
|
|
79
80
|
fallbackQuestion: `⚠ interrupted execution: ${escapeTerminal(name)} (${executionId}) — did it apply? (y)es / (n)o `,
|
|
80
81
|
};
|
|
81
82
|
}
|
|
83
|
+
/**
|
|
84
|
+
* KC3.5 — the SAME uncertainty gate, said honestly for an ask_user call.
|
|
85
|
+
*
|
|
86
|
+
* "Did the interrupted execution apply?" is the right question for a
|
|
87
|
+
* side effect and the wrong one for a question: nothing applied, the
|
|
88
|
+
* human simply never answered. The COPY special-cases ask_user; the
|
|
89
|
+
* mechanism does not — the verdict still maps to the runtime's own
|
|
90
|
+
* rerun/abandoned resolution, whose error-fill text is untouched.
|
|
91
|
+
*
|
|
92
|
+
* (The round's ① probe pinned why this surface exists at all: the
|
|
93
|
+
* shipped recovery blocks on a started-unreported execution regardless
|
|
94
|
+
* 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.)
|
|
96
|
+
*/
|
|
97
|
+
export function unansweredAskView(executionId) {
|
|
98
|
+
return {
|
|
99
|
+
flavor: "simple",
|
|
100
|
+
name: "unanswered question",
|
|
101
|
+
title: `ask_user (${executionId})`,
|
|
102
|
+
speaker: "kiso",
|
|
103
|
+
statusText: "▸ unanswered question",
|
|
104
|
+
args: { kind: "text", lines: [executionId] },
|
|
105
|
+
ruleOverride: "an unanswered question was interrupted — ask it again? — 1 re-ask · 3 drop",
|
|
106
|
+
fallbackQuestion: `⚠ an unanswered question was interrupted (${executionId}) — ask it again? (y)es / (n)o `,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* KC3.5 slice ⓪ (the extraction) — the `[N extensions: …]` banner text:
|
|
111
|
+
* the built-in column, then the user-level names, then the project-level
|
|
112
|
+
* ones marked `project:`.
|
|
113
|
+
*
|
|
114
|
+
* A pure function of three name lists, so what the banner SAYS is
|
|
115
|
+
* testable without a terminal — which matters this round, because the
|
|
116
|
+
* count is where the ask's TTY gate becomes visible: an interactive
|
|
117
|
+
* session reads `built-in: mcp, skills, subagent, ask` and a piped one
|
|
118
|
+
* reads `built-in: mcp, skills, subagent`, from this one composition.
|
|
119
|
+
*/
|
|
120
|
+
export function extensionsBannerText(builtIn, user, project) {
|
|
121
|
+
const total = builtIn.length + user.length + project.length;
|
|
122
|
+
if (total === 0)
|
|
123
|
+
return "";
|
|
124
|
+
const label = (e) => (e.connecting === true ? `${e.name} (connecting…)` : e.name);
|
|
125
|
+
const parts = [];
|
|
126
|
+
if (builtIn.length > 0)
|
|
127
|
+
parts.push(`built-in: ${builtIn.map(label).join(", ")}`);
|
|
128
|
+
if (user.length > 0)
|
|
129
|
+
parts.push(user.map(label).join(", "));
|
|
130
|
+
if (project.length > 0)
|
|
131
|
+
parts.push(`project: ${project.map(label).join(", ")}`);
|
|
132
|
+
return ` · [${total} extension${total === 1 ? "" : "s"}: ${parts.join(" · ")}]`;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* TUI2-R1 (D) — THE key table. Every reader derives from it: the `?`
|
|
136
|
+
* sheet and /help's keys row. A sheet that has drifted from the keys is
|
|
137
|
+
* worse than no sheet, and the only way to make drift impossible is to
|
|
138
|
+
* have one table and no second copy of it.
|
|
139
|
+
*
|
|
140
|
+
* The order is the sheet's reading order, which is why it is grouped by
|
|
141
|
+
* WHAT A HUMAN IS DOING rather than alphabetically: the three ways to
|
|
142
|
+
* put something in (send, newline, files), the three ways to change
|
|
143
|
+
* course (stop, redirect, commands), then the walks (history, expand),
|
|
144
|
+
* then the completions.
|
|
145
|
+
*/
|
|
146
|
+
export const KEY_BINDINGS = [
|
|
147
|
+
{ keys: "enter", what: "send" },
|
|
148
|
+
{ keys: "ctrl+j / shift+⏎", what: "newline" },
|
|
149
|
+
{ keys: "@", what: "files" },
|
|
150
|
+
{ keys: "esc", what: "stop" },
|
|
151
|
+
{ keys: "alt+⏎ / ctrl+⏎", what: "redirect" },
|
|
152
|
+
{ keys: "/", what: "commands" },
|
|
153
|
+
{ keys: "↑↓", what: "history / queue pop" },
|
|
154
|
+
{ keys: "ctrl+r", what: "expand cells" },
|
|
155
|
+
{ keys: "tab", what: "complete (menu / @)" },
|
|
156
|
+
{ keys: "?", what: "this sheet" },
|
|
157
|
+
];
|
|
158
|
+
/** The panel keys, which belong to a panel rather than the composer —
|
|
159
|
+
* one dim line rather than four table rows, because they apply only
|
|
160
|
+
* while a panel is up. */
|
|
161
|
+
/**
|
|
162
|
+
* TUI2-R1.5 pin 6 — this row has to be true of BOTH panel flavors, and
|
|
163
|
+
* "digits select" was wrong in both directions at once.
|
|
164
|
+
*
|
|
165
|
+
* On an APPROVAL a digit only moves the selection (editor's #panelSelect
|
|
166
|
+
* sets `sel` and renders); ENTER is what resolves it. A reader who
|
|
167
|
+
* pressed 1 and walked away had approved nothing — the worst kind of
|
|
168
|
+
* false affordance, on the surface where the stakes are a side effect.
|
|
169
|
+
*
|
|
170
|
+
* On an ASK the opposite: a digit on a SINGLE-choice question answers it
|
|
171
|
+
* and advances the walk (ask-panel's askKey → advance), so "select"
|
|
172
|
+
* undersold it. A multi-select question toggles and waits for enter,
|
|
173
|
+
* like the approval.
|
|
174
|
+
*
|
|
175
|
+
* "digits pick · ⏎ confirms" is the sentence both flavors satisfy. The
|
|
176
|
+
* single-choice fast path — where the confirm is implicit — is the one
|
|
177
|
+
* thing a single row cannot also carry; it is an omission, never a lie.
|
|
178
|
+
*/
|
|
179
|
+
export const PANEL_KEYS_ROW = "panels: digits pick · ⏎ confirms · space toggles · t types an answer";
|
|
180
|
+
/** The sheet's grid: the first six bindings in two 3-column rows, the
|
|
181
|
+
* last four in two 2-column rows (the wide entries get the room). The
|
|
182
|
+
* COLUMN STOPS are the prototype's absolute positions, floored by the
|
|
183
|
+
* content (a future binding widens its column rather than overrunning
|
|
184
|
+
* it — the grid degrades to one space, never to a collision). */
|
|
185
|
+
const SHEET_GRID = [
|
|
186
|
+
[0, 1, 2],
|
|
187
|
+
[3, 4, 5],
|
|
188
|
+
[6, 7],
|
|
189
|
+
[8, 9],
|
|
190
|
+
];
|
|
191
|
+
const SHEET_STOPS = [
|
|
192
|
+
[16, 43],
|
|
193
|
+
[16, 43],
|
|
194
|
+
[36],
|
|
195
|
+
[36],
|
|
196
|
+
];
|
|
197
|
+
/**
|
|
198
|
+
* TUI2-R1 (D) — the sheet, one screen, static.
|
|
199
|
+
*
|
|
200
|
+
* Rows CUT at the width rather than folding: the sheet's contract with
|
|
201
|
+
* the reader is "one screen", and a folded grid at 40 columns is two
|
|
202
|
+
* screens pretending to be one. A narrow terminal shows fewer columns
|
|
203
|
+
* of the same truth, which is the honest degradation.
|
|
204
|
+
*/
|
|
205
|
+
export function keysSheetRows(W) {
|
|
206
|
+
const p = palette();
|
|
207
|
+
const cell = (i) => {
|
|
208
|
+
const b = KEY_BINDINGS[i];
|
|
209
|
+
return b === undefined ? "" : `${p.code}${b.keys}${p.reset} ${b.what}`;
|
|
210
|
+
};
|
|
211
|
+
const plainCell = (i) => {
|
|
212
|
+
const b = KEY_BINDINGS[i];
|
|
213
|
+
return b === undefined ? "" : `${b.keys} ${b.what}`;
|
|
214
|
+
};
|
|
215
|
+
const rows = [`${p.bold}keys${p.reset}`];
|
|
216
|
+
for (let r = 0; r < SHEET_GRID.length; r += 1) {
|
|
217
|
+
const indexes = SHEET_GRID[r];
|
|
218
|
+
let row = "";
|
|
219
|
+
let width = 0;
|
|
220
|
+
for (let c = 0; c < indexes.length; c += 1) {
|
|
221
|
+
row += cell(indexes[c]);
|
|
222
|
+
width += displayWidth(plainCell(indexes[c]));
|
|
223
|
+
const stop = SHEET_STOPS[r][c];
|
|
224
|
+
if (stop === undefined)
|
|
225
|
+
continue; // the last column pads nothing
|
|
226
|
+
const pad = Math.max(1, stop - width);
|
|
227
|
+
row += " ".repeat(pad);
|
|
228
|
+
width += pad;
|
|
229
|
+
}
|
|
230
|
+
rows.push(row);
|
|
231
|
+
}
|
|
232
|
+
rows.push(`${p.dim}${PANEL_KEYS_ROW}${p.reset}`);
|
|
233
|
+
return rows.map((row) => cutRow(row, W));
|
|
234
|
+
}
|
|
235
|
+
/** One row, cut at the width — SGR-aware, the ellipsis after the reset
|
|
236
|
+
* (the cutLine convention; duplicated here rather than imported so the
|
|
237
|
+
* strings module keeps its no-components-dependency shape). */
|
|
238
|
+
function cutRow(row, W) {
|
|
239
|
+
let out = "";
|
|
240
|
+
let width = 0;
|
|
241
|
+
for (let i = 0; i < row.length;) {
|
|
242
|
+
if (row[i] === "\x1b") {
|
|
243
|
+
const m = /^\x1b\[[0-9;]*m/.exec(row.slice(i))?.[0] ?? row[i];
|
|
244
|
+
out += m;
|
|
245
|
+
i += m.length;
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
const cw = displayWidth(row[i]);
|
|
249
|
+
if (width + cw > W)
|
|
250
|
+
return `${out}${palette().reset}`;
|
|
251
|
+
out += row[i];
|
|
252
|
+
width += cw;
|
|
253
|
+
i += 1;
|
|
254
|
+
}
|
|
255
|
+
return out;
|
|
256
|
+
}
|
|
257
|
+
/** TUI2-R1 (D) — the keys as ONE line, for /help. The same table the
|
|
258
|
+
* sheet renders, joined — so the two can disagree only by deleting a
|
|
259
|
+
* test. The sheet is the readable form; this is the greppable one. */
|
|
260
|
+
export function keysHelpRow() {
|
|
261
|
+
return KEY_BINDINGS.map((b) => `${b.keys} ${b.what}`).join(" · ");
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* KC3.5 slice ⓪ (the extraction) — the /help command table.
|
|
265
|
+
*
|
|
266
|
+
* The rows were eight bodyLog calls in the CLI's dispatcher; they are
|
|
267
|
+
* presentation, and presentation belongs here (the KC3 §1 pattern —
|
|
268
|
+
* the FLOW, which is "print these on the chain, then re-prompt", stays
|
|
269
|
+
* in dispatch.ts). The last row carries its own newline exactly as it
|
|
270
|
+
* did inline: bodyLog splits on \n, so `exit` and `keys` land as two
|
|
271
|
+
* rows from one call — the shape the KC1/KC2/KC3 gestures were added
|
|
272
|
+
* to, unchanged.
|
|
273
|
+
*/
|
|
274
|
+
export function helpRows() {
|
|
275
|
+
const p = palette();
|
|
276
|
+
const cmd = (name, desc) => `${p.bold}${name}${p.reset} ${desc}`;
|
|
277
|
+
return [
|
|
278
|
+
cmd("/help", "print this list of commands"),
|
|
279
|
+
cmd("/think", "show the last full thinking block"),
|
|
280
|
+
cmd("/last", "show the most recent tool call's input and output"),
|
|
281
|
+
cmd("/status", "show session id, event count, and context estimate"),
|
|
282
|
+
cmd("/mode", "show the approval tier; /mode <name> switches (manual/default/accept-edits/plan/bypass)"),
|
|
283
|
+
cmd("/model", "list model profiles; /model <name|provider/model> switches"),
|
|
284
|
+
cmd("/compact", "summarize the older conversation to free context"),
|
|
285
|
+
// TUI2-R1 (D): DELIBERATELY UNCHANGED. Deriving this sentence from
|
|
286
|
+
// KEY_BINDINGS would be an improvement and it would also move an
|
|
287
|
+
// assertion outside the round's two declared supersession classes,
|
|
288
|
+
// so the sheet is the derived surface and this row keeps its bytes.
|
|
289
|
+
// `keysHelpRow()` exists for the round that is allowed to make the
|
|
290
|
+
// swap; until then the drift guard is the test that every binding
|
|
291
|
+
// in the table is mentioned here.
|
|
292
|
+
`${cmd("exit", "leave the session")}\n${cmd("keys", "enter sends · ctrl+J newline (shift+enter where encoded) · esc stops the run · alt+⏎ stops it and sends this instead · @ files · 1-4 answers an ask")}`,
|
|
293
|
+
];
|
|
294
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vincemakes/kiso-tui-cells",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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",
|