@vincemakes/kiso-tui-cells 0.8.0 → 0.10.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
@@ -50,10 +57,14 @@ function panelRuleText(view) {
50
57
  * single-row discipline — the row never folds). */
51
58
  function panelOptionsRow(view, sel, W) {
52
59
  const p = palette();
60
+ // TUI2-R1.5 ⑪ (VD-13): ONE separator grammar. The options were
61
+ // two-space separated while every other metadata group in the product
62
+ // uses `·`, and at 80 columns that put `3 No` far enough from its
63
+ // neighbours to read as detached rather than as the third option.
53
64
  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`;
65
+ const o3 = sel === 3 ? `${p.bold}3 No${p.reset}` : `3 No`;
55
66
  if (view.flavor === "simple")
56
- return `${o1} ${o3}`;
67
+ return `${o1} · ${o3}`;
57
68
  // the option-2 span: " 2 Yes, don't ask again for <name>" — the
58
69
  // fixed part is 45 (the gutter + the 1/3 options + the separators +
59
70
  // the 28-cell prefix); the name cuts to W−46 + "…". The "…" needs
@@ -62,13 +73,18 @@ function panelOptionsRow(view, sel, W) {
62
73
  // DROPS: the rule name is the cuttable span, the 1/3 options are
63
74
  // the semantics — the approval decision must survive a narrow
64
75
  // 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}`;
76
+ // TUI2-R1.5 ⑪: option 2 states what it DOES; the tool it would do it
77
+ // for is the panel's title, one row above, and repeating it here was
78
+ // what made this row the widest thing in the block. The fixed part is
79
+ // now 33 cells, so the whole row survives far narrower windows than the
80
+ // 47 the rule name used to demand.
81
+ const o2 = sel === 2 ? `${p.bold}2 Yes, don't ask again${p.reset}` : `2 Yes, don't ask again`;
82
+ const full = `${o1} · ${o2} · ${o3}`;
83
+ if (visibleWidth(full) <= W - 2)
84
+ return full;
85
+ // too narrow for the middle option: the 1/3 decision is the semantics
86
+ // and must survive any winch (invariant ① never fires on this row).
87
+ return cutLine(`${o1} · ${o3}`, Math.max(1, W - 2));
72
88
  }
