@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.
@@ -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
@@ -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} 3 No${p.reset}` : ` 3 No`;
58
+ const o3 = sel === 3 ? `${p.bold}3 No${p.reset}` : `3 No`;
55
59
  if (view.flavor === "simple")
56
- return `${o1} ${o3}`;
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
- if (W < 47)
66
- return cutLine(`${o1} ${o3}`, Math.max(1, W - 2));
67
- const name = escapeTerminal(view.name);
68
- const budget = W - 46;
69
- const shown = visibleWidth(name) > W - 45 ? `${widthCut(name, budget)}…` : name;
70
- const o2 = sel === 2 ? `${p.bold} 2 Yes, don't ask again for ${shown}${p.reset}` : ` 2 Yes, don't ask again for ${shown}`;
71
- return `${o1} ${o2} ${o3}`;
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
- rows.push(`${cutLine(`${p.dim}─ the full args never truncated ─${p.reset}`, Math.max(1, W - 2))}`);
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
- rows.push(`${p.dim}└ ${p.reset}`);
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
@@ -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
- /** Fold a line's CONTENT at W−2 and prefix EVERY row with the gutter
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
@@ -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 summary = escapeTerminal(c.input);
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 = c.verdict !== null && c.verdict.decidedBy !== undefined ? ` · by ${escapeTerminal(c.verdict.decidedBy)}` : "";
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
- const meta = escapeTerminal(settledMeta(c));
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 = c.verdict !== null && c.verdict.decidedBy !== undefined ? ` · approved by ${escapeTerminal(c.verdict.decidedBy)}` : "";
424
- const out = c.isError
425
- ? gutterCut(`${p.red}✗${p.reset} `, `${p.red}${verbCol} ${escapeTerminal(toolTargetOf(c))} (${meta}${approvedBy}, ${elapsed}s)${p.reset}`, W)
426
- : gutterCut(`${p.bold}✓${p.reset} `, `${verbCol} ${escapeTerminal(toolTargetOf(c))} (${meta}${approvedBy}, ${elapsed}s)`, W);
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} ${summary}`, W);
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 out = gutterCut(`${p.bold}${SPINNER[ctx.spinnerI % SPINNER.length]}${p.reset} `, `${verbCol} ${summary} ${elapsed}s`, W);
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} ${summary}`, W);
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
- : c.name.startsWith("shell")
560
- ? shellTail(c.resultText, W)
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
- : liveWindow(c.resultText, W)
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 = foldLine(text, W);
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
- return foldLine(escapeTerminal(this.cell.text), W);
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
- return this.cell.lines.flatMap((l) => foldLine(l, W));
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 search→replace windows replace in place — the changed
27
- * region is KNOWN, so the diff is the old window vs the new window,
28
- * context from the surrounding file. */
29
- export declare function editFileDiff(oldContent: string, search: string, replace: string): DiffResult;
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 search→replace windows replace in place — the changed
95
- * region is KNOWN, so the diff is the old window vs the new window,
96
- * context from the surrounding file. */
97
- export function editFileDiff(oldContent, search, replace) {
98
- const oldLines = oldContent.split("\n");
99
- const searchLines = search.split("\n");
100
- const replaceLines = replace.split("\n");
101
- // Locate the search window (the first occurrence — the edit tool's own
102
- // semantics); no occurrence the whole file is the old side.
103
- let at = -1;
104
- for (let i = 0; i + searchLines.length <= oldLines.length; i += 1) {
105
- if (oldLines.slice(i, i + searchLines.length).join("\n") === search) {
106
- at = i;
107
- break;
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 lines = at < 0 ? withContext(lcsDiff(oldLines, replaceLines)) : withContext(lcsDiff(oldLines, [...oldLines.slice(0, at), ...replaceLines, ...oldLines.slice(at + searchLines.length)]));
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.7.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",