@vincemakes/kiso-tui 0.6.0 → 0.8.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.
@@ -0,0 +1,228 @@
1
+ /**
2
+ * KC3 §3 — the @ file picker's PURE half: the subsequence filter and
3
+ * the deterministic rank. No scoring library, no index, no disk. The
4
+ * file list is DATA the CLI feeds in (the tui purity rule: input is
5
+ * data, output is bytes); this module decides which of those paths a
6
+ * query matches, in what order, and which characters to embolden.
7
+ *
8
+ * Determinism is the whole design constraint. A fuzzy finder that
9
+ * reorders on a tie is unusable at speed — the row under the cursor
10
+ * must not move because two paths scored equal. Every comparison here
11
+ * ends in a total order: run length, then path length, then the raw
12
+ * lexical order of the path (never localeCompare, whose result depends
13
+ * on the machine's locale).
14
+ */
15
+ import { escapeTerminal, palette } from "./render.js";
16
+ import { visibleWidth, widthCut } from "./components.js";
17
+ /**
18
+ * KC3 §5 — the ONE cap. The file list is computed per open with no
19
+ * index and no watcher, so its cost is bounded here rather than
20
+ * amortized somewhere invisible. The source collects at most CAP + 1
21
+ * entries: the extra one is what makes "there were more" DISTINGUISHABLE
22
+ * from "there were exactly this many", so the counter row can say so
23
+ * honestly instead of guessing.
24
+ */
25
+ export const AT_CAP = 2000;
26
+ /**
27
+ * KC3 §5 — the directories the picker never offers, and the other half
28
+ * of its contract with whatever host has to walk a tree to fill it.
29
+ *
30
+ * It lives here beside the cap because the two are the same kind of
31
+ * promise: a host that walks must prune these BEFORE descending (a
32
+ * post-filter would already have walked node_modules, which is the
33
+ * cost the pruning exists to avoid), and must stop at the cap. Hosts
34
+ * that get their list from a VCS ignore this set entirely — the VCS
35
+ * has already applied a better one.
36
+ */
37
+ export const AT_SKIP = new Set([".git", "node_modules", "dist", "build", "coverage"]);
38
+ /** KC3 §4 — the panel's visible height. A ceiling, not a promise: the
39
+ * compositor clamps further when the terminal is short. */
40
+ export const AT_VISIBLE = 5;
41
+ /**
42
+ * The subsequence embedding, TIGHTENED — the two-pass walk every good
43
+ * fuzzy finder uses, and the reason `@ra` emboldens the "ra" of
44
+ * "src/range.js" rather than the r of "src" and the a of "range".
45
+ *
46
+ * Pass 1 walks forward and stops at the EARLIEST index that completes
47
+ * the query — this both answers "does it match at all" and fixes the
48
+ * right-hand edge. Pass 2 walks backward from that edge, taking the
49
+ * LATEST position for each query character in turn, which slides every
50
+ * matched character as far right as it can go without crossing the
51
+ * next one. The result is the most clustered embedding that ends where
52
+ * the earliest match ends.
53
+ *
54
+ * Case-insensitive: both sides are lowercased by the caller once per
55
+ * query rather than once per character.
56
+ *
57
+ * Returns the ascending match indices, or null when the query is not a
58
+ * subsequence of the path at all.
59
+ */
60
+ export function atEmbed(lowerPath, lowerQuery) {
61
+ if (lowerQuery === "")
62
+ return [];
63
+ // pass 1 — forward, to the earliest completing index
64
+ let qi = 0;
65
+ let end = -1;
66
+ for (let pi = 0; pi < lowerPath.length; pi += 1) {
67
+ if (lowerPath[pi] === lowerQuery[qi]) {
68
+ qi += 1;
69
+ if (qi === lowerQuery.length) {
70
+ end = pi;
71
+ break;
72
+ }
73
+ }
74
+ }
75
+ if (end === -1)
76
+ return null; // not a subsequence — no embedding exists
77
+ // pass 2 — backward from that edge, sliding each character right
78
+ const hit = new Array(lowerQuery.length);
79
+ let pi = end;
80
+ for (let j = lowerQuery.length - 1; j >= 0; j -= 1) {
81
+ while (lowerPath[pi] !== lowerQuery[j])
82
+ pi -= 1;
83
+ hit[j] = pi;
84
+ pi -= 1;
85
+ }
86
+ return hit;
87
+ }
88
+ /** The longest run of CONSECUTIVE indices in an ascending list. An
89
+ * empty query has no run — every path ties on it, and the rank falls
90
+ * through to path length. */
91
+ export function longestRun(hit) {
92
+ let best = 0;
93
+ let run = 0;
94
+ for (let i = 0; i < hit.length; i += 1) {
95
+ run = i > 0 && hit[i] === hit[i - 1] + 1 ? run + 1 : 1;
96
+ if (run > best)
97
+ best = run;
98
+ }
99
+ return best;
100
+ }
101
+ /**
102
+ * The filter + the rank. Case-insensitive SUBSEQUENCE over the FULL
103
+ * relative path (so `@tui/ed` finds packages/tui/src/editor.ts — the
104
+ * directory is part of what the user is typing at, not a separate
105
+ * field), ordered by:
106
+ *
107
+ * 1. contiguous-run length DESC — a path where the query appears as
108
+ * a solid stretch beats one where it is scattered across the
109
+ * whole string. This is the key that makes typing feel like
110
+ * aiming rather than fishing.
111
+ * 2. path length ASC — among equally solid hits, the shorter path is
112
+ * the more likely target (src/range.js over a deep vendored copy
113
+ * of the same name).
114
+ * 3. the path itself, lexically — the tiebreak of last resort, and
115
+ * the reason the order NEVER depends on the source's iteration
116
+ * order or on two runs of the same query disagreeing.
117
+ *
118
+ * An EMPTY query matches everything: rule 1 ties at 0 for all, so the
119
+ * listing is shortest-path-first, then lexical. The list is sliced to
120
+ * AT_CAP; `capped` reports whether anything was dropped.
121
+ */
122
+ export function atFilter(items, query) {
123
+ const capped = items.length > AT_CAP;
124
+ const pool = capped ? items.slice(0, AT_CAP) : items;
125
+ const lowerQuery = query.toLowerCase();
126
+ const matches = [];
127
+ for (const item of pool) {
128
+ const hit = atEmbed(item.path.toLowerCase(), lowerQuery);
129
+ if (hit === null)
130
+ continue;
131
+ matches.push({ path: item.path, hit, run: longestRun(hit) });
132
+ }
133
+ matches.sort((a, b) => {
134
+ if (a.run !== b.run)
135
+ return b.run - a.run;
136
+ if (a.path.length !== b.path.length)
137
+ return a.path.length - b.path.length;
138
+ return a.path < b.path ? -1 : a.path > b.path ? 1 : 0;
139
+ });
140
+ return { matches, capped };
141
+ }
142
+ /**
143
+ * KC3 §4 — the picker's WINDOW: which slice of the ranked list is on
144
+ * screen. The window TRAILS the selection exactly as the composer's
145
+ * own viewport trails the cursor (KC1 §5) — derived per read, never
146
+ * stored, so it can never disagree with the selection it is meant to
147
+ * follow.
148
+ */
149
+ export function atWindow(total, selected, visible = AT_VISIBLE) {
150
+ const count = Math.min(total, visible);
151
+ const first = Math.max(0, Math.min(selected - count + 1, total - count));
152
+ return { first, count };
153
+ }
154
+ /** The path split into the two columns the panel draws: the file's own
155
+ * name, and the directory that qualifies it. A path with no slash is
156
+ * all name and no directory. */
157
+ function splitPath(path) {
158
+ const cut = path.lastIndexOf("/");
159
+ return cut === -1 ? { dir: "", name: path } : { dir: path.slice(0, cut + 1), name: path.slice(cut + 1) };
160
+ }
161
+ /**
162
+ * KC3 §4 — ONE row of the panel.
163
+ *
164
+ * Two columns: the file's NAME on the left with its matched characters
165
+ * bold, and the DIRECTORY dim on the right, pushed to the far edge.
166
+ * The name is what the user is aiming at; the directory is what tells
167
+ * two same-named files apart, which is why it is present but quiet.
168
+ *
169
+ * The selected row carries `→` on the inverse band (SGR 7, closed with
170
+ * 27 — never SGR 0, so it composes inside the row's own spans).
171
+ *
172
+ * The `hit` indices are over the FULL path, so they are shifted by the
173
+ * directory's length to land on the name. A hit that falls INSIDE the
174
+ * directory is simply not drawn bold — the directory column is
175
+ * uniformly dim by design (a bold fragment in a right-aligned dim
176
+ * column reads as damage, not as information).
177
+ *
178
+ * The row never exceeds W: the name cuts first (it is the flexible
179
+ * column), and the directory is dropped entirely before the name is
180
+ * cut to nothing.
181
+ */
182
+ export function atRow(match, selected, W) {
183
+ const p = palette();
184
+ const { dir, name } = splitPath(escapeTerminal(match.path));
185
+ const lead = selected ? `${p.rv}→ ${p.rvEnd}` : " ";
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
+ // room: W − the 2-cell lead − 1 separating space before the directory
189
+ const nameRoom = Math.max(1, W - 2 - (dir === "" ? 0 : visibleWidth(dir) + 1));
190
+ const shownName = widthCut(name, nameRoom);
191
+ let painted = "";
192
+ for (let i = 0; i < shownName.length; i += 1) {
193
+ painted += marks.has(i) ? `${p.bold}${shownName[i]}${p.reset}` : shownName[i];
194
+ }
195
+ if (dir === "")
196
+ return `${lead}${painted}`;
197
+ const pad = Math.max(1, W - 2 - visibleWidth(shownName) - visibleWidth(dir));
198
+ return `${lead}${painted}${" ".repeat(pad)}${p.dim}${dir}${p.reset}`;
199
+ }
200
+ /**
201
+ * KC3 §4 — the counter row: `(n/total)`, where n is the 1-based
202
+ * position of the SELECTION in the whole ranked list, not in the
203
+ * visible window. The user needs to know where they are in the list,
204
+ * which the five visible rows cannot tell them.
205
+ *
206
+ * When the source list was truncated the row SAYS SO. A file picker
207
+ * that quietly lists 2,000 of 40,000 files and shows a confident
208
+ * "(3/1998)" is lying by omission; this one admits the horizon.
209
+ */
210
+ export function atCounterRow(selected, total, capped, W) {
211
+ const p = palette();
212
+ const text = capped ? ` (${selected + 1}/${total}) · first ${AT_CAP} files only` : ` (${selected + 1}/${total})`;
213
+ return `${p.dim}${widthCut(text, W)}${p.reset}`;
214
+ }
215
+ /**
216
+ * KC3 §4 — the whole band: at most AT_VISIBLE windowed rows, then the
217
+ * counter. Returned as plain strings for the menu-rows channel, which
218
+ * already accounts them in chromeRows — the picker needs no geometry
219
+ * of its own, which is the entire reason it rides that channel.
220
+ */
221
+ export function atPanelRows(state, W) {
222
+ const { first, count } = atWindow(state.matches.length, state.selected);
223
+ const rows = [];
224
+ for (let i = first; i < first + count; i += 1)
225
+ rows.push(atRow(state.matches[i], i === state.selected, W));
226
+ rows.push(atCounterRow(state.selected, state.matches.length, state.capped, W));
227
+ return rows;
228
+ }
@@ -43,7 +43,14 @@
43
43
  * line-mode bytes byte-for-byte (the e2e guards them).