73
89
  /** The block's rows — EXACTLY the preview's frame shape, the gutter at
74
90
  * the left edge (the preview's two-space mock indent is its own
@@ -80,7 +96,11 @@ export function panelBlockRows(view, phase, sel, W, maxRows) {
80
96
  const rows = [];
81
97
  rows.push(`${gutter}${cutLine(panelRuleText(view), Math.max(1, W - 2))}`);
82
98
  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))}`);
99
+ // TUI2-R1.5 ⑤ (VD-11): the divider is a LABEL, not a design note. "the
100
+ // full args — never truncated" is a sentence about the implementation,
101
+ // addressed to whoever was building the panel; the human reading it
102
+ // during an approval wants to know what the block below is.
103
+ rows.push(`${cutLine(`${p.dim}─ args (full) ─${p.reset}`, Math.max(1, W - 2))}`);
84
104
  // the args — the bounded block's body: fold, then cap. The └ cut is
85
105
  // ONE row (the W20 discipline): when the args exceed the budget, one
86
106
  // notice row carries the count and where the rest is (the event log).
@@ -100,7 +120,15 @@ export function panelBlockRows(view, phase, sel, W, maxRows) {
100
120
  rows.push(...shown);
101
121
  rows.push(`${gutter}${panelOptionsRow(view, sel, W)}`);
102
122
  rows.push(`${gutter}${p.dim}${panelAffordance(view, phase, sel)}${p.reset}`);
103
- rows.push(`${p.dim}└ ${p.reset}`);
123
+ // TUI2-R1.5 11 (VD-13): a real bottom RULE, in the block's own edge
124
+ // vocabulary — the same box-drawing run its divider already uses —
125
+ // anchored at the gutter column. It used to be `\u2514 `: a two-cell stub
126
+ // floating at column 1, with no rule running from it and no corner
127
+ // above it to answer. Worse, `\u2514 ` is the cut-notice prefix everywhere
128
+ // else in the product, so a CAPPED panel emitted two elbow rows in a
129
+ // row meaning entirely different things. The rule reads as an edge,
130
+ // and the cut notice above it reads as a notice.
131
+ rows.push(`${p.dim}\u2514${"\u2500".repeat(Math.max(0, W - 1))}${p.reset}`);
104
132
  return rows;
105
133
  }
106
134
  /** The input row's lead for the panel's phase (the preview's chrome
@@ -149,3 +177,88 @@ export function panelAffordance(view, phase, sel) {
149
177
  return sel === 0 ? "tab amend · esc cancel" : "enter sends · esc backs out";
150
178
  return sel === 0 ? "esc cancel" : "enter sends";
151
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
+ }
@@ -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,57 @@ 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;
218
+ /**
219
+ * TUI2-R2 ⑤ (D, candidate 1) — the FOCUS tint.
220
+ *
221
+ * The cell the next ctrl+r will act on brightens its own `ctrl+r` token
222
+ * to the code tint; the rest of the suffix — the separator, the count —
223
+ * stays dim, because what is being marked is the KEY's target, not the
224
+ * row. Zero new rows, zero new columns: the affordance the cell already
225
+ * prints is the marker.
226
+ *
227
+ * Applied to a row rather than composed into it on purpose. The token is
228
+ * emitted from several places (the settled suffix, the renderer's own
229
+ * `└ +N … · ctrl+r` cut rows) and threading a flag through all of them
230
+ * would put the invariant "exactly one bright token" in as many hands as
231
+ * there are emitters. Here it has exactly one.
232
+ *
233
+ * NO_COLOR: p.code is empty, so the row's bytes are untouched.
234
+ */
235
+ export declare function focusToken(row: string, W: number): string;
213
236
  /** W13 — the rollup opt-in table: which tools collapse, and the count
214
237
  * NOUN (read_file calls → "5 files", list_dir → "5 dirs", search_text
215
238
  * → "5 matches"). Only these tools opt in — a shell burst is never
216
239
  * rolled up (its rows carry meaning). The folded-turn line (W14) reuses
217
240
  * the plurals for its other-tool terms ("2 dirs", "1 match"). */
218
241
  export declare const ROLLUP_NOUN: Readonly<Record<string, string>>;
242
+ /** TUI2-R1 (B) — the verb column of the expanded list names the ACT.
243
+ * TUI2-R2pre ④: this used to be a private three-tool table saying the
244
+ * same thing as the card head's `_file` strip, in a different way and
245
+ * for a different set of tools. Both are `displayVerb` now — the whole
246
+ * point of the ruling is that there is ONE answer to "what does the
247
+ * screen call this". The cut note, which used to be the deliberate
248
+ * exception here, moved with it (see toolCutNote). */
249
+ /** Whether a tool joins an exploration run. Exactly the read-only set —
250
+ * writes, edits, shells and extension tools never group (a burst of
251
+ * side effects is a list of things that HAPPENED, and every row of it
252
+ * carries meaning). */
253
+ export declare function isExploreTool(name: string): boolean;
254
+ /** "8 files · 14 searches" — the per-tool counts in first-call order. */
255
+ export declare function exploreCounts(parts: readonly {
256
+ name: string;
257
+ subjects: readonly string[];
258
+ }[]): string;
259
+ /** TUI2-R1 (B) — the expanded list: ONE row per tool, the verb column
260
+ * then the distinct subjects in first-call order, a repeated subject
261
+ * carrying its ×count, the first three shown and the rest counted.
262
+ * A search's subject is its PATTERN (quoted — the thing that was
263
+ * looked for); a read's or a list's is its path. */
264
+ export declare function exploreRows(parts: readonly {
265
+ name: string;
266
+ subjects: readonly string[];
267
+ }[], W: number): string[];
219
268
  /** W14 — the folded-turn line: a whole QUIET turn (no text), once it is
220
269
  * scrollback, becomes ONE line — the work order's claimed shape
221
270
  * (`▞ thought 19s · 5 reads · no edits`), the counts accumulated at