@vincemakes/kiso-tui 0.46.1 → 0.47.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.
package/dist/at-picker.js CHANGED
@@ -13,7 +13,10 @@
13
13
  * on the machine's locale).
14
14
  */
15
15
  import { escapeTerminal, palette } from "./lines.js";
16
- import { selectionBar, visibleWidth, widthCut } from "./components.js";
16
+ import { atEmbed, bandKeyRow, bandVisible, bandWindow, goldHits, moreMark, selectionBar, visibleWidth, widthCut } from "./components.js";
17
+ // Graphite P3: the band helpers and the matcher moved to tui-cells, where
18
+ // the pick panels draw too; re-exported so every import site stays as it was
19
+ export { atEmbed, bandKeyRow, bandVisible, bandWindow, moreMark };
17
20
  import { bandHeader } from "@vincemakes/kiso-tui-cells/strings";
18
21
  /**
19
22
  * KC3 §5 — the ONE cap. The file list is computed per open with no
@@ -39,53 +42,6 @@ export const AT_SKIP = new Set([".git", "node_modules", "dist", "build", "covera
39
42
  /** KC3 §4 — the panel's visible height. A ceiling, not a promise: the
40
43
  * compositor clamps further when the terminal is short. */
41
44
  export const AT_VISIBLE = 5;
42
- /**
43
- * The subsequence embedding, TIGHTENED — the two-pass walk every good
44
- * fuzzy finder uses, and the reason `@ra` emboldens the "ra" of
45
- * "src/range.js" rather than the r of "src" and the a of "range".
46
- *
47
- * Pass 1 walks forward and stops at the EARLIEST index that completes
48
- * the query — this both answers "does it match at all" and fixes the
49
- * right-hand edge. Pass 2 walks backward from that edge, taking the
50
- * LATEST position for each query character in turn, which slides every
51
- * matched character as far right as it can go without crossing the
52
- * next one. The result is the most clustered embedding that ends where
53
- * the earliest match ends.
54
- *
55
- * Case-insensitive: both sides are lowercased by the caller once per
56
- * query rather than once per character.
57
- *
58
- * Returns the ascending match indices, or null when the query is not a
59
- * subsequence of the path at all.
60
- */
61
- export function atEmbed(lowerPath, lowerQuery) {
62
- if (lowerQuery === "")
63
- return [];
64
- // pass 1 — forward, to the earliest completing index
65
- let qi = 0;
66
- let end = -1;
67
- for (let pi = 0; pi < lowerPath.length; pi += 1) {
68
- if (lowerPath[pi] === lowerQuery[qi]) {
69
- qi += 1;
70
- if (qi === lowerQuery.length) {
71
- end = pi;
72
- break;
73
- }
74
- }
75
- }
76
- if (end === -1)
77
- return null; // not a subsequence — no embedding exists
78
- // pass 2 — backward from that edge, sliding each character right
79
- const hit = new Array(lowerQuery.length);
80
- let pi = end;
81
- for (let j = lowerQuery.length - 1; j >= 0; j -= 1) {
82
- while (lowerPath[pi] !== lowerQuery[j])
83
- pi -= 1;
84
- hit[j] = pi;
85
- pi -= 1;
86
- }
87
- return hit;
88
- }
89
45
  /** The longest run of CONSECUTIVE indices in an ascending list. An
90
46
  * empty query has no run — every path ties on it, and the rank falls
91
47
  * through to path length. */
@@ -140,117 +96,70 @@ export function atFilter(items, query) {
140
96
  });
141
97
  return { matches, capped };
142
98
  }
143
- /**
144
- * KC3 §4 — the picker's WINDOW: which slice of the ranked list is on
145
- * screen. The window TRAILS the selection exactly as the composer's
146
- * own viewport trails the cursor (KC1 §5) — derived per read, never
147
- * stored, so it can never disagree with the selection it is meant to
148
- * follow.
149
- */
150
- export function atWindow(total, selected, visible = AT_VISIBLE) {
151
- const count = Math.min(total, visible);
152
- const first = Math.max(0, Math.min(selected - count + 1, total - count));
153
- return { first, count };
154
- }
155
99
  /** The path split into the two columns the panel draws: the file's own
156
100
  * name, and the directory that qualifies it. A path with no slash is
157
101
  * all name and no directory. */