44
44
  */
45
45
  import { type MenuItem } from "./editor.js";
46
- import { type PanelState } from "./approval-panel.js";
46
+ import type { PanelState } from "./approval-panel.js";
47
+ import { type AtMatch } from "./at-picker.js";
48
+ /** KC3 §4 — the @ picker's bound state (the editor's atState()). */
49
+ export interface AtPanelState {
50
+ readonly matches: readonly AtMatch[];
51
+ readonly selected: number;
52
+ readonly capped: boolean;
53
+ }
47
54
  import { type ResumeMeta } from "./render.js";
48
55
  /** The cursor marker — an APC private sequence the focus component
49
56
  * embeds at the edit position; the compositor strips it and moves
@@ -205,6 +212,10 @@ export declare class Body {
205
212
  items: readonly MenuItem[];
206
213
  selected: number;
207
214
  } | null): void;
215
+ /** KC3 §4: bind the editor's @ file picker. It shares the menu's
216
+ * band — see #menuRows for why that is a decision and not a
217
+ * shortcut. */
218
+ bindAt(state: () => AtPanelState | null): void;
208
219
  /** Bind the pending-turn queue — the CLI's live slots (chat.ts):
209
220
  * the chips render in the menu-rows family, the live caps shrink
210
221
  * by their rows, and the +N queued hint rides the status row. */
