@vincemakes/kiso-tui-cells 0.31.0 → 0.32.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.
@@ -209,7 +209,39 @@ export interface PickOption {
209
209
  /** the dim qualifier ("profile: ds \u00b7 current") \u2014 what tells two
210
210
  * similar rows apart */
211
211
  readonly note?: string;
212
+ /** OR-7 — the SECOND axis. The model's NATIVE levels in vendor order,
213
+ * from the registry. Absent = this option has no second axis and the
214
+ * row renders exactly as it did before. */
215
+ readonly levels?: readonly string[];
216
+ /** where the level cursor starts. UNDEFINED is a real state, not a
217
+ * missing value: a row whose registry default is null (the page
218
+ * states none) marks nothing, and enter applies the profile alone.
219
+ * The panel never invents a default. */
220
+ readonly level?: number;
221
+ /** indexes forbidden for the current thinking mode. The cursor never
222
+ * rests on one \u2014 see `enabledLevel`. */
223
+ readonly disabled?: readonly number[];
224
+ /** shown after the strip when the cursor did NOT land where the
225
+ * previous selection asked ("effort xhigh \u2192 high: the nearest this
226
+ * model supports"). The caller's sentence, reproduced verbatim. */
227
+ readonly levelNote?: string;
212
228
  }
229
+ /** The level cursor, CORRECTED at read time: never off the end, never on
230
+ * a forbidden index, and never invented where the caller supplied none.
231
+ *
232
+ * One rule instead of two. The alternative \u2014 letting the cursor rest on
233
+ * a forbidden index and refusing at enter \u2014 needs a second mechanism to
234
+ * say why nothing happened, and a key that silently does nothing is the
235
+ * defect this panel already fixed once (DC-36). Correcting on read is
236
+ * the discipline the session picker uses for its own selection. */
237
+ export declare function enabledLevel(o: PickOption | undefined, want: number | null): number | null;
238
+ /** Where the cursor OPENS on an option: what the caller asked for,
239
+ * corrected. The two callers (the panel opening, and the highlight
240
+ * moving to another row) mean exactly this and nothing else. */
241
+ export declare function startLevel(o: PickOption | undefined): number | null;
242
+ /** The next enabled index in `dir`, or the current one at the end of the
243
+ * ladder. Forbidden indexes are stepped OVER, never landed on. */
244
+ export declare function stepLevel(o: PickOption, from: number | null, dir: -1 | 1): number | null;
213
245
  /** The whole pick: the header sentence, the options, the free-text
214
246
  * escape hatch, and the honest empty state. */