102
+ /** The cells a file's NAME takes in its row — the measure the band's name
103
+ * column is laid over (escaped as the row draws it, so the column a
104
+ * wide or control-character name asks for is the one it gets). */
105
+ export function atNameWidth(path) {
106
+ return visibleWidth(splitPath(escapeTerminal(path)).name);
107
+ }
158
108
  function splitPath(path) {
159
109
  const cut = path.lastIndexOf("/");
160
110
  return cut === -1 ? { dir: "", name: path } : { dir: path.slice(0, cut + 1), name: path.slice(cut + 1) };
161
111
  }
162
112
  /**
163
- * KC3 §4 — ONE row of the panel.
164
- *
165
- * Two columns: the file's NAME on the left with its matched characters
166
- * bold, and the DIRECTORY dim on the right, pushed to the far edge.
167
- * The name is what the user is aiming at; the directory is what tells
168
- * two same-named files apart, which is why it is present but quiet.
113
+ * KC3 §4, Graphite P2 (owner, 2026-10-03) — ONE row of the panel: the
114
+ * file's NAME, padded to the name column, then its FOLDER as a dim column,
115
+ * so the folders line up. The letters the query matched are gold in both.
169
116
  *
170
- * The selected row carries `→` on the inverse band (SGR 7, closed with
171
- * 27 — never SGR 0, so it composes inside the row's own spans).
117
+ * DECLARED REVERSAL of TUI2-R1.5 ⑧ (VD-9) in two parts: the folder no
118
+ * longer trails each name after an em dash — it is a column one gap after
119
+ * the longest name in the list (never the far edge, which was VD-9's
120
+ * complaint), so twenty rows read as a table; and a match inside the
121
+ * folder is drawn, gold, because the person typed it.
172
122
  *
173
- * The `hit` indices are over the FULL path, so they are shifted by the
174
- * directory's length to land on the name. A hit that falls INSIDE the
175
- * directory is simply not drawn bold — the directory column is
176
- * uniformly dim by design (a bold fragment in a right-aligned dim
177
- * column reads as damage, not as information).
178
- *
179
- * The row never exceeds W: the name cuts first (it is the flexible
180
- * column), and the directory is dropped entirely before the name is
181
- * cut to nothing.
123
+ * The name is the flexible column when the row is short: the folder gives
124
+ * way first, then the name is cut with an ellipsis.
182
125
  */