@@ -246,6 +257,10 @@ export declare class Dock {
246
257
  items: readonly MenuItem[];
247
258
  selected: number;
248
259
  } | null): void;
260
+ /** KC3 §4: bind the editor's @ picker — the SAME band as the slash
261
+ * menu (see Body#menuRows). Unbound, the picker cannot render, and
262
+ * every frame is byte-identical to before the round. */
263
+ bindAt(state: () => AtPanelState | null): void;
249
264
  /** W22: bind the pending-turn queue — the chips + the +N queued
250
265
  * hint (the CLI binds it from chat(); the editor's pop keys ride
251
266
  * the LineInput's own bindQueue). */
@@ -45,7 +45,10 @@
45
45
  import { truncateDiff } from "./diff.js";
46
46
  import { displayWidth } from "./editor.js";
47
47
  import { leadWidth } from "./width.js"; // W23: the ONE width authority (the editor, #inputRow, and editCol share it)
48
- import { panelAffordance, panelBlockRows, panelLead, panelStatus } from "./approval-panel.js";
48
+ // KC3.5: the panel-slot reads come from the DISPATCHERS — one source
49
+ // for four reads, so an ask can never render half as an approval.
50
+ import { panelAffordanceOf, panelLeadOf, panelRowsOf, panelStatusOf } from "./ask-panel.js";
51
+ import { atPanelRows } from "./at-picker.js";
49
52
  import { Container, ROLLUP_NOUN, SPINNER, bodySpacing, boxBottom, boxTop, cellComponent, foldLine, pendingQueueRows, statusLine, turnFold, visibleWidth, } from "./components.js";