215
247
  export interface PickSpec {
@@ -231,6 +263,9 @@ export interface PickSpec {
231
263
  export interface PickRuntime {
232
264
  readonly cursor: number;
233
265
  readonly phase: "options" | "custom";
266
+ /** OR-7: the level cursor within the highlighted option. null = the
267
+ * option has no levels, or has no default to mark. */
268
+ readonly level: number | null;
234
269
  }
235
270
  /** What was picked: a listed option by INDEX (never a label the caller
236
271
  * would have to re-match against its own list), or typed text. */
@@ -322,10 +357,12 @@ export type PanelVerdict = {
322
357
  }
323
358
  /** TUI2-R2 \u2463: the pick's verdict \u2014 the chosen index or the typed
324
359
  * text. Only pick views ever produce it, so the approval path's
325
- * switch is untouched. */
360
+ * switch is untouched. OR-7 adds the second axis beside it: the
361
+ * level INDEX, absent when the option had no levels or marked none. */
326
362
  | {
327
363
  readonly action: "picked";
328
364
  readonly result: PickResult;
365
+ readonly level?: number;
329
366
  };
330
367
  /** The bound panel state the compositor reads — the editor owns the
331
368
  * phase/selection state machine and the key routing; the compositor
@@ -432,7 +469,7 @@ export declare function pickLead(view: PanelView, state: PickRuntime): string;
432
469
  /** The status row's left text \u2014 the CALLER's, because only the caller
433
470
  * knows whether a run is paused behind this panel. */
434
471
  export declare function pickStatus(view: PanelView): string;
435
- export declare function pickAffordance(state: PickRuntime): string;
472
+ export declare function pickAffordance(state: PickRuntime, axis?: boolean): string;
436
473
  /** Compose a pick view. The flavor/name/title/args fields exist for the
437
474
  * approval path and are given inert values here \u2014 the pick block
438
475
  * reads none of them. */
@@ -164,6 +164,58 @@ function segmentRisk(segment) {
164
164
  }
165
165
  return null;
166
166
  }
167
+ /** The level cursor, CORRECTED at read time: never off the end, never on
168
+ * a forbidden index, and never invented where the caller supplied none.
169
+ *
170
+ * One rule instead of two. The alternative \u2014 letting the cursor rest on
171
+ * a forbidden index and refusing at enter \u2014 needs a second mechanism to
172
+ * say why nothing happened, and a key that silently does nothing is the
173
+ * defect this panel already fixed once (DC-36). Correcting on read is
174
+ * the discipline the session picker uses for its own selection. */
175
+ export function enabledLevel(o, want) {
176
+ const levels = o?.levels;
177
+ if (levels === undefined || levels.length === 0 || want === null)
178
+ return null;
179
+ const off = new Set(o?.disabled ?? []);
180
+ if (off.size >= levels.length)
181
+ return null; // every level forbidden: nothing to point at
182
+ const clamped = Math.max(0, Math.min(levels.length - 1, want));
183
+ if (!off.has(clamped))
184
+ return clamped;
185
+ // walk outward from the asked-for index, nearest first
186
+ for (let d = 1; d < levels.length; d += 1) {
187
+ const hi = clamped + d;
188
+ if (hi < levels.length && !off.has(hi))
189
+ return hi;
190
+ const lo = clamped - d;
191
+ if (lo >= 0 && !off.has(lo))
192
+ return lo;
193
+ }
194
+ return null;
195
+ }
196
+ /** Where the cursor OPENS on an option: what the caller asked for,
197
+ * corrected. The two callers (the panel opening, and the highlight
198
+ * moving to another row) mean exactly this and nothing else. */
199
+ export function startLevel(o) {
200
+ return enabledLevel(o, o?.level ?? null);
201
+ }
202
+ /** The next enabled index in `dir`, or the current one at the end of the
203
+ * ladder. Forbidden indexes are stepped OVER, never landed on. */
204
+ export function stepLevel(o, from, dir) {
205
+ const levels = o.levels;
206
+ if (levels === undefined || levels.length === 0)
207
+ return null;
208
+ // the first press on a row that marks nothing enters the ladder at
209
+ // its near end rather than guessing a middle.
210
+ if (from === null)
211
+ return enabledLevel(o, dir === 1 ? 0 : levels.length - 1);
212
+ const off = new Set(o.disabled ?? []);
213
+ for (let i = from + dir; i >= 0 && i < levels.length; i += dir) {
214
+ if (!off.has(i))
215
+ return i;
216
+ }
217
+ return from;
218
+ }
167
219
  /** The rule line's text — the why-asked line (the R3 chain): the tool
168
220
  * name, the first non-abstain speaker, the fix hint (the §3.5 table,
169
221
  * code-accented). The simple flavor carries the CLI's own question
@@ -516,7 +568,14 @@ export function pickBlockRows(view, state, W, maxRows) {
516
568
  // content were scrolled irreversibly into the scrollback every time
517
569
  // `/model` opened on a tight screen. The `+N more` row is a sixth
518
570
  // when it appears, so it is paid for too.
519
- const chrome = 5 + (spec.options.length > Math.min(Math.max(1, maxRows - 5), PICK_MAX) ? 1 : 0);
571
+ // OR-7: the level strip is a row of its own under the highlighted
572
+ // option, so it is chrome and it is paid for here. Inline after the
573
+ // note was the coordination note's shape and it does not fit: five
574
+ // levels need 42 columns and the note column has 48 at width 100,
575
+ // which it already spends on `profile: <name>`. A second axis that
576
+ // truncates on a normal terminal is not a second axis.
577
+ const strip = spec.options[state.cursor]?.levels !== undefined && state.phase === "options" ? 1 : 0;
578
+ const chrome = 5 + strip + (spec.options.length > Math.min(Math.max(1, maxRows - 5 - strip), PICK_MAX) ? 1 : 0);
520
579
  const budget = Math.max(1, maxRows - chrome);
521
580
  const shown = spec.options.slice(0, Math.min(budget, PICK_MAX));
522
581
  // R2: the note takes a COLUMN, not three spaces after a label of
@@ -548,13 +607,35 @@ export function pickBlockRows(view, state, W, maxRows) {
548
607
  if (spec.options.length > shown.length) {
549
608
  rows.push(` ${cutLine(`${p.dim} \u2514 +${spec.options.length - shown.length} more \u2014 /model <name> takes any of them${p.reset}`, room)}`);
550
609
  }
610
+ const axis = shown[state.cursor];
611
+ if (strip === 1 && axis?.levels !== undefined) {
612
+ const off = new Set(axis.disabled ?? []);
613
+ // the RUNTIME cursor, not the option's starting index: the option
614
+ // says where the cursor opens, the state says where it is now.
615
+ // Reading the option here rendered a bracket that never moved
616
+ // while the state underneath it did — the silent movement this
617
+ // whole axis exists to replace.
618
+ const cur = enabledLevel(axis, state.level);
619
+ // the bracket means THE CURSOR IS HERE. It used to mean "the
620
+ // registry default" in the note's text form; a row whose
621
+ // default is null now marks nothing at all rather than
622
+ // promoting the first level into one.
623
+ const cells = axis.levels.map((l, i) => {
624
+ const text = escapeTerminal(l);
625
+ if (off.has(i))
626
+ return `${p.dim}${text}${p.reset}`;
627
+ return i === cur ? `${p.bold}[${text}]${p.reset}` : text;
628
+ });
629
+ const note = axis.levelNote === undefined ? "" : ` ${p.dim}\u2014 ${escapeTerminal(axis.levelNote)}${p.reset}`;
630
+ rows.push(` ${cutLine(`${p.dim}effort: ${p.reset}${cells.join(`${p.dim} \u00b7 ${p.reset}`)}${note}`, room)}`);
631
+ }
551
632
  }
552
633
  const typing = state.phase === "custom";
553
634
  if (spec.typeHint !== undefined) {
554
635
  const tText = cutLine(`${typing ? p.bold : ""}${typing ? "\u2192" : " "} t ${p.reset}${p.dim}${escapeTerminal(spec.typeHint)}${p.reset}`, room);
555
636
  rows.push(typing ? selectionBar(tText, visibleWidth(tText), W) : ` ${tText}`);
556
637
  }
557
- rows.push(` ${p.dim}${cutLine(pickAffordance(state), room)}${p.reset}`);
638
+ rows.push(` ${p.dim}${cutLine(pickAffordance(state, spec.options[state.cursor]?.levels !== undefined), room)}${p.reset}`);
558
639
  rows.push(`${p.dim}${"\u2500".repeat(Math.max(0, W))}${p.reset}`);
559
640
  return rows;
560
641
  }
@@ -579,14 +660,19 @@ export function pickLead(view, state) {
579
660
  export function pickStatus(view) {
580
661
  return view.statusText;
581
662
  }
582
- export function pickAffordance(state) {
663
+ export function pickAffordance(state, axis = false) {
583
664
  // DC-36 — the row NAMES the arrows. TUI2-R2 ④ bound ↑↓ to the pick's
584
665
  // cursor and the keys sheet has said `panels: ↑↓ move` ever since,
585
666
  // but this row — the one a human is actually looking at while the
586
667
  // panel is up — advertised only the digits. The owner read it as
587
668
  // "type the answer", which is the same lesson DC-30 filed: a hint
588
669
  // that omits the gesture is why the gesture goes unused.
589
- return state.phase === "custom" ? "enter commits \u00b7 esc backs out" : "\u2191\u2193 move \u00b7 digits pick \u00b7 \u23ce confirms \u00b7 esc";
670
+ // OR-7 names \u2190\u2192 here for the same reason DC-36 named \u2191\u2193: a gesture
671
+ // this row omits is a gesture that goes unused. It appears only when
672
+ // there is a second axis to walk.
673
+ if (state.phase === "custom")
674
+ return "enter commits \u00b7 esc backs out";
675
+ return axis ? "\u2191\u2193 move \u00b7 \u2190\u2192 effort \u00b7 digits pick \u00b7 \u23ce confirms \u00b7 esc" : "\u2191\u2193 move \u00b7 digits pick \u00b7 \u23ce confirms \u00b7 esc";
590
676
  }
591
677
  /** Compose a pick view. The flavor/name/title/args fields exist for the
592
678
  * approval path and are given inert values here \u2014 the pick block
@@ -99,6 +99,7 @@ export type BodyCell = {
99
99
  text: string;
100
100
  done: boolean;
101
101
  turn: number;
102
+ folded?: boolean;
102
103
  } | {
103
104
  kind: "tool";
104
105
  name: string;
@@ -30,7 +30,7 @@ import { displayWidth, visibleWidth, widthCut } from "./width.js";
30
30
  // KEY_BINDINGS). strings.js imports only render/width here, so this edge
31
31
  // adds no cycle.
32
32
  import { displayVerb } from "./strings.js";
33
- import { bannerLines, breathFrame, cutLine, escapeTerminal, foldThinking, foldResult, renderTerminalGap, renderToolSummary, toolTarget, kUnit, palette, currentGround, } from "./render.js";
33
+ import { bannerLines, breathFrame, cutLine, escapeTerminal, foldThinking, foldThinkingRow, foldResult, renderTerminalGap, renderToolSummary, toolTarget, kUnit, palette, currentGround, } from "./render.js";
34
34
  // TUI2-MD: the markdown renderer's surface reaches the tui through this
35
35
  // module (the tui's components shim re-exports it) — one import edge,
36
36
  // and it points one way: md.ts measures with the width authority, never
@@ -370,6 +370,17 @@ class ThinkingBlock {
370
370
  const text = escapeTerminal(this.cell.text).trim();
371
371
  if (text === "")
372
372
  return [];
373
+ // §2.3 — ctrl+t folded this block. The row is the pipe's own fold,
374
+ // fitted: `foldThinkingRow` is the single source of the shape and
375
+ // with unlimited room it IS `foldThinking`, so thinking has two
376
+ // renderings in the product and not three. It is width-aware here
377
+ // because a row must measure ≤ W and the pipe's line does not —
378
+ // cutting it from the right would take the `/think` suffix, which
379
+ // is the one part of a folded block that says how to read the rest.
380
+ // It keeps the block's own indent, so a folded block sits in the
381
+ // column an unfolded one does (DC-47).
382
+ if (this.cell.folded)
383
+ return [`${THINK_COL}${foldThinkingRow(this.cell.text, Math.max(1, W - THINK_COL.length))}`];
373
384
  // DC-47 — THINKING GOES ONE LEVEL DEEPER THAN PROSE, and the
374
385
  // reason is a law rather than a taste.
375
386
  //
package/dist/render.d.ts CHANGED
@@ -161,6 +161,21 @@ export declare function escapeTerminal(text: string): string;
161
161
  * strategy is presentation-independent.
162
162
  */
163
163
  export declare function foldThinking(block: string): string;
164
+ /** §2.3 — the same fold, as a ROW that fits `room` columns.
165
+ *
166
+ * A row must measure ≤ W (invariant ①), and the pipe's line does not:
167
+ * `…` + 100 characters + " (N chars · /think)" is about 122 columns, so
168
+ * on an 80-column terminal the frame's cut takes the SUFFIX — which is
169
+ * the affordance, the one part of a folded block that says how to read
170
+ * the rest of it. Cutting from the right removes exactly the thing the
171
+ * fold exists to leave behind.
172
+ *
173
+ * So the suffix is reserved FIRST and the head takes what is left. The
174
+ * vocabulary is unchanged — the leading `…`, the character count, the
175
+ * `/think` route — and with unlimited room the result is byte-for-byte
176
+ * the line the pipe has always written, which is what keeps the two
177
+ * renderings one shape rather than two. */
178
+ export declare function foldThinkingRow(block: string, room: number): string;
164
179
  /** v2b — the [result] echo truncates at 160 chars + a /last hint. */
165
180
  export declare function foldResult(content: string): string;
166
181
  /**
package/dist/render.js CHANGED
@@ -113,10 +113,34 @@ export function escapeTerminal(text) {
113
113
  * strategy is presentation-independent.
114
114
  */
115
115
  export function foldThinking(block) {
116
+ // the PIPE's line: no room limit, so the row path below reproduces
117
+ // today's bytes exactly and this stays the one source of the shape.
118
+ return `${foldThinkingRow(block, Number.POSITIVE_INFINITY)}\n`;
119
+ }
120
+ /** §2.3 — the same fold, as a ROW that fits `room` columns.
121
+ *
122
+ * A row must measure ≤ W (invariant ①), and the pipe's line does not:
123
+ * `…` + 100 characters + " (N chars · /think)" is about 122 columns, so
124
+ * on an 80-column terminal the frame's cut takes the SUFFIX — which is
125
+ * the affordance, the one part of a folded block that says how to read
126
+ * the rest of it. Cutting from the right removes exactly the thing the
127
+ * fold exists to leave behind.
128
+ *
129
+ * So the suffix is reserved FIRST and the head takes what is left. The
130
+ * vocabulary is unchanged — the leading `…`, the character count, the
131
+ * `/think` route — and with unlimited room the result is byte-for-byte
132
+ * the line the pipe has always written, which is what keeps the two
133
+ * renderings one shape rather than two. */
134
+ export function foldThinkingRow(block, room) {
116
135
  const p = palette();
117
136
  const trimmed = escapeTerminal(block.trim());
118
137
  const truncated = trimmed.length > 100;
119
- return `${p.dim}…${trimmed.slice(0, 100)}${truncated ? ` (${block.length} chars · /think)` : ""}${p.reset}\n`;
138
+ const suffix = truncated ? ` (${block.length} chars · /think)` : "";
139
+ const head = Number.isFinite(room)
140
+ ? // the leading … costs one column, the suffix costs its own width
141
+ widthCut(trimmed.slice(0, 100), Math.max(1, room - 1 - displayWidth(suffix)))
142
+ : trimmed.slice(0, 100);
143
+ return `${p.dim}…${head}${suffix}${p.reset}`;
120
144
  }
121
145
  /** v2b — the [result] echo truncates at 160 chars + a /last hint. */
122
146
  export function foldResult(content) {
package/dist/strings.js CHANGED
@@ -440,6 +440,21 @@ export function helpRows() {
440
440
  ["/compact", "summarize the older conversation to free context"],
441
441
  ["/clear", "start a fresh conversation (the old session stays resumable)"],
442
442
  ["/resume", "switch to another session; /resume <id> goes directly"],
443
+ // §2.5: the conversation is untouched — this rereads what kiso was
444
+ // built with, not what it has said.
445
+ ["/reload", "reread extensions, skills and config into this session"],
446
+ // §2.2: the two shell gestures and their one escape. They sit
447
+ // beside the slash commands because that is what a reader is
448
+ // looking for when they look here, even though `!` is not one.
449
+ // §2.3: the switch belongs beside ctrl+o's job, and a gesture the
450
+ // sheet does not name is a gesture nobody uses (DC-30, DC-36).
451
+ ["ctrl+t", "fold the thinking blocks, and fold them back"],
452
+ // §2.4: the composer, in your own editor. It names the variables
453
+ // because that is what a reader has to set for it to work.
454
+ ["ctrl+g", "edit the composer in $VISUAL or $EDITOR — the text comes back unsent"],
455
+ ["!<cmd>", "run a shell command and send it with its output as your turn"],
456
+ ["!!<cmd>", "run one and show it here only — the model never sees it"],
457
+ ["\\!", "send a line that really starts with ! (the only escape)"],
443
458
  ["exit", "leave the session"],
444
459
  // TUI2-R1 (D): the SENTENCE is deliberately unchanged. Deriving it
445
460
  // from KEY_BINDINGS would be an improvement and it would also move
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-tui-cells",
3
- "version": "0.31.0",
3
+ "version": "0.32.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",