@vincemakes/kiso-tui-cells 0.9.0 → 0.11.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.
@@ -75,6 +75,38 @@ export interface AskRuntime {
75
75
  readonly custom: readonly (string | null)[];
76
76
  readonly phase: "options" | "custom";
77
77
  }
78
+ /** One thing that can be picked: what it is, and what qualifies it. */
79
+ export interface PickOption {
80
+ readonly label: string;
81
+ /** the dim qualifier ("profile: ds \u00b7 current") \u2014 what tells two
82
+ * similar rows apart */
83
+ readonly note?: string;
84
+ }
85
+ /** The whole pick: the header sentence, the options, the free-text
86
+ * escape hatch, and the honest empty state. */
87
+ export interface PickSpec {
88
+ readonly header: string;
89
+ readonly options: readonly PickOption[];
90
+ /** the `t` row \u2014 typing it directly is always available, because a
91
+ * list of profiles is never the list of models that exist */
92
+ readonly typeHint: string;
93
+ /** shown INSTEAD of the options when there are none. The copy is the
94
+ * caller's and is reproduced verbatim. */
95
+ readonly emptyNote?: string;
96
+ }
97
+ /** The pick panel's runtime state \u2014 the editor owns it, the compositor
98
+ * reads it (the AskRuntime precedent, two fields instead of five). */
99
+ export interface PickRuntime {
100
+ readonly cursor: number;
101
+ readonly phase: "options" | "custom";
102
+ }
103
+ /** What was picked: a listed option by INDEX (never a label the caller
104
+ * would have to re-match against its own list), or typed text. */
105
+ export type PickResult = {
106
+ readonly index: number;
107
+ } | {
108
+ readonly custom: string;
109
+ };
78
110
  /** The ALWAYS-verbose args (the panel's body): the untruncated diff
79
111
  * (edit/write), or the full text (shell = the command line, other =
80
112
  * the pretty-printed JSON). The CLI composes them UNTRUNCATED — the
@@ -116,6 +148,9 @@ export interface PanelView {
116
148
  * panel renders the ask block and the editor routes the ask keys;
117
149
  * absent = the approval/simple panel, unchanged. */
118
150
  readonly ask?: AskSpec;
151
+ /** TUI2-R2 \u2463: the options, when this view is a PICK. Same contract
152
+ * as `ask`, one payload over. */
153
+ readonly pick?: PickSpec;
119
154
  }
