@vincemakes/kiso-tui 0.16.2 → 0.16.4

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.
@@ -63,10 +63,15 @@ export declare function askKey(spec: AskSpec, state: AskRuntime, key: string): A
63
63
  * (clearing its picks) and the walk advances. An empty line is a
64
64
  * no-op back to the options — nothing is recorded. */
65
65
  export declare function askCommitCustom(spec: AskSpec, state: AskRuntime, text: string): AskStep;
66
+ /** The description column, computed over the WHOLE list: two cells past
67
+ * the widest label, never past the half-width, and 0 (meaning "no
68
+ * column, use the em dash") when what is left would not hold a
69
+ * readable description. */
70
+ export declare function askDescriptionStop(q: AskQuestion, W: number): number;
66
71
  /** The ask block's rows — the question as the rule line, the header (or
67
72
  * the counter) as the title, the options as the body, and the
68
- * type-your-own line last. The shape is the W21 block's: gutter, rule,
69
- * title, divider, body, affordance, corner. */
73
+ * type-your-own line last. R2: the shape is the RULE's rule,
74
+ * title, header, body, affordance, rule. */
70
75
  export declare function askBlockRows(view: PanelView, state: AskRuntime, W: number, maxRows: number): string[];
71
76
  /** The status row's right-hand hint — the phase's keys. */
72
77
  export declare function askAffordance(state: AskRuntime): string;
package/dist/ask-panel.js CHANGED
@@ -28,7 +28,7 @@
28
28
  * hand-off, no timeout and no countdown.
29
29
  */
30
30
  import { panelBlockLayout, panelAffordance as basePanelAffordance, panelBlockRows as basePanelBlockRows, panelLead as basePanelLead, panelLeadPlain as basePanelLeadPlain, panelStatus as basePanelStatus, pickAffordance, pickBlockRows, pickLead, pickLeadPlain, pickStatus, } from "./approval-panel.js";
31
- import { cutLine } from "@vincemakes/kiso-tui-cells/components";
31
+ import { cutLine, selectionBar, visibleWidth, widthCut } from "@vincemakes/kiso-tui-cells/components";
32
32
  import { escapeTerminal, palette } from "./render.js";
33
33
  /** The schema's own bounds — the registry refuses anything outside them
34
34
  * (extensions/ask validates; these are the numbers it validates to). */
@@ -181,34 +181,101 @@ export function askCommitCustom(spec, state, text) {
181
181
  return advance(spec, { ...state, custom, picks, phase: "options" });
182
182
  }
183
183
  // ── the rows ─────────────────────────────────────────────────────────