50
53
  import { bannerLines, escapeTerminal, foldResult, foldThinking, palette, renderTerminalGap, renderToolSummary, toolTarget } from "./render.js";
51
54
  /** The cursor marker — an APC private sequence the focus component
@@ -118,6 +121,9 @@ export class Body {
118
121
  #inputState = () => ({ line: "", cursor: 0 });
119
122
  #inputPrompt = "";
120
123
  #menuState = null;
124
+ // KC3 §4: the @ picker's bound state — the SAME band as the menu
125
+ // (see #menuRows: the two are mutually exclusive by construction).
126
+ #atState = null;
121
127
  // W22: the pending-turn queue's bound state — the CLI's live slots
122
128
  // (chat.ts); the chips render in the menu-rows family (above the
123
129
  // box top), the live caps shrink by their rows, and the status
@@ -144,6 +150,8 @@ export class Body {
144
150
  this.#inputPrompt = dockBindings.prompt;
145
151
  if (dockBindings.menu !== null)
146
152
  this.#menuState = dockBindings.menu;
153
+ if (dockBindings.at !== null)
154
+ this.#atState = dockBindings.at;
147
155
  this.#panelState = dockBindings.panel;
148
156
  if (dockBindings.queue !== null)
149
157
  this.#queueState = dockBindings.queue;
@@ -651,7 +659,7 @@ export class Body {
651
659
  #statusSource() {
652
660
  const panel = this.#panelState?.() ?? null;
653
661
  if (panel !== null)
654
- return { status: panelStatus(panel.view, panel.phase, panel.sel), hint: panelAffordance(panel.view, panel.phase, panel.sel) };
662
+ return { status: panelStatusOf(panel), hint: panelAffordanceOf(panel) };
655
663
  // W22: while turns wait in the queue, the right hint shows the
656
664
  // count — the chips below carry the lines themselves.
657
665
  const queued = this.#queueState?.().length ?? 0;
@@ -675,6 +683,12 @@ export class Body {
675
683
  bindMenu(state) {
676
684
  this.#menuState = state;
677
685
  }
686
+ /** KC3 §4: bind the editor's @ file picker. It shares the menu's
687
+ * band — see #menuRows for why that is a decision and not a
688
+ * shortcut. */
689
+ bindAt(state) {
690
+ this.#atState = state;
691
+ }
678
692
  /** Bind the pending-turn queue — the CLI's live slots (chat.ts):
679
693
  * the chips render in the menu-rows family, the live caps shrink
680
694
  * by their rows, and the +N queued hint rides the status row. */