183
- export function atRow(match, selected, W) {
126
+ export function atRow(match, selected, W, nameCol = 0, mark = null) {
184
127
  const p = palette();
185
128
  const { dir, name } = splitPath(escapeTerminal(match.path));
186
- // the name's own matched positions, mapped out of the full path
187
- const marks = new Set(match.hit.filter((i) => i >= dir.length).map((i) => i - dir.length));
188
- // TUI2-R1.5 ⑧ (VD-9): the directory rides NEXT TO the name it
189
- // qualifies. It used to be right-aligned to the band's far edge, which
190
- // on a 100-column terminal put `src/` some eighty columns from the
191
- // `parser.ts` it belongs to: the eye had to cross the whole row to
192
- // learn which parser.ts this was, and the column read as a second list.
193
- // Adjacent and dim, it is what it always meant to be — a qualifier.
194
- // the name is the flexible column and the qualifier gives way first: a
195
- // narrow window keeps the thing being aimed at.
196
- const room = Math.max(1, W - 2);
197
- const suffix = dir === "" ? "" : widthCut(` — ${dir}`, Math.max(0, room - 4));
198
- const nameRoom = Math.max(1, room - visibleWidth(suffix));
199
- const shownName = widthCut(name, nameRoom);
200
- let painted = "";
201
- for (let i = 0; i < shownName.length; i += 1) {
202
- painted += marks.has(i) ? `${p.bold}${shownName[i]}${p.reset}` : shownName[i];
203
- }
204
- const text = `${painted}${suffix === "" ? "" : `${p.dim}${suffix}${p.reset}`}`;
205
- const width = visibleWidth(shownName) + visibleWidth(suffix);
206
- if (!selected)
207
- return ` ${text}`;
208
- // TUI2-R1.5 ⑧ (VD-9): the selection is a FULL-ROW bar — the W16 chip
209
- // mechanism, which the user chip and the turn fold already use. A
210
- // two-cell `→ ` marker on the inverse band is one character of
211
- // highlight in an eighty-column row, and the walkthrough could barely
212
- // find it. The bar spans the row's whole width so the selection is
213
- // visible from anywhere on the line. Mono discipline: reverse video,
214
- // no new colours.
215
- //
216
- // R2: the composition is selectionBar's — one copy, and with it the
217
- // §2.1 rule that dim never sits on the wash (the directory column was
218
- // rendering grey-on-grey inside the bar, i.e. the half of the row the
219
- // selection was meant to help you read).
220
- return selectionBar(`${painted}${suffix === "" ? "" : `${p.dim}${suffix}${p.reset}`}`, width, W);
221
- }
222
- /**
223
- * KC3 §4 — the counter row: `(n/total)`, where n is the 1-based
224
- * position of the SELECTION in the whole ranked list, not in the
225
- * visible window. The user needs to know where they are in the list,
226
- * which the five visible rows cannot tell them.
227
- *
228
- * When the source list was truncated the row SAYS SO. A file picker
229
- * that quietly lists 2,000 of 40,000 files and shows a confident
230
- * "(3/1998)" is lying by omission; this one admits the horizon.
231
- */
232
- export function atCounterRow(selected, total, capped, W) {
233
- const p = palette();
234
- const text = capped ? ` (${selected + 1}/${total}) · first ${AT_CAP} files only` : ` (${selected + 1}/${total})`;
235
- return `${p.dim}${widthCut(text, W)}${p.reset}`;
129
+ const nameHits = new Set(match.hit.filter((i) => i >= dir.length).map((i) => i - dir.length));
130
+ const dirHits = new Set(match.hit.filter((i) => i < dir.length));
131
+ const room = Math.max(1, W - 3); // the two-cell lead and one cell of margin
132
+ const col = Math.min(Math.max(nameCol, visibleWidth(name)), room);
133
+ const shownName = visibleWidth(name) <= col ? name : col <= 1 ? widthCut(name, col) : `${widthCut(name, col - 1)}…`;
134
+ const dirRoom = room - col - 2;
135
+ const shownDir = dir === "" || dirRoom < 4 ? "" : visibleWidth(dir) <= dirRoom ? dir : `${widthCut(dir, dirRoom - 1)}…`;
136
+ const pad = shownDir === "" ? "" : " ".repeat(col - visibleWidth(shownName) + 2);
137
+ const text = `${goldHits(shownName, nameHits, selected ? p.bold : "")}${pad}${shownDir === "" ? "" : goldHits(shownDir, dirHits, p.dim)}`;
138
+ const width = visibleWidth(shownName) + pad.length + visibleWidth(shownDir);
139
+ if (selected)
140
+ return selectionBar(` ${text}`, width + 1, W);
141
+ return mark === null ? ` ${text}` : `${p.dim}${mark}${p.reset} ${text}`;
236
142
  }
237
143
  /**
238
- * KC3 §4 — the whole band: at most AT_VISIBLE windowed rows, then the
144
+ * KC3 §4, Graphite P2 — the whole band: the named hairline with the count
145
+ * (`files · 8`, `2 of 8 match`, and `first 2000 only` when the walk was
146
+ * cut), the windowed rows with their more-marks, and the key row with the
239
147
  * counter. Returned as plain strings for the menu-rows channel, which
240
- * already accounts them in chromeRows — the picker needs no geometry
241
- * of its own, which is the entire reason it rides that channel.
148
+ * already accounts them in chromeRows — the picker needs no geometry of
149
+ * its own, which is the entire reason it rides that channel.
242
150
  */
243
- export function atPanelRows(state, W) {
244
- const { first, count } = atWindow(state.matches.length, state.selected);
245
- // TUI2-R1.5 ⑦(b) (VD-8): the band NAMES itself. It renders frameless
246
- // directly above the composer, so with scrollback behind it there was
247
- // nothing to say where the surface began — the rows read as more
248
- // history. One dim row is enough to make it UI. Mono discipline: dim
249
- // text, no rule, no colour.
250
- const rows = [bandHeader("files", W)];
151
+ export function atPanelRows(state, W, height = 24) {
152
+ const total = state.total ?? state.matches.length;
153
+ const query = state.query ?? "";
154
+ const facts = `${query === "" ? `${total}` : `${state.matches.length} of ${total} match`}${state.capped ? ` · first ${AT_CAP} only` : ""}`;
155
+ // TUI2-R1.5 ⑦(b) (VD-8): the band NAMES itself — with scrollback behind
156
+ // it, nothing else says where the surface begins
157
+ const rows = [bandHeader(`files · ${facts}`, W)];
158
+ const { first, count } = bandWindow(state.matches.length, state.selected, bandVisible(height));
159
+ const nameCol = state.nameCol ?? 0;
251
160
  for (let i = first; i < first + count; i += 1)
252
- rows.push(atRow(state.matches[i], i === state.selected, W));
253
- rows.push(atCounterRow(state.selected, state.matches.length, state.capped, W));
161
+ rows.push(atRow(state.matches[i], i === state.selected, W, nameCol, moreMark(i, first, count, state.matches.length)));
162
+ rows.push(bandKeyRow(["↑↓ move", "tab inserts", "esc"], state.selected, state.matches.length, W));
254
163
  return rows;
255
164
  }
256
165
  /**
@@ -55,11 +55,15 @@ export interface AtPanelState {
55
55
  readonly selected: number;
56
56
  readonly capped: boolean;
57
57
  }
58
+ import { type NoticeMark, type RecallSection } from "./components.js";
58
59
  import { type BannerMeta, type ResumeMeta } from "./lines.js";
60
+ import { type BarInput } from "./status.js";
59
61
  /** The cursor marker — an APC private sequence the focus component
60
62
  * embeds at the edit position; the compositor strips it and moves
61
63
  * relatively (it never reaches the terminal). */
62
64
  export declare const CURSOR_MARKER = "\u001B_[kiso-cur]\u001B\\";
65
+ /** R8 — the command band's window: five rows plus a counter, the same
66
+ * budget the composer's own ceiling can afford above it. */
63
67
  /** KC1 §5 — the input row's bound state. The legacy pair stays
64
68
  * REQUIRED and keeps its exact meaning (the cursor line's visible
65
69
  * slice and its display column), so a one-row provider — the old
@@ -160,6 +164,38 @@ export declare class Body {
160
164
  */
161
165
  fold(label: string, replay: () => void, summary?: string | null): void;
162
166
  notice(text: string): void;
167
+ /** The tasks round (owner, 2026-10-06) — a notice whose terminal form
168
+ * is one meta row per thing it names (a task's end, a project's trust):
169
+ * each row a label (`TASK` once, `""` under it), the sentence cut to one
170
+ * row, its outcome word marked. A pipe keeps `text`, byte for byte. */
171
+ metaNotice(text: string, rows: readonly {
172
+ readonly label: string;
173
+ readonly sentence: string;
174
+ readonly mark?: NoticeMark;
175
+ }[]): void;
176
+ /** The last sweep (owner, 2026-10-06) — what `/think` or `/last` brings
177
+ * back: on the terminal ONE cell, a meta row (`label`, `sentence`) and
178
+ * the sections under it; a pipe keeps `text`, byte for byte. */
179
+ recall(text: string, label: string, sentence: string, sections: readonly RecallSection[]): void;
180
+ /** Graphite R3e (owner, 2026-09-29) — a mode switch: on the terminal
181
+ * `MODE` on a row of its own and `from → to` under it, the new tier
182
+ * bold (full access — the old bypass — in the failure colour, plan in
183
+ * blue) — no explanation:
184
+ * the picker that switched it says what each tier does. A pipe keeps
185
+ * the confirmation it always printed, byte for byte (`text`). */
186
+ modeNotice(text: string, from: string, to: string): void;
187
+ /** The main-sync round (owner, 2026-09-30) — the don't-ask switch, the
188
+ * MODE row's sibling: `DON'T ASK` gold (the person's choice) and
189
+ * `off → on` with the new state bold. It is a switch on top of the tier,
190
+ * not a tier, so it has a row of its own. A pipe keeps #203's words
191
+ * (`don't ask → on`), byte for byte. */
192
+ dontAskNotice(text: string, on: boolean): void;
193
+ /**
194
+ * Graphite §7.11 — the turn's SEAL. On the terminal a cell of its own,
195
+ * drawn at the current width (widest tier that fits); a pipe gets
196
+ * `pipe` — today's recap line — byte for byte.
197
+ */
198
+ seal(tiers: readonly string[], pipe: string): void;
163
199
  /** The startup banner (W1): a LIVE cell — the tier re-derives per
164
200
  * frame (bannerLines with the CURRENT W and H), so a resize re-tiers
165
201
  * the art instead of re-folding frozen rows (a window below 40 cols
@@ -169,6 +205,9 @@ export declare class Body {
169
205
  * the pipe contract). */
170
206
  banner(version: string, extensionsText: string, resume?: ResumeMeta[], meta?: BannerMeta | undefined): void;
171
207
  raw(lines: string[], wrap?: "words"): void;
208
+ /** G3 (R2d) — the person's `!!cmd`: run, shown here, never sent. Off a
209
+ * dock the command and its output are written as they always were. */
210
+ bang(command: string, output: string, isError: boolean): void;
172
211
  /** The last COMPLETE thinking block, for /think. */
173
212
  lastThinking(): string | null;
174
213
  /** The last completed tool call, for /last. */
@@ -266,10 +305,18 @@ export declare class Body {
266
305
  * which time the first frame is already painted with the no-ground
267
306
  * palette. Rather than delay the opening to wait for an answer that
268
307
  * may never come, the frame is painted at once and repainted when the
269
- * answer lands. Invalidating the held screen is exactly what a resize
270
- * does, for exactly the same reason — every row's bytes are wrong —
271
- * so it rides the settle the resize already owns and costs one
272
- * repaint, once per session.
308
+ * answer lands.
309
+ *
310
+ * It is NOT a resize, and must not ride one: the settle returns early
311
+ * when the geometry did not change — which it never does here — so the
312
+ * old path cleared the held screen and painted nothing, leaving the
313
+ * no-ground palette up until the first keystroke. Nor is it a reprint:
314
+ * `3J` would erase the shell's scrollback at every launch, since the
315
+ * answer arrives at every launch. So: the committed cells' cached rows
316
+ * are dropped (their bytes carry the old palette; their geometry does
317
+ * not change, the palette sets no width), the held screen is voided,
318
+ * and one frame repaints what is on screen. Rows already in the
319
+ * terminal's scrollback keep the palette they were written in.
273
320
  */
274
321
  onGroundChange(): void;
275
322
  /**
@@ -325,6 +372,15 @@ export declare class Body {
325
372
  * state — the compacting row passes "esc to cancel" (the affordance
326
373
  * must survive repaints). */
327
374
  setStatus(text: string, hint?: string | null): void;
375
+ /** Graphite §8.9 — hand the status bar its facts. */
376
+ setBar(bar: BarInput | null): void;
377
+ /** Graphite §8.7 — the live row (already composed for the width the
378
+ * caller measured; cut here if the terminal narrowed since), or null
379
+ * when nothing is live. */
380
+ setLive(row: string | null): void;
381
+ /** A confirmation that is true for a moment — `copied 212 chars` — on the
382
+ * live row, until the next keypress. It never displaces a live row. */
383
+ flash(text: string): void;
328
384
  setTail(tail: string): void;
329
385
  /** Bind the editor's panel state — the PanelSelect slot occupant
330
386
  * (W21: the panel replaces the live region + the input lead while
@@ -335,10 +391,13 @@ export declare class Body {
335
391
  /** Bind the editor's slash-command menu state — the MenuSelect slot
336
392
  * occupant (the menu replaces the editor's view while open). */
337
393
  /** TUI2-R1 (D): bind the editor's keys-sheet flag. */
338
- bindSheet(state: () => boolean): void;
394
+ bindSheet(state: () => boolean | ((W: number) => string[])): void;
339
395
  bindMenu(state: () => {
340
396
  items: readonly MenuItem[];
341
397
  selected: number;
398
+ total?: number;
399
+ query?: string;
400
+ nameCol?: number;
342
401
  } | null): void;
343
402
  /** KC3 §4: bind the editor's @ file picker. It shares the menu's
344
403
  * band — see #menuRows for why that is a decision and not a
@@ -348,8 +407,9 @@ export declare class Body {
348
407
  * occupant (see #menuRows for why they share one). */
349
408
  bindPick(state: () => SessionPickState | null): void;
350
409
  /** Bind the pending-turn queue — the CLI's live slots (chat.ts):
351
- * the chips render in the menu-rows family, the live caps shrink
352
- * by their rows, and the +N queued hint rides the status row. */
410
+ * the queued rows render in the menu-rows family and the live caps
411
+ * shrink by their rows (Graphite §8.7). The status bar does not
412
+ * count them; without a bar the status row's hint still does. */
353
413
  bindQueue(state: () => readonly string[]): void;
354
414
  /** The input line's edit column — the old dock's API. v6: the CURSOR
355
415
  * derives from the frame's marker; this is the same value computed
@@ -405,6 +465,12 @@ export declare class Dock {
405
465
  size: number;
406
466
  } | null;
407
467
  setStatus(text: string, hint?: string | null): void;
468
+ /** Graphite §8.9 — the status bar's facts. */
469
+ setBar(bar: BarInput | null): void;
470
+ /** Graphite §8.7 — the live row, or null. */
471
+ setLive(row: string | null): void;
472
+ /** A short confirmation on the live row, gone at the next keypress. */
473
+ flash(text: string): void;
408
474
  setTail(tail: string): void;
409
475
  /** W21: bind the editor's panel state — the PanelSelect slot
410
476
  * occupant (the panel replaces the live region + the input lead
@@ -412,7 +478,7 @@ export declare class Dock {
412
478
  bindApproval(state: () => PanelState | null): void;
413
479
  /** TUI2-R1 (D): bind the editor's keys-sheet flag — the slot read for
414
480
  * the ? overlay (the menu/picker binding pattern). */
415
- bindSheet(state: () => boolean): void;
481
+ bindSheet(state: () => boolean | ((W: number) => string[])): void;
416
482
  /** R5 — the transcript viewer's three doors, forwarded to the live
417
483
  * compositor. Unlike the other bindings there is no buffer: the
418
484
  * viewer cannot be open before a compositor exists, so a call with
@@ -424,6 +490,9 @@ export declare class Dock {
424
490
  bindMenu(state: () => {
425
491
  items: readonly MenuItem[];
426
492
  selected: number;
493
+ total?: number;
494
+ query?: string;
495
+ nameCol?: number;
427
496
  } | null): void;
428
497
  /** KC3 §4: bind the editor's @ picker — the SAME band as the slash
429
498
  * menu (see Body#menuRows). Unbound, the picker cannot render, and
@@ -433,8 +502,8 @@ export declare class Dock {
433
502
  * occupant. Unbound (every path but bare `kiso resume`), the picker
434
503
  * cannot render and every frame is byte-identical to before. */
435
504
  bindPick(state: () => SessionPickState | null): void;
436
- /** W22: bind the pending-turn queue — the chips + the +N queued
437
- * hint (the CLI binds it from chat(); the editor's pop keys ride
505
+ /** W22: bind the pending-turn queue — the queued rows (the CLI
506
+ * binds it from chat(); the editor's pop keys ride
438
507
  * the LineInput's own bindQueue). */
439
508
  bindQueue(state: () => readonly string[]): void;
440
509
  editCol(): number;