120
155
  export type PanelVerdict = {
121
156
  readonly action: "allow";
@@ -135,6 +170,13 @@ export type PanelVerdict = {
135
170
  | {
136
171
  readonly action: "answers";
137
172
  readonly result: AskResult;
173
+ }
174
+ /** TUI2-R2 \u2463: the pick's verdict \u2014 the chosen index or the typed
175
+ * text. Only pick views ever produce it, so the approval path's
176
+ * switch is untouched. */
177
+ | {
178
+ readonly action: "picked";
179
+ readonly result: PickResult;
138
180
  };
139
181
  /** The bound panel state the compositor reads — the editor owns the
140
182
  * phase/selection state machine and the key routing; the compositor
@@ -145,6 +187,8 @@ export interface PanelState {
145
187
  readonly sel: PanelSel;
146
188
  /** KC3.5: the ask's walk — present exactly when `view.ask` is. */
147
189
  readonly ask?: AskRuntime;
190
+ /** TUI2-R2 \u2463: the pick's walk — present exactly when `view.pick` is. */
191
+ readonly pick?: PickRuntime;
148
192
  }
149
193
  /** The block's rows — EXACTLY the preview's frame shape, the gutter at
150
194
  * the left edge (the preview's two-space mock indent is its own
@@ -168,3 +212,34 @@ export declare function panelStatus(view: PanelView, phase: PanelPhase, sel: Pan
168
212
  * phase's keys. The approval flavor gains the tab-amend path; the
169
213
  * simple flavor (the trust/uncertain gates) never does. */
170
214
  export declare function panelAffordance(view: PanelView, phase: PanelPhase, sel: PanelSel): string;
215
+ /**
216
+ * The pick block's rows — the prototype's C frame.
217
+ *
218
+ * The header says what is in effect right now, because the first
219
+ * question anyone opening this panel has is "what am I on?". The
220
+ * options are numbered from 1 and the number IS the key. The `t` row is
221
+ * last and always present: a profile list is a convenience, never the
222
+ * set of models that exist, and a picker that can only offer what
223
+ * someone remembered to configure is a smaller product than the one it
224
+ * replaced.
225
+ *
226
+ * Single-row discipline: every row CUTS, never folds — the block's
227
+ * height is its row count (the W20 rule the #checked throw demands).
228
+ */
229
+ export declare function pickBlockRows(view: PanelView, state: PickRuntime, W: number, maxRows: number): string[];
230
+ /** The digits are the keys, so the list the panel offers is bounded by
231
+ * the digits there are. Beyond it, `/model <name>` still takes any
232
+ * profile — the panel says so rather than paginating. */
233
+ export declare const PICK_MAX = 9;
234
+ /** The input row's lead: the digit range while picking, the named
235
+ * prompt while typing one out. */
236
+ export declare function pickLeadPlain(view: PanelView, state: PickRuntime): string;
237
+ export declare function pickLead(view: PanelView, state: PickRuntime): string;
238
+ /** The status row's left text \u2014 the CALLER's, because only the caller
239
+ * knows whether a run is paused behind this panel. */
240
+ export declare function pickStatus(view: PanelView): string;
241
+ export declare function pickAffordance(state: PickRuntime): string;
242
+ /** Compose a pick view. The flavor/name/title/args fields exist for the
243
+ * approval path and are given inert values here \u2014 the pick block
244
+ * reads none of them. */
245
+ export declare function modelPickView(spec: PickSpec, statusText: string): PanelView;
@@ -26,6 +26,9 @@
26
26
  */
27
27
  import { displayWidth } from "./width.js";
28
28
  import { cutLine, diffBody, gutterFold, visibleWidth, widthCut } from "./components.js";
29
+ // TUI2-R2pre ④: strings.js takes only a TYPE from this module, so the
30
+ // import is erased at compile time and no runtime cycle exists.
31
+ import { displayVerb } from "./strings.js";
29
32
  import { escapeTerminal, palette } from "./render.js";
30
33
  /** The rule line's text — the why-asked line (the R3 chain): the tool
31
34
  * name, the first non-abstain speaker, the fix hint (the §3.5 table,
@@ -37,7 +40,11 @@ function panelRuleText(view) {
37
40
  if (view.ruleOverride !== undefined)
38
41
  return escapeTerminal(view.ruleOverride);
39
42
  const hint = view.hint;
40
- const base = `${p.bold}${escapeTerminal(view.name)}${p.reset} ${p.dim}needs approvalasked by${p.reset} ${p.bold}${escapeTerminal(view.speaker)}${p.reset}`;
43
+ // TUI2-R2pre ④: the rule line is the panel's header it says the ACT
44
+ // ("edit needs approval"). view.name keeps the RAW tool name, which is
45
+ // what the option-2 rule prefill and the fallbackQuestion (the
46
+ // dock-less/pipe path — byte-identical by ruling) still read.
47
+ const base = `${p.bold}${escapeTerminal(displayVerb(view.name))}${p.reset} ${p.dim}needs approval — asked by${p.reset} ${p.bold}${escapeTerminal(view.speaker)}${p.reset}`;
41
48
  return hint ? `${base}${p.dim} ·${p.reset} ${p.code}${escapeTerminal(hint)}${p.reset}` : base;
42
49
  }
43
50
  /** The numbered options row — "1 Yes 2 Yes, don't ask again for
@@ -170,3 +177,88 @@ export function panelAffordance(view, phase, sel) {
170
177
  return sel === 0 ? "tab amend · esc cancel" : "enter sends · esc backs out";
171
178
  return sel === 0 ? "esc cancel" : "enter sends";
172
179
  }
180
+ // ── TUI2-R2 ④: the pick block, its lead, its status, its affordance ──
181
+ /**
182
+ * The pick block's rows — the prototype's C frame.
183
+ *
184
+ * The header says what is in effect right now, because the first
185
+ * question anyone opening this panel has is "what am I on?". The
186
+ * options are numbered from 1 and the number IS the key. The `t` row is
187
+ * last and always present: a profile list is a convenience, never the
188
+ * set of models that exist, and a picker that can only offer what
189
+ * someone remembered to configure is a smaller product than the one it
190
+ * replaced.
191
+ *
192
+ * Single-row discipline: every row CUTS, never folds — the block's
193
+ * height is its row count (the W20 rule the #checked throw demands).
194
+ */
195
+ export function pickBlockRows(view, state, W, maxRows) {
196
+ const p = palette();
197
+ const spec = view.pick;
198
+ const gutter = `${p.dim}\u2502${p.reset} `;
199
+ const rows = [];
200
+ const room = Math.max(1, W - 2);
201
+ rows.push(`${gutter}${cutLine(`${p.bold}${escapeTerminal(spec.header.split(" \u2014 ")[0] ?? spec.header)}${p.reset}${p.dim}${escapeTerminal(spec.header.slice((spec.header.split(" \u2014 ")[0] ?? "").length))}${p.reset}`, room)}`);
202
+ if (spec.options.length === 0) {
203
+ // the honest empty state \u2014 the caller's own copy, verbatim
204
+ rows.push(`${gutter}${cutLine(`${p.dim} ${escapeTerminal(spec.emptyNote ?? "no options")}${p.reset}`, room)}`);
205
+ }
206
+ else {
207
+ // the budget: the header, the t row, the affordance and the rule
208
+ const budget = Math.max(1, maxRows - 4);
209
+ const shown = spec.options.slice(0, Math.min(budget, PICK_MAX));
210
+ for (let i = 0; i < shown.length; i += 1) {
211
+ const o = shown[i];
212
+ const mark = i === state.cursor && state.phase === "options";
213
+ const head = `${mark ? p.bold : ""} ${i + 1} ${escapeTerminal(o.label)}${mark ? p.reset : ""}`;
214
+ const note = o.note === undefined ? "" : `${p.dim} ${escapeTerminal(o.note)}${p.reset}`;
215
+ rows.push(`${gutter}${cutLine(`${head}${note}`, room)}`);
216
+ }
217
+ if (spec.options.length > shown.length) {
218
+ rows.push(`${gutter}${cutLine(`${p.dim} \u2514 +${spec.options.length - shown.length} more \u2014 /model <name> takes any of them${p.reset}`, room)}`);
219
+ }
220
+ }
221
+ rows.push(`${gutter}${cutLine(`${state.phase === "custom" ? p.bold : ""} t ${p.reset}${p.dim}${escapeTerminal(spec.typeHint)}${p.reset}`, room)}`);
222
+ rows.push(`${gutter}${p.dim}${cutLine(pickAffordance(state), room)}${p.reset}`);
223
+ rows.push(`${p.dim}\u2514${"\u2500".repeat(Math.max(0, W - 1))}${p.reset}`);
224
+ return rows;
225
+ }
226
+ /** The digits are the keys, so the list the panel offers is bounded by
227
+ * the digits there are. Beyond it, `/model <name>` still takes any
228
+ * profile — the panel says so rather than paginating. */
229
+ export const PICK_MAX = 9;
230
+ /** The input row's lead: the digit range while picking, the named
231
+ * prompt while typing one out. */
232
+ export function pickLeadPlain(view, state) {
233
+ if (state.phase === "custom")
234
+ return "provider/model: ";
235
+ const n = Math.min(view.pick.options.length, PICK_MAX);
236
+ return n === 0 ? "t> " : `1-${n}> `;
237
+ }
238
+ export function pickLead(view, state) {
239
+ const p = palette();
240
+ return `${p.bold}${pickLeadPlain(view, state)}${p.reset}`;
241
+ }
242
+ /** The status row's left text \u2014 the CALLER's, because only the caller
243
+ * knows whether a run is paused behind this panel. */
244
+ export function pickStatus(view) {
245
+ return view.statusText;
246
+ }
247
+ export function pickAffordance(state) {
248
+ return state.phase === "custom" ? "enter commits \u00b7 esc backs out" : "digits pick \u00b7 \u23ce confirms \u00b7 esc";
249
+ }
250
+ /** Compose a pick view. The flavor/name/title/args fields exist for the
251
+ * approval path and are given inert values here \u2014 the pick block
252
+ * reads none of them. */
253
+ export function modelPickView(spec, statusText) {
254
+ return {
255
+ flavor: "simple",
256
+ name: "model",
257
+ title: "model",
258
+ speaker: "you",
259
+ statusText,
260
+ args: { kind: "text", lines: [] },
261
+ fallbackQuestion: "switch model? (name) ",
262
+ pick: spec,
263
+ };
264
+ }
@@ -18,6 +18,8 @@
18
18
  * tint, fold wording).