@@ -692,7 +706,7 @@ export class Body {
692
706
  // + 1 — the SAME formula the marker embeds at (the panel lead when
693
707
  // the panel owns the row; the old prompt-only math desynced the
694
708
  // panel rows' edit column; leadWidth is the ONE authority)
695
- const lead = panel !== null ? panelLead(panel.view, panel.phase, panel.sel) : this.#inputPrompt;
709
+ const lead = panel !== null ? panelLeadOf(panel) : this.#inputPrompt;
696
710
  return 3 + leadWidth(lead) + st.cursor;
697
711
  }
698
712
  /** The old dock's redraw — the editor's onRender target: mark + the
@@ -766,7 +780,7 @@ export class Body {
766
780
  // W21: the panel's own rows (the cap is exact — the scalar
767
781
  // reflects the screen). W22: the queue chips occupy their
768
782
  // own band — the panel's cap shrinks by their rows.
769
- return (panelBlockRows(panel.view, panel.phase, panel.sel, this.#opts.width(), Math.max(1, this.#opts.height() - 4 - inputExtra - queueRows.length)).length +
783
+ return (panelRowsOf(panel, this.#opts.width(), Math.max(1, this.#opts.height() - 4 - inputExtra - queueRows.length)).length +
770
784
  CHROME_ROWS +
771
785
  inputExtra +
772
786
  queueRows.length);
@@ -853,7 +867,7 @@ export class Body {
853
867
  // the W11 blank would separate it from the frozen content).
854
868
  // The cap is exact, so the force-commit loop never fires. W22:
855
869
  // the queue band sits below the panel — the cap shrinks by it.
856
- liveLines = panelBlockRows(panel.view, panel.phase, panel.sel, W, Math.max(1, H - 4 - inputExtra - queueRows.length));
870
+ liveLines = panelRowsOf(panel, W, Math.max(1, H - 4 - inputExtra - queueRows.length));
857
871
  }
858
872
  else {
859
873
  let prev = this.#committed > 0 ? this.#lineCache[this.#committed - 1] : null;
@@ -1098,7 +1112,24 @@ export class Body {
1098
1112
  const hidden = lines.length - keep;
1099
1113
  return [...pendingQueueRows(lines.slice(0, keep), W), `${p.dim}□ …${hidden} more queued${p.reset}`];
1100
1114
  }
1115
+ /**
1116
+ * The band ABOVE the box top. Two occupants share it — the slash
1117
+ * menu and (KC3 §4) the @ file picker.
1118
+ *
1119
+ * Sharing is the design, not an economy. This band is already
1120
+ * counted in chromeRows, already shrinks the live content cap,
1121
+ * already redraws with the frame, and already clamps the composer's
1122
+ * visible rows; a picker with a band of its own would have to
1123
+ * re-derive every one of those and could disagree with any of them.
1124
+ * The two occupants are mutually exclusive BY CONSTRUCTION — the
1125
+ * editor's precedence gate keeps the picker shut whenever the menu
1126
+ * is open — so one function can own the band without either
1127
+ * occupant knowing the other exists.
1128
+ */
1101
1129
  #menuRows(W) {