184
- /** One option row: the number, the selection mark, the label, and the
185
- * description after an em dash. The row CUTS (never folds) the
186
- * block's height is its row count, the W20 discipline. */
187
- function optionRow(o, n, picked, cursor, multi, W) {
184
+ /**
185
+ * One option row: the cursor arrow, the number, the selection mark, the
186
+ * label, and the description in its own RIGHT COLUMN. The row CUTS
187
+ * (never folds) the block's height is its row count, the W20
188
+ * discipline.
189
+ *
190
+ * R2 (design §7.5) — two changes, both about what survives:
191
+ *
192
+ * - the cursor carries `→` as well as the bar. The bar is the loud
193
+ * signal and the arrow is the durable one: strip every escape and the
194
+ * row still says which option the cursor is on, which is law 1.3's
195
+ * test applied to a selection rather than to an outcome.
196
+ * - the description leaves the em dash and takes a column. Run-on
197
+ * `label — description` makes the labels — the thing being chosen
198
+ * between — unscannable, because each one starts at a column the
199
+ * previous row's length decided. `stop` is computed ONCE over the
200
+ * whole option list by the caller, so the column is a property of
201
+ * the list and not of the row.
202
+ *
203
+ * A narrow block has no room for two columns; `stop` arrives as 0 and
204
+ * the em-dash form is what it degrades to.
205
+ */
206
+ function optionRow(o, n, picked, cursor, multi, W, stop = 0) {
188
207
  const p = palette();
189
208
  const mark = multi ? (picked ? "◉" : "◯") : picked ? "◉" : " ";
190
- const head = `${cursor ? p.bold : ""} ${n} ${mark} ${escapeTerminal(o.label)}${p.reset}`;
191
- const body = o.description === undefined ? "" : `${p.dim}${escapeTerminal(o.description)}${p.reset}`;
192
- return cutLine(`${head}${body}`, Math.max(1, W - 2));
209
+ const lead = askOptionLead(o, n, mark, cursor);
210
+ const head = `${cursor ? p.bold : ""}${lead}${p.reset}`;
211
+ const room = Math.max(1, W - 2);
212
+ let body;
213
+ if (o.description === undefined)
214
+ body = "";
215
+ else if (stop > 0) {
216
+ const desc = widthCut(escapeTerminal(o.description), Math.max(0, room - stop));
217
+ body = `${" ".repeat(Math.max(1, stop - visibleWidth(lead)))}${p.dim}${desc}${p.reset}`;
218
+ }
219
+ else
220
+ body = `${p.dim} — ${escapeTerminal(o.description)}${p.reset}`;
221
+ const text = cutLine(`${head}${body}`, Math.max(1, W - 2));
222
+ // R2: the cursor is a FULL-ROW bar, the same one the approval panel
223
+ // has had since R1.5 ⑧ — "a two-cell marker in an eighty-column row is
224
+ // a selection you have to hunt for". This panel was carrying bold and
225
+ // nothing else, which on a white terminal is close to invisible. One
226
+ // ruling, applied in the second place it was always about.
227
+ return cursor ? selectionBar(text, visibleWidth(text), W) : ` ${text}`; // R2: the frame is a rule, so a row is just indented
228
+ }
229
+ /** The row's left span, PLAIN — the one place its shape is written, so
230
+ * the column arithmetic below and the row above cannot disagree about
231
+ * how wide it is. The arrow's cell is spent on every row so the digit
232
+ * column does not move as the cursor walks. */
233
+ function askOptionLead(o, n, mark, cursor) {
234
+ return `${cursor ? "→" : " "} ${n} ${mark} ${escapeTerminal(o.label)}`;
235
+ }
236
+ /** Below this many cells a right column is not a column, it is a
237
+ * three-word stub — the em-dash form carries more of the sentence. */
238
+ const ASK_DESC_MIN = 18;
239
+ /** The description column, computed over the WHOLE list: two cells past
240
+ * the widest label, never past the half-width, and 0 (meaning "no
241
+ * column, use the em dash") when what is left would not hold a
242
+ * readable description. */
243
+ export function askDescriptionStop(q, W) {
244
+ const multi = q.multiSelect === true;
245
+ if (!q.options.some((o) => o.description !== undefined))
246
+ return 0;
247
+ const widest = Math.max(...q.options.map((o, i) => visibleWidth(askOptionLead(o, i + 1, multi ? "◯" : " ", false))));
248
+ const room = Math.max(1, W - 2);
249
+ const stop = widest + 2;
250
+ if (stop > Math.floor(room / 2) || room - stop < ASK_DESC_MIN)
251
+ return 0;
252
+ return stop;
193
253
  }
194
254
  /** The ask block's rows — the question as the rule line, the header (or
195
255
  * the counter) as the title, the options as the body, and the
196
- * type-your-own line last. The shape is the W21 block's: gutter, rule,
197
- * title, divider, body, affordance, corner. */
256
+ * type-your-own line last. R2: the shape is the RULE's rule,
257
+ * title, header, body, affordance, rule. */
198
258
  export function askBlockRows(view, state, W, maxRows) {
199
259
  const p = palette();
200
260
  const spec = view.ask;
201
261
  const q = spec.questions[state.qIndex];
202
262
  const multi = q.multiSelect === true;
203
- const gutter = `${p.dim}│${p.reset} `;
204
263
  const counter = spec.questions.length > 1 ? `${p.dim} ‹ ${state.qIndex + 1}/${spec.questions.length} ›${p.reset}` : "";
205
264
  const rows = [];
206
- rows.push(`${gutter}${cutLine(`${p.bold}${escapeTerminal(q.question)}${p.reset}${counter}`, Math.max(1, W - 2))}`);
265
+ // R2: the same dashed rule the composer and the approval panel use.
266
+ rows.push(`${p.dim}${"\u254c".repeat(Math.max(0, W))}${p.reset}`);
267
+ rows.push(` ${cutLine(`${p.bold}${escapeTerminal(q.question)}${p.reset}${counter}`, Math.max(1, W - 2))}`);
207
268
  const header = q.header === undefined ? "the question" : escapeTerminal(q.header.slice(0, ASK_HEADER_CAP));
208
- rows.push(`${gutter}${cutLine(`${p.dim}${header}${p.reset}`, Math.max(1, W - 2))}`);
209
- rows.push(cutLine(`${p.dim}─ ${multi ? "pick any space toggles" : "pick one"} ─${p.reset}`, Math.max(1, W - 2)));
269
+ // R2: the divider row is gone (the opening rule says a block starts
270
+ // here), but the multi-select gesture it carried is INFORMATION and
271
+ // rides the header instead — dropping it would have been a regression
272
+ // wearing a restyle's clothes.
273
+ const gesture = multi ? `${p.dim} · pick any — space toggles${p.reset}` : "";
274
+ rows.push(` ${cutLine(`${p.dim}${header}${p.reset}${gesture}`, Math.max(1, W - 2))}`);
275
+ rows.push("");
210
276
  const picks = state.picks[state.qIndex] ?? [];
211
- const body = q.options.map((o, i) => `${gutter}${optionRow(o, i + 1, picks.includes(i), state.cursor === i, multi, W)}`);
277
+ const stop = askDescriptionStop(q, W);
278
+ const body = q.options.map((o, i) => optionRow(o, i + 1, picks.includes(i), state.cursor === i, multi, W, stop));
212
279
  // REL-0152-D3: the row is part of the list, so it carries the same
213
280
  // cursor affordance the options do. Dim-always made a reachable row
214
281
  // look like a footnote.
@@ -220,14 +287,22 @@ export function askBlockRows(view, state, W, maxRows) {
220
287
  // like nothing happened. The placeholder is dim and the answer is
221
288
  // not: the two can never be mistaken for each other.
222
289
  const typingHere = state.phase === "custom";
223
- body.push(`${gutter}${cutLine(typed !== null && typed !== undefined
224
- ? `${onCustom || typingHere ? p.bold : ""} t ${escapeTerminal(typed)}${p.reset}`
290
+ // R2: the custom row is one of the list's rows, so it wears the
291
+ // list's cursor the arrow AND the bar. It was carrying bold alone,
292
+ // which is the exact invisibility §7.5 was written about, on the one
293
+ // row a keyboard user reaches last.
294
+ const customLead = `${onCustom ? "→" : " "} t `;
295
+ const customText = cutLine(typed !== null && typed !== undefined
296
+ ? `${onCustom || typingHere ? p.bold : ""}${customLead}◉ ${escapeTerminal(typed)}${p.reset}`
225
297
  : typingHere
226
- ? `${p.bold} t ▸${p.reset} ${p.dim}type your answer — enter sends, esc backs out${p.reset}`
227
- : `${onCustom ? p.bold : p.dim} t type your own answer${p.reset}`, Math.max(1, W - 2))}`);
298
+ ? `${p.bold}${customLead}▸${p.reset} ${p.dim}type your answer — enter sends, esc backs out${p.reset}`
299
+ : `${onCustom ? p.bold : p.dim}${customLead} type your own answer${p.reset}`, Math.max(1, W - 2));
300
+ body.push(onCustom ? selectionBar(customText, visibleWidth(customText), W) : ` ${customText}`);
228
301
  // the bounded block: the options fold nothing and cut individually,
229
302
  // so the cap drops whole rows with the W21 notice row.
230
- const budget = Math.max(1, maxRows - 5);
303
+ // R2: SIX rows of frame — the block opens with a rule as well as
304
+ // closing with one, and the divider became a blank.
305
+ const budget = Math.max(1, maxRows - 6);
231
306
  if (body.length > budget) {
232
307
  const kept = Math.max(0, budget - 1);
233
308
  rows.push(...body.slice(0, kept));
@@ -236,16 +311,13 @@ export function askBlockRows(view, state, W, maxRows) {
236
311
  else {
237
312
  rows.push(...body);
238
313
  }
239
- rows.push(`${gutter}${p.dim}${askAffordance(state)}${p.reset}`);
240
- // TUI2-R1.5 11 (VD-13), shared with the approval panel: a real bottom RULE, in the block's own edge
241
- // vocabulary the same box-drawing run its divider already uses
242
- // anchored at the gutter column. It used to be `\u2514 `: a two-cell stub
243
- // floating at column 1, with no rule running from it and no corner
244
- // above it to answer. Worse, `\u2514 ` is the cut-notice prefix everywhere
245
- // else in the product, so a CAPPED panel emitted two elbow rows in a
246
- // row meaning entirely different things. The rule reads as an edge,
247
- // and the cut notice above it reads as a notice.
248
- rows.push(`${p.dim}\u2514${"\u2500".repeat(Math.max(0, W - 1))}${p.reset}`);
314
+ rows.push(` ${p.dim}${askAffordance(state)}${p.reset}`);
315
+ // R2, shared with the approval panel: the block closes with the SAME
316
+ // dashed rule it opened with, and the same one the composer uses.
317
+ // TUI2-R1.5 had already replaced a two-cell `\u2514 ` stub with a
318
+ // real rule for the reason that stub read as the cut-notice prefix it
319
+ // collides with; this keeps that finding and only changes which rule.
320
+ rows.push(`${p.dim}${"\u254c".repeat(Math.max(0, W))}${p.reset}`);
249
321
  return rows;
250
322
  }
251
323
  /** The status row's right-hand hint — the phase's keys. */
@@ -256,8 +328,15 @@ export function askAffordance(state) {
256
328
  }
257
329
  /** The status row's left text — the ask's own line, with the walk. */
258
330
  export function askStatus(view, state) {
331
+ // R2: the status says the DURABLE thing. Every other agent's option
332
+ // panel dies with its process; this one is a fact in the event log, so
333
+ // killing kiso and coming back brings the question with it and never
334
+ // re-asks an answered one (ADR-0051 §8). The screen had never said so.
335
+ // It costs a status string and it is the cheapest claim in the product
336
+ // that no competitor can copy without building the log first.
259
337
  const total = view.ask.questions.length;
260
- return total > 1 ? `▸ question ${state.qIndex + 1} of ${total}` : "a question for you";
338
+ const where = total > 1 ? `question ${state.qIndex + 1} of ${total}` : "a question for you";
339
+ return `⏸ ${where} · answers are durable facts`;
261
340
  }
262
341
  /** The input row's lead: the digit lead while picking, the typing lead
263
342
  * in the custom phase (the rule-input phase's shape, reused). */
@@ -347,7 +426,7 @@ export function askView(spec) {
347
426
  name: "ask_user",
348
427
  title: first.header ?? first.question,
349
428
  speaker: "kiso",
350
- statusText: " a question for you",
429
+ statusText: " a question for you",
351
430
  args: { kind: "text", lines: askDeclineList(spec) },
352
431
  fallbackQuestion: `⚠ ${escapeTerminal(first.question)} — this terminal cannot show the option panel; the question is declined `,
353
432
  ask: spec,
@@ -157,7 +157,15 @@ export declare function atPanelRows(state: {
157
157
  selected: number;
158
158
  capped: boolean;
159
159
  }, W: number): string[];
160
- /** TUI2-R1.5 ⑦(b) — the one-row dim header that turns a band into a
161
- * surface. Shared by the @ picker and the / menu so the two read the
162
- * same way. */
160
+ /**
161
+ * TUI2-R1.5 ⑦(b) the one row that turns a band into a surface. Shared
162
+ * by the @ picker, the / menu and the session picker so the three read
163
+ * the same way.
164
+ *
165
+ * R2: the label rides the RULE. It was a bare dim word on its own row —
166
+ * which said "a surface starts here" only if you already knew that, and
167
+ * it spent a row saying it. The dashed rule is the edge vocabulary the
168
+ * composer and every panel now share, so a band opens the way everything
169
+ * else does and the label tells you WHICH band in the same row.
170
+ */
163
171
  export declare function bandHeader(label: string, W: number): string;
package/dist/at-picker.js CHANGED
@@ -13,7 +13,7 @@
13
13
  * on the machine's locale).
14
14
  */
15
15
  import { escapeTerminal, palette } from "./render.js";
16
- import { visibleWidth, widthCut } from "./components.js";
16
+ import { selectionBar, visibleWidth, widthCut } from "./components.js";
17
17
  /**
18
18
  * KC3 §5 — the ONE cap. The file list is computed per open with no
19
19
  * index and no watcher, so its cost is bounded here rather than
@@ -212,11 +212,11 @@ export function atRow(match, selected, W) {
212
212
  // visible from anywhere on the line. Mono discipline: reverse video,
213
213
  // no new colours.
214
214
  //
215
- // The inner spans close with rvEnd (SGR 27), never SGR 0 — a reset
216
- // inside the bar would punch a hole in it. `painted`'s bold marks and
217
- // the dim suffix both end in SGR 0, so the bar is re-opened after each.
218
- const inner = `${painted.replaceAll(p.reset, `${p.reset}${p.rv}`)}${suffix === "" ? "" : `${p.dim}${suffix}${p.reset}${p.rv}`}`;
219
- return `${p.rv} ${inner}${" ".repeat(Math.max(0, W - width - 2))} ${p.rvEnd}`;
215
+ // R2: the composition is selectionBar's one copy, and with it the
216
+ // §2.1 rule that dim never sits on the wash (the directory column was
217
+ // rendering grey-on-grey inside the bar, i.e. the half of the row the
218
+ // selection was meant to help you read).
219
+ return selectionBar(`${painted}${suffix === "" ? "" : `${p.dim}${suffix}${p.reset}`}`, width, W);
220
220
  }
221
221
  /**
222
222
  * KC3 §4 — the counter row: `(n/total)`, where n is the 1-based
@@ -252,10 +252,19 @@ export function atPanelRows(state, W) {
252
252
  rows.push(atCounterRow(state.selected, state.matches.length, state.capped, W));
253
253
  return rows;
254
254
  }
255
- /** TUI2-R1.5 ⑦(b) — the one-row dim header that turns a band into a
256
- * surface. Shared by the @ picker and the / menu so the two read the
257
- * same way. */
255
+ /**
256
+ * TUI2-R1.5 ⑦(b) the one row that turns a band into a surface. Shared
257
+ * by the @ picker, the / menu and the session picker so the three read
258
+ * the same way.
259
+ *
260
+ * R2: the label rides the RULE. It was a bare dim word on its own row —
261
+ * which said "a surface starts here" only if you already knew that, and
262
+ * it spent a row saying it. The dashed rule is the edge vocabulary the
263
+ * composer and every panel now share, so a band opens the way everything
264
+ * else does and the label tells you WHICH band in the same row.
265
+ */
258
266
  export function bandHeader(label, W) {
259
267
  const p = palette();
260
- return `${p.dim}${widthCut(label, Math.max(1, W))}${p.reset}`;
268
+ const head = `\u254c\u254c\u254c ${label} `;
269
+ return `${p.dim}${widthCut(`${head}${"\u254c".repeat(Math.max(1, W - head.length))}`, Math.max(1, W))}${p.reset}`;
261
270
  }
@@ -52,7 +52,7 @@ export interface AtPanelState {
52
52
  readonly selected: number;
53
53
  readonly capped: boolean;
54
54
  }
55
- import { type ResumeMeta } from "./render.js";
55
+ import { type BannerMeta, type ResumeMeta } from "./render.js";
56
56
  /** The cursor marker — an APC private sequence the focus component
57
57
  * embeds at the edit position; the compositor strips it and moves
58
58
  * relatively (it never reaches the terminal). */
@@ -171,7 +171,7 @@ export declare class Body {
171
171
  * list re-gates with the tier (BIG only) and re-times with the
172
172
  * frame. The inactive path keeps the historical bytes (no resume —
173
173
  * the pipe contract). */
174
- banner(version: string, extensionsText: string, resume?: ResumeMeta[]): void;
174
+ banner(version: string, extensionsText: string, resume?: ResumeMeta[], meta?: BannerMeta | undefined): void;
175
175
  raw(lines: string[], wrap?: "words"): void;
176
176
  /** The last COMPLETE thinking block, for /think. */
177
177
  lastThinking(): string | null;
@@ -239,6 +239,20 @@ export declare class Body {
239
239
  * that choice here, and has to pay carefully.
240
240
  */
241
241
  onResize(): void;
242
+ /**
243
+ * DC-3 — the ground arrived, so every colour on the held screen is
244
+ * stale.
245
+ *
246
+ * The terminal answers `OSC 11` a few milliseconds after startup, by
247
+ * which time the first frame is already painted with the no-ground
248
+ * palette. Rather than delay the opening to wait for an answer that
249
+ * may never come, the frame is painted at once and repainted when the
250
+ * answer lands. Invalidating the held screen is exactly what a resize
251
+ * does, for exactly the same reason — every row's bytes are wrong —
252
+ * so it rides the settle the resize already owns and costs one
253
+ * repaint, once per session.
254
+ */
255
+ onGroundChange(): void;
242
256
  /** W18: the status row's right-aligned hint is part of the status
243
257
  * state — the compacting row passes "esc to cancel" (the affordance
244
258
  * must survive repaints). */
@@ -295,6 +309,8 @@ export declare class Dock {
295
309
  enter(): void;
296
310
  exit(): void;
297
311
  onResize(): void;
312
+ /** DC-3 — the terminal answered what its background is; repaint. */
313
+ onGroundChange(): void;
298
314
  /** TUI2-R3v2 ②: where the last frame put the panel's clickable option
299
315
  * rows (absolute screen rows). The editor binds this and does no row
300
316
  * arithmetic of its own. */
@@ -667,7 +667,7 @@ export class Body {
667
667
  this.#closeOpenThinking();
668
668
  this.#closeOpenText();
669
669
  const p = palette();
670
- this.#write(`${p.bold}▞${p.reset} ${escapeTerminal(header)}\n`);
670
+ this.#write(`${p.bold}✦${p.reset} ${escapeTerminal(header)}\n`);
671
671
  const glyphOf = (status) => (status === "pending" ? "□" : status === "active" ? "▖" : "▣");
672
672
  for (const item of items)
673
673
  this.#write(` ${glyphOf(item.status)} ${escapeTerminal(item.text)}\n`);
@@ -706,21 +706,21 @@ export class Body {
706
706
  * list re-gates with the tier (BIG only) and re-times with the
707
707
  * frame. The inactive path keeps the historical bytes (no resume —
708
708
  * the pipe contract). */
709
- banner(version, extensionsText, resume = []) {
709
+ banner(version, extensionsText, resume = [], meta) {
710
710
  if (!this.#isActive()) {
711
711
  this.#closeOpenThinking();
712
712
  this.#closeOpenText();
713
713
  const W = this.#opts.width() || 80; // a 0-size pty falls back
714
714
  const H = this.#opts.height();
715
715
  const p = palette();
716
- for (const r of bannerLines(W, H, version, extensionsText))
716
+ for (const r of bannerLines(W, H, version, extensionsText, [], Date.now(), meta))
717
717
  this.#write(`${p.dim}${r}${p.reset}\n`);
718
718
  this.#write("\n");
719
719
  return;
720
720
  }
721
721
  this.#closeOpenThinking();
722
722
  this.#closeOpenText();
723
- this.#cells.push({ kind: "banner", version, extensionsText, resume, done: true });
723
+ this.#cells.push({ kind: "banner", version, extensionsText, resume, meta, done: true });
724
724
  this.#mark();
725
725
  }
726
726
  raw(lines, wrap) {
@@ -815,11 +815,11 @@ export class Body {
815
815
  // the row showed, then one row per tool with its subjects. The
816
816
  // header keeps W15's shape; only the subject changes.
817
817
  if (cell.rolled.parts !== undefined) {
818
- const header = `${p.bold}▞${p.reset} expanded · ${escapeTerminal(`explored ${exploreCounts(cell.rolled.parts)}`)} · ${back}`;
818
+ const header = `${p.bold}✦${p.reset} expanded · ${escapeTerminal(`explored ${exploreCounts(cell.rolled.parts)}`)} · ${back}`;
819
819
  return { kind: "appended", lines: [header, ...exploreRows(cell.rolled.parts, this.#opts.width())] };
820
820
  }
821
821
  const noun = ROLLUP_NOUN[cell.name] ?? "calls";
822
- const header = `${p.bold}▞${p.reset} expanded · ${escapeTerminal(`${displayVerb(cell.name)} ${cell.rolled.count} ${noun}`)} · ${back}`;
822
+ const header = `${p.bold}✦${p.reset} expanded · ${escapeTerminal(`${displayVerb(cell.name)} ${cell.rolled.count} ${noun}`)} · ${back}`;
823
823
  return {
824
824
  kind: "appended",
825
825
  lines: [header, ...cell.rolled.targets.map((t) => ` ${p.dim}└ ${escapeTerminal(t)}${p.reset}`)],
@@ -835,7 +835,7 @@ export class Body {
835
835
  }
836
836
  const turnsBack = this.#cells.slice(idx + 1).filter((c) => c.kind === "user").length;
837
837
  const p = palette();
838
- const header = `${p.bold}▞${p.reset} expanded · ${escapeTerminal(`${displayVerb(cell.name)} ${toolTarget(cell.name, input)}`)} · ${turnsBack} ${turnsBack === 1 ? "turn" : "turns"} back`;
838
+ const header = `${p.bold}✦${p.reset} expanded · ${escapeTerminal(`${displayVerb(cell.name)} ${toolTarget(cell.name, input)}`)} · ${turnsBack} ${turnsBack === 1 ? "turn" : "turns"} back`;
839
839
  return {
840
840
  kind: "appended",
841
841
  lines: [
@@ -1092,6 +1092,23 @@ export class Body {
1092
1092
  if (this.#resizeTimer.unref !== undefined)
1093
1093
  this.#resizeTimer.unref();
1094
1094
  }
1095
+ /**
1096
+ * DC-3 — the ground arrived, so every colour on the held screen is
1097
+ * stale.
1098
+ *
1099
+ * The terminal answers `OSC 11` a few milliseconds after startup, by
1100
+ * which time the first frame is already painted with the no-ground
1101
+ * palette. Rather than delay the opening to wait for an answer that
1102
+ * may never come, the frame is painted at once and repainted when the
1103
+ * answer lands. Invalidating the held screen is exactly what a resize
1104
+ * does, for exactly the same reason — every row's bytes are wrong —
1105
+ * so it rides the settle the resize already owns and costs one
1106
+ * repaint, once per session.
1107
+ */
1108
+ onGroundChange() {
1109
+ this.#screen = [];
1110
+ this.onResize();
1111
+ }
1095
1112
  /** The one repaint a drag earns, once its signals have stopped. */
1096
1113
  #settleResize() {
1097
1114
  if (!this.#resizePending || !this.#isActive())
@@ -1182,9 +1199,11 @@ export class Body {
1182
1199
  // W23: the frame-derived column — wallL + leadWidth(lead) + cells
1183
1200
  // + 1 — the SAME formula the marker embeds at (the panel lead when
1184
1201
  // the panel owns the row; the old prompt-only math desynced the
1185
- // panel rows' edit column; leadWidth is the ONE authority)
1202
+ // panel rows' edit column; leadWidth is the ONE authority).
1203
+ // R2: wallL is 0 — the box is retired, so the row starts at column
1204
+ // one and the frame's marker and this formula share the constant.
1186
1205
  const lead = panel !== null ? panelLeadOf(panel) : this.#inputPrompt;
1187
- return 3 + leadWidth(lead) + st.cursor;
1206
+ return 1 + leadWidth(lead) + st.cursor;
1188
1207
  }
1189
1208
  /** The old dock's redraw — the editor's onRender target: mark + the
1190
1209
  * scheduler (16ms coalescing — the old sync draw coalesces the same). */
@@ -1903,8 +1922,14 @@ export class Body {
1903
1922
  inserted = true;
1904
1923
  }
1905
1924
  const cw = displayWidth(row[i]);
1906
- if (w + cw > W - 4)
1907
- break; // the cap the two walls' columns
1925
+ // R2: no walls to pay for — but ONE column stays reserved. When
1926
+ // the cursor rests past the content the drawn cell is taken out
1927
+ // of the pad, and a walk that filled to exactly W leaves no pad
1928
+ // to take it from: the row becomes W+1 and invariant ① throws.
1929
+ // The old cap paid for two walls and a space; this pays for the
1930
+ // cursor, which is the only thing still owed a cell.
1931
+ if (w + cw > W - 1)
1932
+ break;
1908
1933
  markerLine += row[i];
1909
1934
  w += cw;
1910
1935
  i += 1;
@@ -1938,8 +1963,8 @@ export class Body {
1938
1963
  const after = markerLine.slice(mi + CURSOR_MARKER.length);
1939
1964
  if (after.length === 0) {
1940
1965
  // the cursor rests past the content — an inverse space,
1941
- // taken OUT of the pad (the walk capped content at W−4,
1942
- // so the pad is 1 and the row still totals W)
1966
+ // taken OUT of the pad (the walk capped content at W, so
1967
+ // the pad absorbs it and the row still totals W)
1943
1968
  stripped0 = `${before}\x1b[7m \x1b[27m`;
1944
1969
  cursorPad = 1;
1945
1970
  }
@@ -1954,10 +1979,12 @@ export class Body {
1954
1979
  stripped0 = `${before}\x1b[7m${glyph}\x1b[27m${after.slice(glyph.length)}`;
1955
1980
  }
1956
1981
  }
1957
- // the pad completes the row to W the content stopped at W−4,
1958
- // so the pad is 1 (≥ 0 after the drawn cursor consumed one)
1959
- const padW = W - 3 - w - cursorPad;
1960
- return { stripped: `\x1b[2m│ \x1b[0m${stripped0}\x1b[2m${" ".repeat(padW)}│\x1b[0m`, markerCell };
1982
+ // R2 the box is retired (see boxTop): the composer is two dashed
1983
+ // rules and the row between them, so there are no walls to pay for
1984
+ // and no pad to hold them off. The row is exactly W, as it always
1985
+ // was; what changed is that all W columns belong to the input.
1986
+ const padW = Math.max(0, W - w - cursorPad);
1987
+ return { stripped: `${stripped0}${" ".repeat(padW)}`, markerCell };
1961
1988
  }
1962
1989
  /** KC1 §6 — the focus component's input ROWS (N = 1 today's single
1963
1990
  * row, byte for byte). The lead rides the FIRST row and the
@@ -1994,7 +2021,7 @@ export class Body {
1994
2021
  cursorRow -= first;
1995
2022
  }
1996
2023
  const out = [];
1997
- let markerCol = 3;
2024
+ let markerCol = 1; // R2: no wall to skip — the row starts at column 1
1998
2025
  for (let r = 0; r < rows.length; r += 1) {
1999
2026
  const text = `${r === 0 ? lead : " ".repeat(leadW)}${rows[r]}`;
2000
2027
  const bytes = this.#inputRowBytes(text, W, r === cursorRow ? leadW + cursorCol : null);
@@ -2002,7 +2029,7 @@ export class Body {
2002
2029
  // W23: the frame-derived column — wallL (2) + the marker's
2003
2030
  // cell + 1 — the CHA lands the cursor AT the marker from ANY base
2004
2031
  if (r === cursorRow)
2005
- markerCol = 3 + bytes.markerCell;
2032
+ markerCol = 1 + bytes.markerCell;
2006
2033
  }
2007
2034
  return { rows: out, markerRow: cursorRow, markerCol };
2008
2035
  }
@@ -2323,6 +2350,10 @@ export class Dock {
2323
2350
  onResize() {
2324
2351
  compositorRef?.onResize();
2325
2352
  }
2353
+ /** DC-3 — the terminal answered what its background is; repaint. */
2354
+ onGroundChange() {
2355
+ compositorRef?.onGroundChange();
2356
+ }
2326
2357
  /** TUI2-R3v2 ②: where the last frame put the panel's clickable option
2327
2358
  * rows (absolute screen rows). The editor binds this and does no row
2328
2359
  * arithmetic of its own. */
package/dist/editor.d.ts CHANGED
@@ -56,6 +56,15 @@ export declare class Editor {
56
56
  #private;
57
57
  readonly closed: Promise<void>;
58
58
  constructor(onRender: () => void);
59
+ /**
60
+ * DC-7 — the terminal answering a question kiso asked it.
61
+ *
62
+ * The body is everything between `ESC ]` and the terminator, verbatim
63
+ * and unparsed (`11;rgb:ffff/ffff/ffff`). The editor's job ends at
64
+ * keeping it out of the draft; deciding what a report MEANS belongs to
65
+ * whoever asked the question.
66
+ */
67
+ onOsc(cb: (body: string) => void): void;
59
68
  onLine(cb: (line: string) => void): void;
60
69
  onSigint(cb: () => void): void;
61
70
  onEot(cb: () => void): void;
package/dist/editor.js CHANGED
@@ -81,6 +81,11 @@ const TAB = 0x09;
81
81
  * the geometry stays legal down to the compositor's enter gate (H = 4
82
82
  * ⇒ one row, exactly today's minimum). */
83
83
  const N_MAX = 6;
84
+ /** DC-7 — the longest OSC kiso will hold while waiting for a terminator.
85
+ * The reports it reads are tens of bytes; anything past this is a
86
+ * payload for someone else, and holding it is how the editor goes
87
+ * deaf. */
88
+ const OSC_MAX = 1024;
84
89
  /** The dim "…" — the ONE truncation mark: the horizontal scroll's
85
90
  * prefix (unchanged) and the viewport's hidden-rows markers. */
86
91
  const ELLIPSIS = "\x1b[2m…\x1b[0m";
@@ -293,7 +298,9 @@ export class Editor {
293
298
  #queueState = () => [];
294
299
  #queuePop = null;
295
300
  #queuePopMode = false;
296
- #pending = ""; // an incomplete ESC/CSI prefix across chunks
301
+ #pending = ""; // an incomplete ESC/CSI/OSC prefix across chunks
302
+ /** DC-7: the terminal's own reports (OSC). Never a keystroke. */
303
+ #oscCb = null;
297
304
  #decoder = new TextDecoder();
298
305
  #entered = false;
299
306
  #onData;
@@ -306,6 +313,17 @@ export class Editor {
306
313
  this.#closedResolve = resolve;
307
314
  });
308
315
  }
316
+ /**
317
+ * DC-7 — the terminal answering a question kiso asked it.
318
+ *
319
+ * The body is everything between `ESC ]` and the terminator, verbatim
320
+ * and unparsed (`11;rgb:ffff/ffff/ffff`). The editor's job ends at
321
+ * keeping it out of the draft; deciding what a report MEANS belongs to
322
+ * whoever asked the question.
323
+ */
324
+ onOsc(cb) {
325
+ this.#oscCb = cb;
326
+ }
309
327
  onLine(cb) {
310
328
  this.#lineCb = cb;
311
329
  // Flush submits that arrived before the handler was wired (typed
@@ -1101,6 +1119,32 @@ export class Editor {
1101
1119
  this.#csi(m[1], m[2]);
1102
1120
  i += m[0].length + 1;
1103
1121
  }
1122
+ else if (rest.startsWith("]")) {
1123
+ // DC-7: an OSC is a message FROM the terminal — a background
1124
+ // colour answer, a theme-change notice, a clipboard report.
1125
+ // There was no branch for it, so the bytes fell through to
1126
+ // the literal-text path and the answer was typed into the
1127
+ // draft. The terminator is BEL **or** ST: Apple Terminal
1128
+ // answers `ESC ] 11 ; rgb:… BEL`, and ST is the standard.
1129
+ const end = /\x07|\x1b\\/.exec(rest);
1130
+ if (end === null) {
1131
+ const tail = text.slice(i);
1132
+ // The unterminated case is the SGR-1006 hazard by another
1133
+ // door: park it and a stream that never terminates grows
1134
+ // #pending forever until the editor goes deaf. A report
1135
+ // long enough to be a payload (OSC 52 carries a whole
1136
+ // clipboard) is not one we read, so past the cap it is
1137
+ // dropped rather than held.
1138
+ if (tail.length > OSC_MAX) {
1139
+ i = text.length;
1140
+ break;
1141
+ }
1142
+ this.#pending = tail; // incomplete OSC — wait for more
1143
+ break;
1144
+ }
1145
+ this.#oscCb?.(rest.slice(1, end.index));
1146
+ i += 1 + end.index + end[0].length;
1147
+ }
1104
1148
  else if (rest.startsWith("O")) {
1105
1149
  i += 3; // SS3 (function keys) — ignored
1106
1150
  }
@@ -2309,7 +2353,14 @@ export class Editor {
2309
2353
  // owns the keys, the brick otherwise): maxW = W − walls − lead.
2310
2354
  const lead = this.#panel !== null ? panelLead(this.#panel.view, this.#panel.phase, this.#panel.cursor, this.#panel.ask ?? undefined) : PROMPT;
2311
2355
  const leadW = leadWidth(lead);
2312
- const maxW = Math.max(1, W - leadW - 4); // W6: the box's walls (2+2) the visible line fits the box's inner width; the "…" rides inside
2356
+ // DC-17: ONE column, not four. W6's box took 2+2 and this kept
2357
+ // reserving them after law 1.1 retired it — so the horizontal
2358
+ // scroll fired three columns early, the cursor could never reach
2359
+ // the row's last three cells, and the two width authorities the
2360
+ // W23 contract says must never disagree disagreed by 3. The
2361
+ // compositor's walk caps at W−1 (the drawn cursor's own cell);
2362
+ // this is the same one column, on the same row.
2363
+ const maxW = Math.max(1, W - leadW - 1);
2313
2364
  // KC1: the scroll is the CURSOR LINE's own offset — a single-line
2314
2365
  // buffer's line starts at 0, so the math is today's exactly. The
2315
2366
  // clamp catches a walk onto a line SHORTER than the old offset.
package/dist/index.d.ts CHANGED
@@ -10,7 +10,7 @@ export { Body, Dock, CURSOR_MARKER, type BodyOptions } from "./compositor.js";
10
10
  export { panelAffordance, panelBlockRows, panelLead, panelLeadPlain, panelLeadWidth, panelStatus, PICK_MAX, modelPickView, pickAffordance, pickBlockRows, pickLeadPlain, type PickOption, type PickResult, type PickRuntime, type PickSpec, type PanelArgs, type PanelFlavor, type PanelPhase, deletionRiskHint, SAFER_BACK, SAFER_DEGRADED, SAFER_DEGRADED_TRUNCATED, saferDegradedNote, type SaferAnswer, type SaferFailure, type SaferOption, type SaferRuntime, panelOptions, type PanelOption, type PanelOptionKind, type PanelState, type PanelVerdict, type PanelView, } from "./approval-panel.js";
11
11
  export { Container, foldLine, foldWords, visibleWidth, SPINNER, type Component, type FrameCtx } from "./components.js";
12
12
  export { Editor, MENU_ITEMS, PROMPT, PROMPT_WIDTH, displayWidth, charWidth, widthOf, type MenuItem, } from "./editor.js";
13
- export { bannerLines, COLOR_OFF, COLOR_ON, escapeTerminal, foldResult, foldThinking, kUnit, palette, renderEvent, renderRecap, renderResumeList, renderSessionLine, renderStatusLine, relativeTime, renderTerminalGap, renderToolSummary, TAGLINE, toolTarget, truncateRow, type Palette, type PathResolver, type RecapStats, type ResumeMeta, type RenderInput, type RenderResult, type RunUsage, } from "./render.js";
13
+ export { bannerLines, COLOR_OFF, COLOR_ON, currentGround, setGround, escapeTerminal, foldResult, foldThinking, kUnit, palette, renderEvent, renderRecap, renderResumeList, renderSessionLine, renderStatusLine, relativeTime, renderTerminalGap, renderToolSummary, TAGLINE, toolTarget, truncateRow, type Palette, type PathResolver, type RecapStats, type ResumeMeta, type RenderInput, type RenderResult, type RunUsage, } from "./render.js";
14
14
  export { editFileDiff, truncateDiff, writeFileDiff, type DiffLine, type DiffResult } from "./diff.js";
15
15
  export { STATUS_GLYPHS, cacheHitPct, idleStatus, runningStatus, type StatusMeter } from "./status.js";
16
16
  export { contextRows, contextUnavailableRows, type ContextLedger } from "./context-ledger.js";
@@ -18,4 +18,5 @@ export { interactivePrompt, projectTrustRows, projectTrustView, projectUntrusted
18
18
  export { AT_CAP, AT_SKIP, AT_VISIBLE, atEmbed, atFilter, atPanelRows, atWindow, bandHeader, longestRun, type AtItem, type AtMatch } from "./at-picker.js";
19
19
  export { BADGE_GLYPH, idColumn, sessionAge, sessionBadge, sessionCounterRow, sessionFilter, sessionListFooter, sessionListRow, sessionNote, sessionPickerRows, sessionRow, type SessionCardView, type SessionPickState, } from "./session-picker.js";
20
20
  export { ASK_HEADER_CAP, ASK_MAX_OPTIONS, ASK_MAX_QUESTIONS, ASK_MIN_OPTIONS, askAffordance, askAnswers, askBlockRows, askCommitCustom, askDeclineAll, askDeclineList, askKey, askLeadPlain, askStart, askStatus, askView, type AskAnswer, type AskOption, type AskQuestion, type AskResult, type AskRuntime, type AskSpec, type AskStep, } from "./ask-panel.js";
21
+ export { resolveGround, type Ground } from "@vincemakes/kiso-tui-cells";
21
22
  export { KEY_BINDINGS, PANEL_KEYS_ROW, displayVerb, extensionsBannerText, helpRows, keysHelpRow, keysSheetRows, unansweredAskView, type BannerExtension, type KeyBinding } from "./strings.js";
package/dist/index.js CHANGED
@@ -15,7 +15,7 @@ export { panelAffordance, panelBlockRows, panelLead, panelLeadPlain, panelLeadWi
15
15
  PICK_MAX, modelPickView, pickAffordance, pickBlockRows, pickLeadPlain, deletionRiskHint, SAFER_BACK, SAFER_DEGRADED, SAFER_DEGRADED_TRUNCATED, saferDegradedNote, panelOptions, } from "./approval-panel.js";
16
16
  export { Container, foldLine, foldWords, visibleWidth, SPINNER } from "./components.js";
17
17
  export { Editor, MENU_ITEMS, PROMPT, PROMPT_WIDTH, displayWidth, charWidth, widthOf, } from "./editor.js";
18
- export { bannerLines, COLOR_OFF, COLOR_ON, escapeTerminal, foldResult, foldThinking, kUnit, palette, renderEvent, renderRecap, renderResumeList, renderSessionLine, renderStatusLine, relativeTime, renderTerminalGap, renderToolSummary, TAGLINE, toolTarget, truncateRow, } from "./render.js";
18
+ export { bannerLines, COLOR_OFF, COLOR_ON, currentGround, setGround, escapeTerminal, foldResult, foldThinking, kUnit, palette, renderEvent, renderRecap, renderResumeList, renderSessionLine, renderStatusLine, relativeTime, renderTerminalGap, renderToolSummary, TAGLINE, toolTarget, truncateRow, } from "./render.js";
19
19
  export { editFileDiff, truncateDiff, writeFileDiff } from "./diff.js";
20
20
  // KC2 §5: the status rows' formatters — the CLI keeps the state and the
21
21
  // repaint, the terminal layer owns what the row says.
@@ -42,4 +42,5 @@ export { ASK_HEADER_CAP, ASK_MAX_OPTIONS, ASK_MAX_QUESTIONS, ASK_MIN_OPTIONS, as
42
42
  // honestly for a question nobody answered (the ① probe's surface).
43
43
  // TUI2-R1 (D): the keys sheet + THE key table — one source for the ?
44
44
  // overlay and /help's keys row.
45
+ export { resolveGround } from "@vincemakes/kiso-tui-cells";
45
46
  export { KEY_BINDINGS, PANEL_KEYS_ROW, displayVerb, extensionsBannerText, helpRows, keysHelpRow, keysSheetRows, unansweredAskView } from "./strings.js";
package/dist/render.js CHANGED
@@ -103,10 +103,10 @@ export function renderEvent(ev, prevThinking = false, resolvePath = (p) => p) {
103
103
  prompt: false,
104
104
  };
105
105
  case "checklist": {
106
- // ⑥: the durable checklist — the header accent (the recap's
106
+ // ⑥: the durable checklist — the header accent (the recap's
107
107
  // brick) + one brick-glyph line per item. Static line content:
108
108
  // byte-identical in pipes and NO_COLOR.
109
- const lines = [`${p.bold}▞${p.reset} ${escapeTerminal(ev.header)}`];
109
+ const lines = [`${p.bold}✦${p.reset} ${escapeTerminal(ev.header)}`];
110
110
  for (const item of ev.items) {
111
111
  const glyph = item.status === "pending" ? "□" : item.status === "active" ? "▖" : "▣";
112
112
  lines.push(` ${glyph} ${escapeTerminal(item.text)}`);
@@ -174,7 +174,7 @@ export function renderRecap(s) {
174
174
  // row's right side.
175
175
  if (s.mode === "plan") {
176
176
  const parts = ["plan ready", "/mode default executes", "/mode accept-edits auto-approves edits"];
177
- return `${p.bold}▞${p.reset} ${parts.join(" · ")}\n`;
177
+ return `${p.bold}✦${p.reset} ${parts.join(" · ")}\n`;
178
178
  }
179
179
  const parts = [`${s.seconds}s`, `${s.tools} tool${s.tools === 1 ? "" : "s"}${s.edits > 0 ? ` (${s.edits} edit${s.edits === 1 ? "" : "s"})` : ""}`];
180
180
  if (s.usage.known) {
@@ -193,7 +193,7 @@ export function renderRecap(s) {
193
193
  }
194
194
  if (s.ctxLeftPct !== null)
195
195
  parts.push(`ctx left ~${Math.round(s.ctxLeftPct)}%`);
196
- return `${p.bold}▞${p.reset} ${parts.join(" · ")}\n`;
196
+ return `${p.bold}✦${p.reset} ${parts.join(" · ")}\n`;
197
197
  }
198
198
  /** One-line summary of a session, for `kiso sessions`. */
199
199
  export function renderSessionLine(meta) {
@@ -68,10 +68,23 @@ export declare function sessionAge(updatedAt: number, now: number): string;
68
68
  * (the whole reason a filter-as-you-type picker is usable). */
69
69
  export declare function idColumn(cards: readonly SessionCardView[]): number;
70
70
  /**
71
- * The filter — the @ picker's muscle, aimed at the session id: a
71
+ * The filter — the @ picker's muscle, aimed at what the ROW SHOWS: a
72
72
  * case-insensitive SUBSEQUENCE, ranked by the longest contiguous run,
73
- * then by id length, then lexically. Identical determinism, identical
74
- * feel; a row under the cursor never moves because two ids tied.
73
+ * then by the haystack's length, then lexically. Identical determinism,
74
+ * identical feel; a row under the cursor never moves because two
75
+ * candidates tied.
76
+ *
77
+ * DC-13 (R2) — it used to search the ID and nothing else. That was
78
+ * coherent while the id was the row's leading column; the owner's
79
+ * ruling moved the TITLE there and retired the id from the row, and the
80
+ * filter did not follow. The result is the worst kind of search: typing
81
+ * what you can SEE returns nothing, and typing an id you cannot see
82
+ * narrows the list for a reason the screen never explains.
83
+ *
84
+ * Both are searched, title FIRST. The id stays a haystack because
85
+ * `kiso sessions` prints ids and a human who copied one must be able to
86
+ * paste it here; a title hit outranks an id hit at equal run length,
87
+ * because the title is what the person was reading.
75
88
  *
76
89
  * An empty query matches everything and keeps the caller's order (the
77
90
  * listing's newest-first), because "no query" is not a search — it is
@@ -107,10 +120,23 @@ export interface SessionPickState {
107
120
  * channel.
108
121
  */
109
122
  export declare function sessionPickerRows(state: SessionPickState, W: number, now: number): string[];
110
- /** Slice ③ — the `kiso sessions` TTY row: the SAME projection, printed
111
- * rather than picked. No selection bar (nothing is selected on a
112
- * listing) and no leading indent: this row starts at column 1 like
113
- * every other line a shell command prints. */
123
+ /**
124
+ * Slice the `kiso sessions` TTY row: the same projection, printed
125
+ * rather than picked. No selection bar (nothing is selected on a
126
+ * listing) and no leading indent: this row starts at column 1 like
127
+ * every other line a shell command prints.
128
+ *
129
+ * DC-16: it keeps the ID, and the picker row does not. Sharing one
130
+ * projection is what stops the two surfaces drifting — that is this
131
+ * module's whole design and it stays — but "share the projection" is
132
+ * not "be the same row". The owner's ruling was about the PICKER, where
133
+ * the id was four characters of machine identity in the column the eye
134
+ * lands on first. A LISTING is the surface you read to copy an id OUT
135
+ * of: it is what `/resume <id>` and the filter's id haystack both
136
+ * assume exists, and deleting the id here quietly falsified both. The
137
+ * id goes LAST and dim — present for the hand that needs it, out of the
138
+ * way of the eye that does not.
139
+ */
114
140
  export declare function sessionListRow(card: SessionCardView, W: number, now: number, idCol: number): string;
115
141
  /** Slice ③ — the listing's last line: the count, and the one thing the
116
142
  * user can do next. */
@@ -24,7 +24,7 @@
24
24
  * listing render from ONE definition instead of two that drift.
25
25
  */
26
26
  import { escapeTerminal, palette } from "./render.js";
27
- import { visibleWidth, widthCut } from "./components.js";
27
+ import { selectionBar, visibleWidth, widthCut } from "./components.js";
28
28
  import { atEmbed, bandHeader, longestRun, AT_VISIBLE, atWindow } from "./at-picker.js";
29
29
  /** The glyph per state — one cell each, so the badge column never
30
30
  * shifts the id column (a column that moves per row reads as damage). */
@@ -105,10 +105,23 @@ export function idColumn(cards) {
105
105
  return Math.min(Math.max(w, 1), 24);
106
106
  }
107
107
  /**
108
- * The filter — the @ picker's muscle, aimed at the session id: a
108
+ * The filter — the @ picker's muscle, aimed at what the ROW SHOWS: a
109
109
  * case-insensitive SUBSEQUENCE, ranked by the longest contiguous run,
110
- * then by id length, then lexically. Identical determinism, identical
111
- * feel; a row under the cursor never moves because two ids tied.
110
+ * then by the haystack's length, then lexically. Identical determinism,
111
+ * identical feel; a row under the cursor never moves because two
112
+ * candidates tied.
113
+ *
114
+ * DC-13 (R2) — it used to search the ID and nothing else. That was
115
+ * coherent while the id was the row's leading column; the owner's
116
+ * ruling moved the TITLE there and retired the id from the row, and the
117
+ * filter did not follow. The result is the worst kind of search: typing
118
+ * what you can SEE returns nothing, and typing an id you cannot see
119
+ * narrows the list for a reason the screen never explains.
120
+ *
121
+ * Both are searched, title FIRST. The id stays a haystack because
122
+ * `kiso sessions` prints ids and a human who copied one must be able to
123
+ * paste it here; a title hit outranks an id hit at equal run length,
124
+ * because the title is what the person was reading.
112
125
  *
113
126
  * An empty query matches everything and keeps the caller's order (the
114
127
  * listing's newest-first), because "no query" is not a search — it is
@@ -120,17 +133,22 @@ export function sessionFilter(cards, query) {
120
133
  const lower = query.toLowerCase();
121
134
  const scored = [];
122
135
  for (const card of cards) {
123
- const hit = atEmbed(card.id.toLowerCase(), lower);
136
+ const shown = (card.title ?? card.id).toLowerCase();
137
+ const onTitle = atEmbed(shown, lower);
138
+ const hit = onTitle ?? atEmbed(card.id.toLowerCase(), lower);
124
139
  if (hit === null)
125
140
  continue;
126
- scored.push({ card, run: longestRun(hit) });
141
+ const key = onTitle !== null ? shown : card.id;
142
+ scored.push({ card, run: longestRun(hit), onTitle: onTitle !== null, key });
127
143
  }
128
144
  scored.sort((a, b) => {
129
145
  if (a.run !== b.run)
130
146
  return b.run - a.run;
131
- if (a.card.id.length !== b.card.id.length)
132
- return a.card.id.length - b.card.id.length;
133
- return a.card.id < b.card.id ? -1 : a.card.id > b.card.id ? 1 : 0;
147
+ if (a.onTitle !== b.onTitle)
148
+ return a.onTitle ? -1 : 1;
149
+ if (a.key.length !== b.key.length)
150
+ return a.key.length - b.key.length;
151
+ return a.key < b.key ? -1 : a.key > b.key ? 1 : 0;
134
152
  });
135
153
  return scored.map((s) => s.card);
136
154
  }
@@ -173,27 +191,29 @@ function rowSpans(card, budget, now, idCol) {
173
191
  text += `${sessionBadge(card.badge)} `;
174
192
  w += 2;
175
193
  }
176
- const id = widthCut(escapeTerminal(card.id), Math.max(1, Math.min(idCol, budget - w)));
177
- put(id, id);
178
- // the column pad only survives while there is room for what follows
179
- const pad = Math.max(0, Math.min(idCol - visibleWidth(id), budget - w));
180
- put(" ".repeat(pad), " ".repeat(pad));
181
- const meta = ` ${sessionAge(card.updatedAt, now)} · ${card.turns} turn${card.turns === 1 ? "" : "s"}`;
182
- put(meta, `${p.dim}${meta}${p.reset}`);
183
- // REL-0152-D6b: the TITLE the only span on this row that answers
184
- // "which conversation is this?". It goes after the meta and before
185
- // the note, and it is bounded so the note (which can be the one that
186
- // demands an action) still has room at ordinary widths; on a narrow
187
- // terminal `put` drops whichever no longer fits, in that order.
188
- const title = card.title ?? "";
194
+ // R2 (owner, 2026-08-27) the TITLE LEADS and the id is gone.
195
+ //
196
+ // The id was four characters of machine identity sitting in the column
197
+ // the eye lands on first, and the title the only span that answers
198
+ // "which conversation is this?" — came after the meta. The order is
199
+ // reversed: the title takes the left edge, the age and turn count go
200
+ // right and dim, and the note keeps its reserve. The id is still
201
+ // reachable where it is USED (`kiso sessions`, `/status`) it left
202
+ // the row it was never the subject of.
203
+ // a row with no title at all falls back to the id: the id left the
204
+ // row it was never the subject of, but a row must still IDENTIFY
205
+ // something an anonymous row is not a picker row. (In practice
206
+ // `sessionTitle` returns "(no prompt)" rather than "", so this is
207
+ // the seam for callers that build a card without records.)
208
+ const title = card.title ?? card.id;
189
209
  if (title !== "") {
190
210
  const room = Math.max(0, Math.min(budget - w - 3 - NOTE_RESERVE, TITLE_MAX));
191
211
  const cut = widthCut(escapeTerminal(title), room);
192
- if (cut !== "") {
193
- put(" ", " ");
212
+ if (cut !== "")
194
213
  put(cut, `${p.bold}${cut}${p.reset}`);
195
- }
196
214
  }
215
+ const meta = ` ${sessionAge(card.updatedAt, now)} · ${card.turns} turn${card.turns === 1 ? "" : "s"}`;
216
+ put(meta, `${p.dim}${meta}${p.reset}`);
197
217
  const note = widthCut(sessionNote(card), Math.max(0, budget - w - 3));
198
218
  if (note !== "") {
199
219
  // the ? note carries the warn tint — the row's own words are what
@@ -221,8 +241,10 @@ export function sessionRow(card, selected, W, now, idCol) {
221
241
  const { text, width } = rowSpans(card, Math.max(0, W - 2), now, idCol);
222
242
  if (!selected)
223
243
  return ` ${text}`;
224
- const inner = text.replaceAll(p.reset, `${p.reset}${p.rv}`);
225
- return `${p.rv} ${inner}${" ".repeat(Math.max(0, W - width - 2))} ${p.rvEnd}`;
244
+ // R2: one bar, in one place — and with it §2.1's rule that dim never
245
+ // sits on the wash. The age/turns/note spans were dim INSIDE the bar,
246
+ // grey on grey, on the row the cursor was pointing at.
247
+ return selectionBar(text, width, W);
226
248
  }
227
249
  /** The counter row — the SELECTION's 1-based place in the whole
228
250
  * filtered list, which the visible window cannot tell the user. */
@@ -253,12 +275,30 @@ export function sessionPickerRows(state, W, now) {
253
275
  rows.push(sessionCounterRow(state.selected, state.matches.length, W));
254
276
  return rows;
255
277
  }
256
- /** Slice ③ — the `kiso sessions` TTY row: the SAME projection, printed
257
- * rather than picked. No selection bar (nothing is selected on a
258
- * listing) and no leading indent: this row starts at column 1 like
259
- * every other line a shell command prints. */
278
+ /**
279
+ * Slice the `kiso sessions` TTY row: the same projection, printed
280
+ * rather than picked. No selection bar (nothing is selected on a
281
+ * listing) and no leading indent: this row starts at column 1 like
282
+ * every other line a shell command prints.
283
+ *
284
+ * DC-16: it keeps the ID, and the picker row does not. Sharing one
285
+ * projection is what stops the two surfaces drifting — that is this
286
+ * module's whole design and it stays — but "share the projection" is
287
+ * not "be the same row". The owner's ruling was about the PICKER, where
288
+ * the id was four characters of machine identity in the column the eye
289
+ * lands on first. A LISTING is the surface you read to copy an id OUT
290
+ * of: it is what `/resume <id>` and the filter's id haystack both
291
+ * assume exists, and deleting the id here quietly falsified both. The
292
+ * id goes LAST and dim — present for the hand that needs it, out of the
293
+ * way of the eye that does not.
294
+ */
260
295
  export function sessionListRow(card, W, now, idCol) {
261
- return rowSpans(card, W, now, idCol).text;
296
+ const p = palette();
297
+ const tail = ` ${card.id}`;
298
+ const { text, width } = rowSpans(card, Math.max(1, W - visibleWidth(tail)), now, idCol);
299
+ if (width + visibleWidth(tail) > W)
300
+ return text; // a terminal too narrow for both keeps the words
301
+ return `${text}${p.dim}${tail}${p.reset}`;
262
302
  }
263
303
  /** Slice ③ — the listing's last line: the count, and the one thing the
264
304
  * user can do next. */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-tui",
3
- "version": "0.16.2",
4
- "description": "kiso tui \u2014 the pure terminal layer (cell renderer, dock, raw editor, diff, palette). Zero runtime dependencies: input is data, output is bytes.",
3
+ "version": "0.16.4",
4
+ "description": "kiso tui the pure terminal layer (cell renderer, dock, raw editor, diff, palette). Zero runtime dependencies: input is data, output is bytes.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "exports": {
@@ -35,6 +35,6 @@
35
35
  },
36
36
  "homepage": "https://github.com/vincemakes/kiso/tree/main/packages/tui#readme",
37
37
  "dependencies": {
38
- "@vincemakes/kiso-tui-cells": "0.16.2"
38
+ "@vincemakes/kiso-tui-cells": "0.16.4"
39
39
  }
40
40
  }