19
19
  */
20
20
  import { foldThinking, foldResult, renderToolSummary, type ResumeMeta } from "./render.js";
21
+ import { type MdBlock } from "./md.js";
22
+ export { MdStream, renderBlock, renderMarkdown, type MdBlock, type MdKind } from "./md.js";
21
23
  /** The spinner glyphs, cycled by the compositor's on-demand tick. */
22
24
  export declare const SPINNER: string[];
23
25
  /** The frame context the compositor passes down — the pieces of time
@@ -43,8 +45,12 @@ export type RenderLine = string;
43
45
  */
44
46
  export declare function foldLine(line: string, W: number): string[];
45
47
  /** The visible width of a rendered line (SGR stripped — the invariant
46
- * the compositor enforces on every emitted line). */
47
- export declare function visibleWidth(line: string): number;
48
+ * the compositor enforces on every emitted line). TUI2-MD ⑤: the body
49
+ * moved to width.ts (the width authority's own home) so the markdown
50
+ * renderer can measure without importing this module back — the
51
+ * re-export is verbatim, so every existing importer and the barrel see
52
+ * exactly what they saw. */
53
+ export { visibleWidth } from "./width.js";
48
54
  /** A component: render the display lines for one piece of state. */
49
55
  export interface Component {
50
56
  render(width: number, ctx: FrameCtx): string[];
@@ -145,6 +151,18 @@ export type BodyCell = {
145
151
  kind: "text";
146
152
  text: string;
147
153
  done: boolean;
154
+ }
155
+ /** TUI2-MD ⑤ — ONE markdown block of assistant body text. The cell is
156
+ * the commit unit the compositor already had, so block-freeze needs
157
+ * no new commit machinery: a CLOSED block is a DONE cell and the
158
+ * natural loop freezes it; the OPEN tail block is the one cell left
159
+ * live. `block` carries the block's SOURCE (never rendered rows), so
160
+ * a resize re-renders it at the new width exactly as every other
161
+ * cell does. */
162
+ | {
163
+ kind: "md";
164
+ block: MdBlock;
165
+ done: boolean;
148
166
  } | {
149
167
  kind: "notice";
150
168
  text: string;
@@ -215,12 +233,37 @@ export declare function gutterFold(gutter: string, line: string, W: number): str
215
233
  * ellipsis ride the row (the invariant ① cap holds). */
216
234
  export declare function gutterCut(gutter: string, line: string, W: number): string[];
217
235
  export declare function expandSuffix(lines: number | null, room: number): string;
236
+ /**
237
+ * TUI2-R2 ⑤ (D, candidate 1) — the FOCUS tint.
238
+ *
239
+ * The cell the next ctrl+r will act on brightens its own `ctrl+r` token
240
+ * to the code tint; the rest of the suffix — the separator, the count —
241
+ * stays dim, because what is being marked is the KEY's target, not the
242
+ * row. Zero new rows, zero new columns: the affordance the cell already
243
+ * prints is the marker.
244
+ *
245
+ * Applied to a row rather than composed into it on purpose. The token is
246
+ * emitted from several places (the settled suffix, the renderer's own
247
+ * `└ +N … · ctrl+r` cut rows) and threading a flag through all of them
248
+ * would put the invariant "exactly one bright token" in as many hands as
249
+ * there are emitters. Here it has exactly one.
250
+ *
251
+ * NO_COLOR: p.code is empty, so the row's bytes are untouched.
252
+ */
253
+ export declare function focusToken(row: string, W: number): string;
218
254
  /** W13 — the rollup opt-in table: which tools collapse, and the count
219
255
  * NOUN (read_file calls → "5 files", list_dir → "5 dirs", search_text
220
256
  * → "5 matches"). Only these tools opt in — a shell burst is never
221
257
  * rolled up (its rows carry meaning). The folded-turn line (W14) reuses
222
258
  * the plurals for its other-tool terms ("2 dirs", "1 match"). */
223
259
  export declare const ROLLUP_NOUN: Readonly<Record<string, string>>;
260
+ /** TUI2-R1 (B) — the verb column of the expanded list names the ACT.
261
+ * TUI2-R2pre ④: this used to be a private three-tool table saying the
262
+ * same thing as the card head's `_file` strip, in a different way and
263
+ * for a different set of tools. Both are `displayVerb` now — the whole
264
+ * point of the ruling is that there is ONE answer to "what does the
265
+ * screen call this". The cut note, which used to be the deliberate
266
+ * exception here, moved with it (see toolCutNote). */
224
267
  /** Whether a tool joins an exploration run. Exactly the read-only set —
225
268
  * writes, edits, shells and extension tools never group (a burst of
226
269
  * side effects is a list of things that HAPPENED, and every row of it
@@ -17,8 +17,18 @@
17
17
  * (untouched); render.ts supplies the original text (palette, escape,
18
18
  * tint, fold wording).
19
19
  */
20
- import { displayWidth } from "./width.js";
20
+ import { displayWidth, visibleWidth } from "./width.js";
21
+ // TUI2-R2pre ④: the ONE display-verb table (strings.ts, beside
22
+ // KEY_BINDINGS). strings.js imports only render/width here, so this edge
23
+ // adds no cycle.
24
+ import { displayVerb } from "./strings.js";
21
25
  import { bannerLines, escapeTerminal, foldThinking, foldResult, colorInlineCode, renderTerminalGap, renderToolSummary, toolTarget, kUnit, palette, } from "./render.js";
26
+ // TUI2-MD: the markdown renderer's surface reaches the tui through this
27
+ // module (the tui's components shim re-exports it) — one import edge,
28
+ // and it points one way: md.ts measures with the width authority, never
29
+ // back through here.
30
+ import { renderBlock } from "./md.js";
31
+ export { MdStream, renderBlock, renderMarkdown } from "./md.js";
22
32
  /** The spinner glyphs, cycled by the compositor's on-demand tick. */
23
33
  export const SPINNER = ["▖", "▘", "▝", "▗"];
24
34
  /**
@@ -90,24 +100,12 @@ export function foldLine(line, W) {
90
100
  return out;
91
101
  }
92
102
  /** The visible width of a rendered line (SGR stripped — the invariant
93
- * the compositor enforces on every emitted line). */
94
- export function visibleWidth(line) {
95
- let w = 0;
96
- for (let i = 0; i < line.length;) {
97
- if (line[i] === "\x1b") {
98
- const m = /^\x1b\[[0-9;?]*[A-Za-z]/.exec(line.slice(i));
99
- if (m !== null) {
100
- i += m[0].length;
101
- continue;
102
- }
103
- i += 1;
104
- continue;
105
- }
106
- w += displayWidth(line[i]);
107
- i += 1;
108
- }
109
- return w;
110
- }
103
+ * the compositor enforces on every emitted line). TUI2-MD ⑤: the body
104
+ * moved to width.ts (the width authority's own home) so the markdown
105
+ * renderer can measure without importing this module back — the
106
+ * re-export is verbatim, so every existing importer and the barrel see
107
+ * exactly what they saw. */
108
+ export { visibleWidth } from "./width.js";
111
109
  /** The W11 spacing formula — "a row gets one blank line above it when
112
110
  * the row is itself a block, or when the previous sibling was taller
113
111
  * than one row". One-row siblings pack tight; anything multi-row
@@ -156,6 +154,8 @@ export function cellComponent(cell) {
156
154
  return new ToolExecution(cell);
157
155
  case "text":
158
156
  return new AssistantMessage(cell);
157
+ case "md":
158
+ return new MarkdownBlock(cell);
159
159
  case "notice":
160
160
  return new ErrorLine(cell);
161
161
  case "banner":
@@ -454,7 +454,7 @@ class ToolExecution {
454
454
  render(W, ctx) {
455
455
  const p = palette();
456
456
  const c = this.cell;
457
- const verb = escapeTerminal(c.name.replace("_file", ""));
457
+ const verb = escapeTerminal(displayVerb(c.name));
458
458
  const verbCol = verb.length < 5 ? `${verb}${" ".repeat(5 - verb.length)}` : verb;
459
459
  const parts = c.rolled?.parts;
460
460
  if (c.rolled !== null && parts !== undefined) {
@@ -711,6 +711,46 @@ function appendSuffix(row, suffix) {
711
711
  const p = palette();
712
712
  return `${row}${p.dim}${suffix}${p.reset}`;
713
713
  }
714
+ /**
715
+ * TUI2-R2 ⑤ (D, candidate 1) — the FOCUS tint.
716
+ *
717
+ * The cell the next ctrl+r will act on brightens its own `ctrl+r` token
718
+ * to the code tint; the rest of the suffix — the separator, the count —
719
+ * stays dim, because what is being marked is the KEY's target, not the
720
+ * row. Zero new rows, zero new columns: the affordance the cell already
721
+ * prints is the marker.
722
+ *
723
+ * Applied to a row rather than composed into it on purpose. The token is
724
+ * emitted from several places (the settled suffix, the renderer's own
725
+ * `└ +N … · ctrl+r` cut rows) and threading a flag through all of them
726
+ * would put the invariant "exactly one bright token" in as many hands as
727
+ * there are emitters. Here it has exactly one.
728
+ *
729
+ * NO_COLOR: p.code is empty, so the row's bytes are untouched.
730
+ */
731
+ export function focusToken(row, W) {
732
+ const p = palette();
733
+ const at = row.lastIndexOf(CTRL_R);
734
+ if (at !== -1) {
735
+ // the row already names the key — brighten the token in place, and
736
+ // leave every other span exactly as it was
737
+ if (p.code === "")
738
+ return row;
739
+ return `${row.slice(0, at)}${p.code}${CTRL_R}${p.reset}${p.dim}${row.slice(at + CTRL_R.length)}`;
740
+ }
741
+ // A LIVE row does not carry the affordance today, and the live cell is
742
+ // the one ctrl+r takes FIRST (expandNext scans the live tail before
743
+ // the committed ring) — so the row the key is aimed at was the one row
744
+ // that never said the key existed. The affordance IS the marker here:
745
+ // it appears on the focused row and nowhere else, which is why no
746
+ // unfocused row's bytes move (every existing live-row assertion
747
+ // renders a cell with no focus and is untouched).
748
+ const room = W - visibleWidth(row);
749
+ if (room < SUFFIX_MIN)
750
+ return row; // never at the cost of invariant ①
751
+ return `${row}${p.dim} · ${p.reset}${p.code}${CTRL_R}${p.reset}`;
752
+ }
753
+ const CTRL_R = "ctrl+r";
714
754
  /** TUI2-R1 (A) — the expanded block's last row: the way back. The
715
755
  * rollup's expanded list carries a second clause (its members' full
716
756
  * outputs live in /last, which the group row cannot show). */
@@ -736,10 +776,13 @@ const EXPLORE_NOUN = {
736
776
  list_dir: ["dir", "dirs"],
737
777
  search_text: ["search", "searches"],
738
778
  };
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" };
779
+ /** TUI2-R1 (B) — the verb column of the expanded list names the ACT.
780
+ * TUI2-R2pre ④: this used to be a private three-tool table saying the
781
+ * same thing as the card head's `_file` strip, in a different way and
782
+ * for a different set of tools. Both are `displayVerb` now — the whole
783
+ * point of the ruling is that there is ONE answer to "what does the
784
+ * screen call this". The cut note, which used to be the deliberate
785
+ * exception here, moved with it (see toolCutNote). */
743
786
  /** Whether a tool joins an exploration run. Exactly the read-only set —
744
787
  * writes, edits, shells and extension tools never group (a burst of
745
788
  * side effects is a list of things that HAPPENED, and every row of it
@@ -770,7 +813,7 @@ export function exploreRows(parts, W) {
770
813
  counts.set(s, (counts.get(s) ?? 0) + 1);
771
814
  const shown = [...counts.entries()].slice(0, 3).map(([s, n]) => (n > 1 ? `${s} ×${n}` : s));
772
815
  const more = counts.size > 3 ? ` (+${counts.size - 3})` : "";
773
- const verb = EXPLORE_VERB[part.name] ?? part.name;
816
+ const verb = displayVerb(part.name);
774
817
  rows.push(cutLine(`${p.dim}${BODY_ROW}${escapeTerminal(`${verb.padEnd(6)} ${shown.join(" · ")}${more}`)}${p.reset}`, W));
775
818
  }
776
819
  // TUI2-R1.5 ① (VD-15): the footer used to promise "/last shows the full
@@ -812,7 +855,7 @@ export function turnFold(t, W) {
812
855
  parts.push(countTerm(n, noun.endsWith("es") ? noun.slice(0, -2) : noun.slice(0, -1), noun));
813
856
  }
814
857
  else {
815
- const verb = name.replace("_file", "");
858
+ const verb = displayVerb(name);
816
859
  parts.push(countTerm(n, verb, `${verb}s`));
817
860
  }
818
861
  }
@@ -1110,14 +1153,22 @@ export function diffBody(diff, W, expanded = false) {
1110
1153
  * offset=N", the output cap, list_dir's entry cap). The note reaches
1111
1154
  * the MODEL and never the human — this row surfaces it. Detected in
1112
1155
  * the result's TAIL (the note is appended at the end); returns null
1113
- * when the tool did not truncate. */
1156
+ * when the tool did not truncate.
1157
+ *
1158
+ * TUI2-R2pre ④: the verb here is the DISPLAY one now. This row used to
1159
+ * be the sanctioned raw-name exception, on the reasoning that it names
1160
+ * the tool the model should call again — but the row is addressed to
1161
+ * the HUMAN (the model already has the note in its own transcript, which
1162
+ * is where it read it), and the ruling names this advisory family
1163
+ * explicitly. The `offset=N` it carries is the actionable half and is
1164
+ * untouched. */
1114
1165
  function toolCutNote(name, resultText) {
1115
1166
  const tail = resultText.slice(-300);
1116
1167
  const m = /offset=(\d+)/.exec(tail);
1117
1168
  if (m !== null)
1118
- return `capped by ${escapeTerminal(name)} · offset=${m[1]} for the rest`;
1169
+ return `capped by ${escapeTerminal(displayVerb(name))} · offset=${m[1]} for the rest`;
1119
1170
  if (/…\[truncated\]/.test(tail) || /… \+?\d+ more (?:lines|entries)/.test(tail))
1120
- return `capped by ${escapeTerminal(name)} · /last for the rest`;
1171
+ return `capped by ${escapeTerminal(displayVerb(name))} · /last for the rest`;
1121
1172
  return null;
1122
1173
  }
1123
1174
  /** The assistant body text — wrapped at W, the inline-code tint per
@@ -1135,6 +1186,20 @@ class AssistantMessage {
1135
1186
  return wrapped.length > 0 ? wrapped.map((l) => colorInlineCode(l)) : [""];
1136
1187
  }
1137
1188
  }
1189
+ /** TUI2-MD ⑤ — one markdown block. Pure in (block, W): the same source
1190
+ * and the same width give the same bytes, which is the freeze property
1191
+ * the commit path relies on. The block carries its own leading blank
1192
+ * (the style table's rhythm), so the compositor's W11 join formula
1193
+ * steps aside between two of these. */
1194
+ class MarkdownBlock {
1195
+ cell;
1196
+ constructor(cell) {
1197
+ this.cell = cell;
1198
+ }
1199
+ render(W, _ctx) {
1200
+ return renderBlock(this.cell.block, W);
1201
+ }
1202
+ }
1138
1203
  /** The ⚠ / notice lines — the error surface. */
1139
1204
  class ErrorLine {
1140
1205
  cell;
package/dist/index.d.ts CHANGED
@@ -6,11 +6,12 @@
6
6
  * package); the cli never imports it directly. Experimental — no
7
7
  * API-stability promise yet.
8
8
  */
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";
9
+ export { SPINNER, foldLine, foldWords, visibleWidth, bodySpacing, Container, cellComponent, focusToken, 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
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
15
  export { extensionsBannerText, helpRows, unansweredAskView, type BannerExtension } from "./strings.js";
16
+ export { displayVerb } from "./strings.js";
16
17
  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, foldWords, 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, focusToken, 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
@@ -25,4 +25,7 @@ export { panelAffordance, panelBlockRows, panelLead, panelLeadPlain, panelLeadWi
25
25
  export { interactivePrompt, projectTrustRows, projectTrustView, projectUntrustedNote, uncertainView, } from "./strings.js";
26
26
  // KC3.5: the interrupted-ask copy and the extracted /help table.
27
27
  export { extensionsBannerText, helpRows, unansweredAskView } from "./strings.js";
28
+ // TUI2-R2pre ④: the ONE display-verb table — the screen names the act,
29
+ // the tool table names the call.
30
+ export { displayVerb } from "./strings.js";
28
31
  export { bannerLines, COLOR_OFF, COLOR_ON, colorInlineCode, escapeTerminal, foldResult, foldThinking, kUnit, palette, relativeTime, renderResumeList, renderTerminalGap, renderToolSummary, TAGLINE, toolTarget, truncateRow, } from "./render.js";