1130
+ const at = this.#atState?.() ?? null;
1131
+ if (at !== null)
1132
+ return atPanelRows(at, W);
1102
1133
  const menu = this.#menuState?.();
1103
1134
  if (menu === null || menu === undefined || menu.items.length === 0)
1104
1135
  return [];
@@ -1195,7 +1226,7 @@ export class Body {
1195
1226
  // the lead — the panel's phase lead when the panel owns the row
1196
1227
  // (1-3> / the rule input's "2 Yes, don't ask again for " / the
1197
1228
  // amend "feedback (deny): "), the bound prompt otherwise
1198
- const lead = panel !== null ? panelLead(panel.view, panel.phase, panel.sel) : this.#inputPrompt;
1229
+ const lead = panel !== null ? panelLeadOf(panel) : this.#inputPrompt;
1199
1230
  const leadW = leadWidth(lead);
1200
1231
  // a LEGACY one-row provider (the old {line, cursor} shape) keeps
1201
1232
  // working: its single line is the composer's single row
@@ -1589,6 +1620,16 @@ export class Dock {
1589
1620
  }
1590
1621
  compositorRef.bindMenu(state);
1591
1622
  }
1623
+ /** KC3 §4: bind the editor's @ picker — the SAME band as the slash
1624
+ * menu (see Body#menuRows). Unbound, the picker cannot render, and
1625
+ * every frame is byte-identical to before the round. */
1626
+ bindAt(state) {
1627
+ if (compositorRef === null) {
1628
+ dockBindings.at = state;
1629
+ return;
1630
+ }
1631
+ compositorRef.bindAt(state);
1632
+ }
1592
1633
  /** W22: bind the pending-turn queue — the chips + the +N queued
1593
1634
  * hint (the CLI binds it from chat(); the editor's pop keys ride
1594
1635
  * the LineInput's own bindQueue). */
@@ -1614,4 +1655,4 @@ let compositorRef = null;
1614
1655
  * — the old snapshot froze `menu` at bindInput time and the slash-
1615
1656
  * command menu silently never bound in the real CLI (the e2e gates
1616
1657
  * bind the Body directly and could not see it). */
1617
- const dockBindings = { state: null, prompt: "", menu: null, panel: null, queue: null };
1658
+ const dockBindings = { state: null, prompt: "", menu: null, at: null, panel: null, queue: null };
package/dist/editor.d.ts CHANGED
@@ -23,7 +23,8 @@
23
23
  */
24
24
  import { charWidth, displayWidth, widthOf } from "./width.js";
25
25
  export { charWidth, displayWidth, widthOf };
26
- import { type PanelState, type PanelVerdict, type PanelView } from "./approval-panel.js";
26
+ import type { PanelState, PanelVerdict, PanelView } from "./approval-panel.js";
27
+ import { type AtItem, type AtMatch } from "./at-picker.js";
27
28
  export declare const PROMPT = "\u258C ";
28
29
  export declare const PROMPT_WIDTH: number;
29
30
  /** v3 §04 — the slash-command menu's command table (English one-liners). */
@@ -83,6 +84,20 @@ export declare class Editor {
83
84
  items: readonly MenuItem[];
84
85
  selected: number;
85
86
  } | null;
87
+ /** KC3 §3 — bind the file source. The tui owns no file list and
88
+ * never touches a disk (input is data, output is bytes): the CLI
89
+ * feeds the paths, and until it does, the picker cannot open at
90
+ * all — which is exactly why every non-@ scenario and every
91
+ * consumer that does not bind (the recovery flow, the existing
92
+ * gates) is byte-identical. */
93
+ bindAtItems(source: () => readonly AtItem[]): void;
94
+ /** KC3 §4 — the picker's visible state for the dock; null when
95
+ * closed. The compositor windows it and draws the counter. */
96
+ atState(): {
97
+ matches: readonly AtMatch[];
98
+ selected: number;
99
+ capped: boolean;
100
+ } | null;
86
101
  /** One-shot question mode: the NEXT submit answers, not a turn. */
87
102
  question(_query: string, cb: (answer: string) => void): void;
88
103
  /** Cancel a pending question — the buffer stays (its text becomes the