@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.
- package/dist/ask-panel.d.ts +95 -0
- package/dist/ask-panel.js +268 -0
- package/dist/at-picker.d.ts +159 -0
- package/dist/at-picker.js +228 -0
- package/dist/compositor.d.ts +16 -1
- package/dist/compositor.js +48 -7
- package/dist/editor.d.ts +16 -1
- package/dist/editor.js +271 -7
- package/dist/index.d.ts +4 -0
- package/dist/index.js +14 -0
- package/dist/strings.d.ts +6 -0
- package/dist/strings.js +6 -0
- package/package.json +2 -2
|
@@ -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
|
+
}
|
package/dist/compositor.d.ts
CHANGED
|
@@ -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 {
|
|
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). */
|
package/dist/compositor.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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 ?
|
|
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 (
|
|
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 =
|
|
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 ?
|
|
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 {
|
|
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
|