@vincemakes/kiso-tui 0.5.0 → 0.7.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,159 @@
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
+ /** The bound source's item — a repo-relative path and nothing else.
16
+ * Structural: the CLI passes whatever it likes as long as it has a
17
+ * path (slice 5 passes exactly this). */
18
+ export interface AtItem {
19
+ readonly path: string;
20
+ }
21
+ /** A matched path plus the indices the panel emboldens. */
22
+ export interface AtMatch {
23
+ readonly path: string;
24
+ /** the matched character positions, ascending — the panel renders
25
+ * these bold-white and the rest dim */
26
+ readonly hit: readonly number[];
27
+ /** the longest CONTIGUOUS run inside `hit` — the rank's first key,
28
+ * carried so the panel and the ranking can never disagree about
29
+ * why a row is where it is */
30
+ readonly run: number;
31
+ }
32
+ /**
33
+ * KC3 §5 — the ONE cap. The file list is computed per open with no
34
+ * index and no watcher, so its cost is bounded here rather than
35
+ * amortized somewhere invisible. The source collects at most CAP + 1
36
+ * entries: the extra one is what makes "there were more" DISTINGUISHABLE
37
+ * from "there were exactly this many", so the counter row can say so
38
+ * honestly instead of guessing.
39
+ */
40
+ export declare const AT_CAP = 2000;
41
+ /**
42
+ * KC3 §5 — the directories the picker never offers, and the other half
43
+ * of its contract with whatever host has to walk a tree to fill it.
44
+ *
45
+ * It lives here beside the cap because the two are the same kind of
46
+ * promise: a host that walks must prune these BEFORE descending (a
47
+ * post-filter would already have walked node_modules, which is the
48
+ * cost the pruning exists to avoid), and must stop at the cap. Hosts
49
+ * that get their list from a VCS ignore this set entirely — the VCS
50
+ * has already applied a better one.
51
+ */
52
+ export declare const AT_SKIP: ReadonlySet<string>;
53
+ /** KC3 §4 — the panel's visible height. A ceiling, not a promise: the
54
+ * compositor clamps further when the terminal is short. */
55
+ export declare const AT_VISIBLE = 5;
56
+ /**
57
+ * The subsequence embedding, TIGHTENED — the two-pass walk every good
58
+ * fuzzy finder uses, and the reason `@ra` emboldens the "ra" of
59
+ * "src/range.js" rather than the r of "src" and the a of "range".
60
+ *
61
+ * Pass 1 walks forward and stops at the EARLIEST index that completes
62
+ * the query — this both answers "does it match at all" and fixes the
63
+ * right-hand edge. Pass 2 walks backward from that edge, taking the
64
+ * LATEST position for each query character in turn, which slides every
65
+ * matched character as far right as it can go without crossing the
66
+ * next one. The result is the most clustered embedding that ends where
67
+ * the earliest match ends.
68
+ *
69
+ * Case-insensitive: both sides are lowercased by the caller once per
70
+ * query rather than once per character.
71
+ *
72
+ * Returns the ascending match indices, or null when the query is not a
73
+ * subsequence of the path at all.
74
+ */
75
+ export declare function atEmbed(lowerPath: string, lowerQuery: string): number[] | null;
76
+ /** The longest run of CONSECUTIVE indices in an ascending list. An
77
+ * empty query has no run — every path ties on it, and the rank falls
78
+ * through to path length. */
79
+ export declare function longestRun(hit: readonly number[]): number;
80
+ /**
81
+ * The filter + the rank. Case-insensitive SUBSEQUENCE over the FULL
82
+ * relative path (so `@tui/ed` finds packages/tui/src/editor.ts — the
83
+ * directory is part of what the user is typing at, not a separate
84
+ * field), ordered by:
85
+ *
86
+ * 1. contiguous-run length DESC — a path where the query appears as
87
+ * a solid stretch beats one where it is scattered across the
88
+ * whole string. This is the key that makes typing feel like
89
+ * aiming rather than fishing.
90
+ * 2. path length ASC — among equally solid hits, the shorter path is
91
+ * the more likely target (src/range.js over a deep vendored copy
92
+ * of the same name).
93
+ * 3. the path itself, lexically — the tiebreak of last resort, and
94
+ * the reason the order NEVER depends on the source's iteration
95
+ * order or on two runs of the same query disagreeing.
96
+ *
97
+ * An EMPTY query matches everything: rule 1 ties at 0 for all, so the
98
+ * listing is shortest-path-first, then lexical. The list is sliced to
99
+ * AT_CAP; `capped` reports whether anything was dropped.
100
+ */
101
+ export declare function atFilter(items: readonly AtItem[], query: string): {
102
+ matches: AtMatch[];
103
+ capped: boolean;
104
+ };
105
+ /**
106
+ * KC3 §4 — the picker's WINDOW: which slice of the ranked list is on
107
+ * screen. The window TRAILS the selection exactly as the composer's
108
+ * own viewport trails the cursor (KC1 §5) — derived per read, never
109
+ * stored, so it can never disagree with the selection it is meant to
110
+ * follow.
111
+ */
112
+ export declare function atWindow(total: number, selected: number, visible?: number): {
113
+ first: number;
114
+ count: number;
115
+ };
116
+ /**
117
+ * KC3 §4 — ONE row of the panel.
118
+ *
119
+ * Two columns: the file's NAME on the left with its matched characters
120
+ * bold, and the DIRECTORY dim on the right, pushed to the far edge.
121
+ * The name is what the user is aiming at; the directory is what tells
122
+ * two same-named files apart, which is why it is present but quiet.
123
+ *
124
+ * The selected row carries `→` on the inverse band (SGR 7, closed with
125
+ * 27 — never SGR 0, so it composes inside the row's own spans).
126
+ *
127
+ * The `hit` indices are over the FULL path, so they are shifted by the
128
+ * directory's length to land on the name. A hit that falls INSIDE the
129
+ * directory is simply not drawn bold — the directory column is
130
+ * uniformly dim by design (a bold fragment in a right-aligned dim
131
+ * column reads as damage, not as information).
132
+ *
133
+ * The row never exceeds W: the name cuts first (it is the flexible
134
+ * column), and the directory is dropped entirely before the name is
135
+ * cut to nothing.
136
+ */
137
+ export declare function atRow(match: AtMatch, selected: boolean, W: number): string;
138
+ /**
139
+ * KC3 §4 — the counter row: `(n/total)`, where n is the 1-based
140
+ * position of the SELECTION in the whole ranked list, not in the
141
+ * visible window. The user needs to know where they are in the list,
142
+ * which the five visible rows cannot tell them.
143
+ *
144
+ * When the source list was truncated the row SAYS SO. A file picker
145
+ * that quietly lists 2,000 of 40,000 files and shows a confident
146
+ * "(3/1998)" is lying by omission; this one admits the horizon.
147
+ */
148
+ export declare function atCounterRow(selected: number, total: number, capped: boolean, W: number): string;
149
+ /**
150
+ * KC3 §4 — the whole band: at most AT_VISIBLE windowed rows, then the
151
+ * counter. Returned as plain strings for the menu-rows channel, which
152
+ * already accounts them in chromeRows — the picker needs no geometry
153
+ * of its own, which is the entire reason it rides that channel.
154
+ */
155
+ export declare function atPanelRows(state: {
156
+ matches: readonly AtMatch[];
157
+ selected: number;
158
+ capped: boolean;
159
+ }, W: number): string[];
@@ -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
+ }
@@ -44,6 +44,13 @@
44
44
  */
45
45
  import { type MenuItem } from "./editor.js";
46
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). */
@@ -46,6 +46,7 @@ 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
  import { panelAffordance, panelBlockRows, panelLead, panelStatus } from "./approval-panel.js";
49
+ import { atPanelRows } from "./at-picker.js";
49
50
  import { Container, ROLLUP_NOUN, SPINNER, bodySpacing, boxBottom, boxTop, cellComponent, foldLine, pendingQueueRows, statusLine, turnFold, visibleWidth, } from "./components.js";
50
51
  import { bannerLines, escapeTerminal, foldResult, foldThinking, palette, renderTerminalGap, renderToolSummary, toolTarget } from "./render.js";
51
52
  /** The cursor marker — an APC private sequence the focus component
@@ -118,6 +119,9 @@ export class Body {
118
119
  #inputState = () => ({ line: "", cursor: 0 });
119
120
  #inputPrompt = "";
120
121
  #menuState = null;
122
+ // KC3 §4: the @ picker's bound state — the SAME band as the menu
123
+ // (see #menuRows: the two are mutually exclusive by construction).
124
+ #atState = null;
121
125
  // W22: the pending-turn queue's bound state — the CLI's live slots
122
126
  // (chat.ts); the chips render in the menu-rows family (above the
123
127
  // box top), the live caps shrink by their rows, and the status
@@ -144,6 +148,8 @@ export class Body {
144
148
  this.#inputPrompt = dockBindings.prompt;
145
149
  if (dockBindings.menu !== null)
146
150
  this.#menuState = dockBindings.menu;
151
+ if (dockBindings.at !== null)
152
+ this.#atState = dockBindings.at;
147
153
  this.#panelState = dockBindings.panel;
148
154
  if (dockBindings.queue !== null)
149
155
  this.#queueState = dockBindings.queue;
@@ -675,6 +681,12 @@ export class Body {
675
681
  bindMenu(state) {
676
682
  this.#menuState = state;
677
683
  }
684
+ /** KC3 §4: bind the editor's @ file picker. It shares the menu's
685
+ * band — see #menuRows for why that is a decision and not a
686
+ * shortcut. */
687
+ bindAt(state) {
688
+ this.#atState = state;
689
+ }
678
690
  /** Bind the pending-turn queue — the CLI's live slots (chat.ts):
679
691
  * the chips render in the menu-rows family, the live caps shrink
680
692
  * by their rows, and the +N queued hint rides the status row. */
@@ -1098,7 +1110,24 @@ export class Body {
1098
1110
  const hidden = lines.length - keep;
1099
1111
  return [...pendingQueueRows(lines.slice(0, keep), W), `${p.dim}□ …${hidden} more queued${p.reset}`];
1100
1112
  }
1113
+ /**
1114
+ * The band ABOVE the box top. Two occupants share it — the slash
1115
+ * menu and (KC3 §4) the @ file picker.
1116
+ *
1117
+ * Sharing is the design, not an economy. This band is already
1118
+ * counted in chromeRows, already shrinks the live content cap,
1119
+ * already redraws with the frame, and already clamps the composer's
1120
+ * visible rows; a picker with a band of its own would have to
1121
+ * re-derive every one of those and could disagree with any of them.
1122
+ * The two occupants are mutually exclusive BY CONSTRUCTION — the
1123
+ * editor's precedence gate keeps the picker shut whenever the menu
1124
+ * is open — so one function can own the band without either
1125
+ * occupant knowing the other exists.
1126
+ */
1101
1127
  #menuRows(W) {
1128
+ const at = this.#atState?.() ?? null;
1129
+ if (at !== null)
1130
+ return atPanelRows(at, W);
1102
1131
  const menu = this.#menuState?.();
1103
1132
  if (menu === null || menu === undefined || menu.items.length === 0)
1104
1133
  return [];
@@ -1589,6 +1618,16 @@ export class Dock {
1589
1618
  }
1590
1619
  compositorRef.bindMenu(state);
1591
1620
  }
1621
+ /** KC3 §4: bind the editor's @ picker — the SAME band as the slash
1622
+ * menu (see Body#menuRows). Unbound, the picker cannot render, and
1623
+ * every frame is byte-identical to before the round. */
1624
+ bindAt(state) {
1625
+ if (compositorRef === null) {
1626
+ dockBindings.at = state;
1627
+ return;
1628
+ }
1629
+ compositorRef.bindAt(state);
1630
+ }
1592
1631
  /** W22: bind the pending-turn queue — the chips + the +N queued
1593
1632
  * hint (the CLI binds it from chat(); the editor's pop keys ride
1594
1633
  * the LineInput's own bindQueue). */
@@ -1614,4 +1653,4 @@ let compositorRef = null;
1614
1653
  * — the old snapshot froze `menu` at bindInput time and the slash-
1615
1654
  * command menu silently never bound in the real CLI (the e2e gates
1616
1655
  * bind the Body directly and could not see it). */
1617
- const dockBindings = { state: null, prompt: "", menu: null, panel: null, queue: null };
1656
+ const dockBindings = { state: null, prompt: "", menu: null, at: null, panel: null, queue: null };
package/dist/editor.d.ts CHANGED
@@ -24,6 +24,7 @@
24
24
  import { charWidth, displayWidth, widthOf } from "./width.js";
25
25
  export { charWidth, displayWidth, widthOf };
26
26
  import { type PanelState, type PanelVerdict, type 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). */
@@ -48,6 +49,11 @@ export declare class Editor {
48
49
  onEot(cb: () => void): void;
49
50
  onEscape(cb: () => void): void;
50
51
  onExpand(cb: () => void): void;
52
+ /** KC2 §2: the redirect chain — the gesture hands the buffer's text
53
+ * over while the run is told to stop. Mirrors onEscape (a list, so
54
+ * listeners can coexist); the line arrives already gone from the
55
+ * composer, exactly as a submit's does. */
56
+ onRedirect(cb: (line: string) => void): void;
51
57
  /** W22: bind the pending-turn queue — the CLI's live slots. The ↑
52
58
  * pop walks them (each pop leaves the queue, cancelling the turn);
53
59
  * esc ends the walk after one more pop. */
@@ -78,6 +84,20 @@ export declare class Editor {
78
84
  items: readonly MenuItem[];
79
85
  selected: number;
80
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;
81
101
  /** One-shot question mode: the NEXT submit answers, not a turn. */
82
102
  question(_query: string, cb: (answer: string) => void): void;
83
103
  /** Cancel a pending question — the buffer stays (its text becomes the
package/dist/editor.js CHANGED
@@ -27,6 +27,7 @@ import { charWidth, displayWidth, leadWidth, widthOf } from "./width.js";
27
27
  export { charWidth, displayWidth, widthOf };
28
28
  import { palette } from "./render.js";
29
29
  import { panelLead } from "./approval-panel.js";
30
+ import { AT_VISIBLE, atFilter } from "./at-picker.js";
30
31
  // TUI v4 #16d: the input row is the blue brick + the edit area — the
31
32
  // "you>" text is gone (the brick IS the prompt; the pipe path's readline
32
33
  // prompt keeps its own "you> " — v2a line mode, byte-for-byte).
@@ -44,6 +45,13 @@ export const MENU_ITEMS = [
44
45
  /** KC1 §3 — the newline code point. Every source (paste, Ctrl+J, the
45
46
  * Shift+Enter encodings, a CRLF pair) normalizes to exactly ONE. */
46
47
  const NEWLINE = 0x0a;
48
+ /** KC3 §3 — the picker's sigil, and the two characters that count as a
49
+ * word boundary before it. A `@` anywhere else (vince@example.com) is
50
+ * an ordinary character: the reference is a thing you START, not a
51
+ * thing an address accidentally becomes. */
52
+ const AT = 0x40;
53
+ const SPACE = 0x20;
54
+ const TAB = 0x09;
47
55
  /** KC1 §5 — the composer's CEILING (adjudication A1): at most 6 visible
48
56
  * rows. A ceiling only — N_visible clamps by the terminal's height so
49
57
  * the geometry stays legal down to the compositor's enter gate (H = 4
@@ -87,6 +95,12 @@ export class Editor {
87
95
  // (dispatch) coexist; a listener removes itself via an unarmed guard
88
96
  // (the compact's handler no-ops after its abort has fired).
89
97
  #escapeCbs = [];
98
+ // KC2 §2: the redirect LIST — mirrors #escapeCbs. The editor FORWARDS
99
+ // the gesture with the buffer's text; it never interprets it. What a
100
+ // redirect MEANS (abort the run, then run THIS ahead of the queue) is
101
+ // the CLI's — here it is only "these two keys, pressed together, hand
102
+ // the line over by a different door than Enter's".
103
+ #redirectCbs = [];
90
104
  // W15: the expand-key list (ctrl+r) — the CLI's dispatch decides the
91
105
  // target (a live cell toggles in place; a committed cell appends the
92
106
  // expanded block). Mirrors the escape list: multiple listeners can
@@ -95,6 +109,22 @@ export class Editor {
95
109
  #onRender;
96
110
  #menuOpen = false; // v3 §04: the slash-command menu
97
111
  #menuSel = 0;
112
+ // KC3 §3 — the @ file picker. THREE fields and no more: the armed
113
+ // bit, the selection, and the per-open SNAPSHOT of the file list.
114
+ // The query is deliberately NOT stored — it is derived from the
115
+ // buffer and the cursor on every read (the KC1 flat-buffer
116
+ // discipline: never a second mutable model). That is what makes
117
+ // backspacing past the `@` close the picker with no handler
118
+ // anywhere, and what keeps every existing op — the kills, paste,
119
+ // the history stash, the queue-pop replace — correct for free.
120
+ #atOpen = false;
121
+ #atSel = 0;
122
+ // the list is snapshotted AT OPEN and held for that open's lifetime
123
+ // (§4: no index, no watcher, no re-listing per keystroke). An armed
124
+ // bit with no token under the cursor is inert by construction — the
125
+ // next open re-snapshots, so a stale list can never be shown.
126
+ #atList = null;
127
+ #atItems = null;
98
128
  // A2 (the feel): the session-scoped input history — every submitted TURN
99
129
  // line (never a question answer), capped at 100, never persisted. ↑↓
100
130
  // navigate it ONLY from an empty input or while already browsing.
@@ -144,6 +174,13 @@ export class Editor {
144
174
  onExpand(cb) {
145
175
  this.#expandCbs.push(cb);
146
176
  }
177
+ /** KC2 §2: the redirect chain — the gesture hands the buffer's text
178
+ * over while the run is told to stop. Mirrors onEscape (a list, so
179
+ * listeners can coexist); the line arrives already gone from the
180
+ * composer, exactly as a submit's does. */
181
+ onRedirect(cb) {
182
+ this.#redirectCbs.push(cb);
183
+ }
147
184
  /** W22: bind the pending-turn queue — the CLI's live slots. The ↑
148
185
  * pop walks them (each pop leaves the queue, cancelling the turn);
149
186
  * esc ends the walk after one more pop. */
@@ -202,7 +239,7 @@ export class Editor {
202
239
  * the frame's clamp is the authority. */
203
240
  #visibleRows(lineCount) {
204
241
  const H = process.stdout.rows ?? 24;
205
- const bands = (this.#menuOpen ? this.#menuFiltered().length : 0) + this.#queueState().length;
242
+ const bands = (this.#menuOpen ? this.#menuFiltered().length : 0) + this.#atRows() + this.#queueState().length;
206
243
  return Math.max(1, Math.min(lineCount, N_MAX, Math.max(1, H - 3 - bands)));
207
244
  }
208
245
  /** The dock's input-row state — ADDITIVE (§5): `line` + `cursor` keep
@@ -263,6 +300,128 @@ export class Editor {
263
300
  this.#menuSel = 0;
264
301
  this.#onRender();
265
302
  }
303
+ /** KC3 §3 — bind the file source. The tui owns no file list and
304
+ * never touches a disk (input is data, output is bytes): the CLI
305
+ * feeds the paths, and until it does, the picker cannot open at
306
+ * all — which is exactly why every non-@ scenario and every
307
+ * consumer that does not bind (the recovery flow, the existing
308
+ * gates) is byte-identical. */
309
+ bindAtItems(source) {
310
+ this.#atItems = source;
311
+ }
312
+ /**
313
+ * KC3 §3 — the token under the cursor, DERIVED. Scans back from the
314
+ * cursor within the CURSOR'S LINE for the `@` that opens it:
315
+ * - whitespace before finding one → there is no token (the space
316
+ * ended it);
317
+ * - an `@` that is not itself at a word boundary → inert (the
318
+ * email case: the `@` of vince@example.com opens nothing);
319
+ * - otherwise the token runs from that `@` to the CURSOR — never
320
+ * to the end of the line, so `@ra|.js` narrows on "ra".
321
+ * Line-local: the start of any line of a multi-line composer is a
322
+ * boundary, exactly like the start of the buffer.
323
+ */
324
+ #atToken() {
325
+ const b = this.#cursorBounds();
326
+ for (let i = this.#cursor - 1; i >= b.start; i -= 1) {
327
+ const cp = this.#chars[i];
328
+ if (cp === SPACE || cp === TAB)
329
+ return null;
330
+ if (cp !== AT)
331
+ continue;
332
+ const before = i > b.start ? this.#chars[i - 1] : null;
333
+ if (before !== null && before !== SPACE && before !== TAB)
334
+ return null; // mid-word
335
+ return { start: i, query: String.fromCodePoint(...this.#chars.slice(i + 1, this.#cursor)) };
336
+ }
337
+ return null;
338
+ }
339
+ /** KC3 §3 — the picker's full state, or null when it is not up. Up
340
+ * requires ALL of: armed, nobody with higher precedence holding the
341
+ * keys, a live token under the cursor, and at least one match (the
342
+ * menu's precedent — a panel with nothing in it is noise, and the
343
+ * keys fall back to their ordinary meanings). */
344
+ #atView() {
345
+ if (!this.#atOpen || this.#atList === null)
346
+ return null;
347
+ if (this.#panel !== null || this.#menuOpen)
348
+ return null;
349
+ const token = this.#atToken();
350
+ if (token === null)
351
+ return null;
352
+ const { matches, capped } = atFilter(this.#atList, token.query);
353
+ if (matches.length === 0)
354
+ return null;
355
+ // the selection CLAMPS at read time rather than being corrected
356
+ // on every edit — narrowing the query can only ever shrink the
357
+ // list, and a clamp is the whole correction that needs
358
+ return { matches, selected: Math.min(this.#atSel, matches.length - 1), capped, start: token.start };
359
+ }
360
+ #atUp() {
361
+ return this.#atView() !== null;
362
+ }
363
+ /** KC3 §4 — the picker's visible state for the dock; null when
364
+ * closed. The compositor windows it and draws the counter. */
365
+ atState() {
366
+ const view = this.#atView();
367
+ if (view === null)
368
+ return null;
369
+ return { matches: view.matches, selected: view.selected, capped: view.capped };
370
+ }
371
+ /** KC3 §3 — arm the picker at a freshly typed `@`. The gate is the
372
+ * KC2 precedence pattern: the approval panel, the slash menu and a
373
+ * pending question each own the keys first. A paste is literal text
374
+ * (guarded by the caller). The history browse and the queue-pop
375
+ * walk are NOT re-tested here because typing has already ended them
376
+ * — #insert leaves both before a character ever lands. */
377
+ #atArm() {
378
+ if (this.#atItems === null)
379
+ return;
380
+ if (this.#panel !== null || this.#menuOpen || this.#questionCb !== null)
381
+ return;
382
+ if (this.#atToken() === null)
383
+ return; // not at a word boundary
384
+ this.#atOpen = true;
385
+ this.#atSel = 0;
386
+ this.#atList = this.#atItems(); // §5: listed per OPEN, never per keystroke
387
+ }
388
+ #atClose() {
389
+ this.#atOpen = false;
390
+ this.#atSel = 0;
391
+ this.#atList = null;
392
+ }
393
+ /**
394
+ * KC3 §3 — accept: the token becomes `@<path> `.
395
+ *
396
+ * The CANONICAL PATH and a trailing space, and nothing else — the
397
+ * file's CONTENT is never inserted. That is the whole product
398
+ * decision: the model is handed a reference it can choose to read,
399
+ * so an @ mention costs a path's worth of tokens instead of a
400
+ * file's, and the model's own read_file call is what pays for the
401
+ * bytes it actually needs.
402
+ *
403
+ * Only [token.start, cursor) is replaced, so text after the cursor
404
+ * survives and a multi-line buffer keeps every other line.
405
+ */
406
+ #atAccept() {
407
+ const view = this.#atView();
408
+ if (view === null)
409
+ return;
410
+ const insert = [...`@${view.matches[view.selected].path} `].map((ch) => ch.codePointAt(0));
411
+ this.#chars.splice(view.start, this.#cursor - view.start, ...insert);
412
+ this.#cursor = view.start + insert.length;
413
+ this.#atClose();
414
+ this.#reflow();
415
+ this.#onRender();
416
+ }
417
+ /** KC3 §4 — the picker's band height, the editor's honest estimate
418
+ * (the compositor re-applies the clamp against the frame's REAL
419
+ * folded rows, exactly as it does for the menu): the windowed rows
420
+ * plus the counter row. */
421
+ #atRows() {
422
+ const view = this.#atView();
423
+ return view === null ? 0 : Math.min(view.matches.length, AT_VISIBLE) + 1;
424
+ }
266
425
  /** One-shot question mode: the NEXT submit answers, not a turn. */
267
426
  question(_query, cb) {
268
427
  this.#questionCb = cb;
@@ -291,6 +450,7 @@ export class Editor {
291
450
  this.#menuOpen = false;
292
451
  this.#menuSel = 0;
293
452
  this.#queuePopMode = false; // W22: the panel owns the keys while up
453
+ this.#atClose(); // KC3 §3: and the picker closes with everything else
294
454
  this.#onRender();
295
455
  }
296
456
  /** W21: cancel the panel — the SIGINT path's pair to beginPanel. */
@@ -408,6 +568,17 @@ export class Editor {
408
568
  else if (rest.startsWith("O")) {
409
569
  i += 3; // SS3 (function keys) — ignored
410
570
  }
571
+ else if (rest.startsWith("\x0d") && this.#composerIdle()) {
572
+ // KC2 §2 — Alt+Enter. A terminal sends Alt+X as ESC and X in
573
+ // ONE write, so SAME-CHUNK is the whole test: no timer, no
574
+ // hold, nothing parked. The identical two bytes arriving in
575
+ // SEPARATE chunks are NOT combined — they fall to the branch
576
+ // below, where the bare Esc fires at once (its immediacy is
577
+ // exactly what a hold would spend) and the next chunk's CR
578
+ // submits: today's two gestures, untouched.
579
+ this.#redirect();
580
+ i += 2; // both bytes belong to the one gesture
581
+ }
411
582
  else if (this.#menuOpen) {
412
583
  // v3 §04: Esc closes the menu and clears the buffer.
413
584
  // CA-4: the closing esc consumes its burst (the `i += 1`
@@ -419,6 +590,17 @@ export class Editor {
419
590
  this.#refreshMenu();
420
591
  i += 1;
421
592
  }
593
+ else if (this.#atUp()) {
594
+ // KC3 §3: esc closes the picker and leaves the BUFFER
595
+ // ALONE — unlike the menu's esc, which clears it. The
596
+ // sentence around the reference is still being written,
597
+ // and a dismissed picker must not take it away. CA-4:
598
+ // the closing esc consumes its burst, so it can never
599
+ // also abort the run.
600
+ this.#atClose();
601
+ this.#onRender();
602
+ i += 1;
603
+ }
422
604
  else if (this.#queuePopMode) {
423
605
  // W22: esc in the pop-mode — ONE more pop, then the
424
606
  // mode ends: the next esc at rest rides the escapeCbs
@@ -515,6 +697,12 @@ export class Editor {
515
697
  }
516
698
  i += 1;
517
699
  }
700
+ else if (c === "\t" && this.#atUp()) {
701
+ // KC3 §3: Tab accepts the selected path — the token becomes
702
+ // `@<path> `. Never the file's content.
703
+ this.#atAccept();
704
+ i += 1;
705
+ }
518
706
  else if (c === "\x12") {
519
707
  // W15: the expand key (ctrl+r) — rides the chain like a
520
708
  // command, the editor just forwards it.
@@ -541,6 +729,18 @@ export class Editor {
541
729
  this.#insert(NEWLINE);
542
730
  return;
543
731
  }
732
+ // KC2 §2 — Ctrl+Enter, the SAME two encodings with modifier 5
733
+ // (1 + ctrl): kitty's CSI-u and xterm's modifyOtherKeys. Never
734
+ // claimed universal — a terminal that encodes neither sends a plain
735
+ // CR, which is an ordinary submit/queue (the safe degrade). The
736
+ // chunk-split safety is the existing #pending CSI resume, shared
737
+ // with Shift+Enter above. Outside the normal composer state the
738
+ // sequence is simply unknown, exactly like any other stray CSI.
739
+ if ((final === "u" && params === "13;5") || (final === "~" && params === "27;5;13")) {
740
+ if (this.#composerIdle())
741
+ this.#redirect();
742
+ return;
743
+ }
544
744
  if (final === "~") {
545
745
  const n = Number(params);
546
746
  if (n === 3)
@@ -568,6 +768,14 @@ export class Editor {
568
768
  else
569
769
  this.#menuSel = Math.min(this.#menuFiltered().length - 1, this.#menuSel + 1);
570
770
  }
771
+ else if (this.#atUp()) {
772
+ // KC3 §3: the picker owns ↑↓ while up — the SELECTION, never
773
+ // the cursor and never the composer's line walk. It sits
774
+ // ABOVE the multi-line branch on purpose: a picker opened on
775
+ // line 2 of a composer must still select.
776
+ const view = this.#atView();
777
+ this.#atSel = final === "A" ? Math.max(0, view.selected - 1) : Math.min(view.matches.length - 1, view.selected + 1);
778
+ }
571
779
  else if (this.#chars.includes(NEWLINE)) {
572
780
  // KC1 §4: a MULTI-LINE buffer's ↑↓ walk its lines. The
573
781
  // history and the queue-pop below stay gated on an EMPTY
@@ -728,6 +936,11 @@ export class Editor {
728
936
  this.#reflow();
729
937
  if (!this.#pasting)
730
938
  this.#refreshMenu();
939
+ // KC3 §3: a TYPED `@` arms the picker; a PASTED one never does —
940
+ // a paste is content, and content that happens to contain an
941
+ // address must not open a file browser mid-sentence.
942
+ if (cp === AT && !this.#pasting)
943
+ this.#atArm();
731
944
  }
732
945
  #backspace() {
733
946
  if (this.#cursor === 0)
@@ -783,8 +996,86 @@ export class Editor {
783
996
  this.#cursor = i;
784
997
  this.#reflow();
785
998
  }
999
+ /** KC1/KC2 — the buffer LEAVES: the flat chars, the cursor, the
1000
+ * horizontal scroll, the ↑/↓ goal, the menu and the pop-walk all
1001
+ * reset together (W22: a departing line ends the pop-walk, so the
1002
+ * next esc at rest interrupts again). Shared by the submit and the
1003
+ * redirect — the two doors a line can leave by. */
1004
+ #takeLine() {
1005
+ const line = String.fromCodePoint(...this.#chars);
1006
+ this.#chars = [];
1007
+ this.#cursor = 0;
1008
+ this.#scroll = 0;
1009
+ this.#verticalGoalCol = null;
1010
+ this.#menuOpen = false;
1011
+ this.#menuSel = 0;
1012
+ this.#queuePopMode = false;
1013
+ this.#atClose(); // KC3 §3: a departing line takes its picker with it
1014
+ return line;
1015
+ }
1016
+ /** A2: the history remembers submitted TURN lines — never question
1017
+ * answers, never empties; adjacent duplicates collapse, the tail
1018
+ * caps at 100. A redirect is a turn, so it is remembered too. */
1019
+ #remember(line) {
1020
+ if (this.#history[this.#history.length - 1] !== line)
1021
+ this.#history.push(line);
1022
+ if (this.#history.length > 100)
1023
+ this.#history.shift();
1024
+ }
1025
+ /** KC2 §2 — the NORMAL composer state: the redirect gesture is live
1026
+ * ONLY here. The approval panel, the slash menu, the history browse
1027
+ * and the queue-pop walk each OWN their keys first (the W21 "the
1028
+ * panel owns the keys" design, restated as a gate); a pending
1029
+ * question is the panel's dock-less twin (askPanel routes to
1030
+ * question() when the dock cannot render, so the ask owns the keys
1031
+ * there too); and a bracketed paste is literal TEXT, where an ESC CR
1032
+ * is the pasted content's own bytes and never a keypress. In every
1033
+ * one of those states the two bytes fall through to today's
1034
+ * handling — two gestures, unchanged. */
1035
+ #composerIdle() {
1036
+ return (this.#panel === null &&
1037
+ !this.#menuOpen &&
1038
+ !this.#atUp() && // KC3 §3: the @ picker owns the keys while up, exactly like the menu
1039
+ this.#historyIdx === null &&
1040
+ !this.#queuePopMode &&
1041
+ !this.#pasting &&
1042
+ this.#questionCb === null);
1043
+ }
1044
+ /**
1045
+ * KC2 §2 — the gesture's meaning, kept as small as it can honestly be.
1046
+ *
1047
+ * An EMPTY buffer carries no correction, so the gesture degenerates to
1048
+ * the bare Esc: the abort alone, nothing submitted. With text, the
1049
+ * line leaves exactly as a submit's does and the listeners decide (the
1050
+ * CLI aborts a live run and front-jumps the correction; idle, it is
1051
+ * simply an Enter). UNWIRED — the recovery flow never binds it — the
1052
+ * gesture IS a submit: a line is never lost to a missing binding.
1053
+ */
1054
+ #redirect() {
1055
+ if (this.#chars.length === 0) {
1056
+ for (const cb of [...this.#escapeCbs])
1057
+ cb();
1058
+ return;
1059
+ }
1060
+ if (this.#redirectCbs.length === 0) {
1061
+ this.#submit();
1062
+ return;
1063
+ }
1064
+ const line = this.#takeLine();
1065
+ this.#remember(line);
1066
+ for (const cb of [...this.#redirectCbs])
1067
+ cb(line);
1068
+ this.#onRender();
1069
+ }
786
1070
  #submit() {
787
- let line = String.fromCodePoint(...this.#chars);
1071
+ // KC3 §3: Enter ACCEPTS while the picker is up — the same rule the
1072
+ // menu's A1 feel established (complete first, let the user read
1073
+ // what they got, and let the NEXT Enter send it). An @ reference
1074
+ // that submitted on the first Enter would send the fragment.
1075
+ if (this.#atUp()) {
1076
+ this.#atAccept();
1077
+ return;
1078
+ }
788
1079
  if (this.#menuOpen) {
789
1080
  // A1 (the feel): Enter submits the EXACT selection directly; a
790
1081
  // PARTIAL selection COMPLETES the buffer (the Tab semantics)
@@ -792,7 +1083,7 @@ export class Editor {
792
1083
  // again. The old behavior executed the completed command on
793
1084
  // the first Enter, before the user had seen the completion.
794
1085
  const m = this.#menuFiltered()[this.#menuSel];
795
- if (m !== undefined && m.name !== line) {
1086
+ if (m !== undefined && m.name !== this.line()) {
796
1087
  this.#chars = [...m.name].map((ch) => ch.codePointAt(0));
797
1088
  this.#cursor = this.#chars.length;
798
1089
  this.#reflow();
@@ -801,13 +1092,7 @@ export class Editor {
801
1092
  return; // completed, not executed
802
1093
  }
803
1094
  }
804
- this.#chars = [];
805
- this.#cursor = 0;
806
- this.#scroll = 0;
807
- this.#verticalGoalCol = null;
808
- this.#menuOpen = false;
809
- this.#menuSel = 0;
810
- this.#queuePopMode = false; // W22: a submit ends the pop-walk — the next esc at rest interrupts again
1095
+ const line = this.#takeLine();
811
1096
  const cb = this.#questionCb;
812
1097
  this.#questionCb = null;
813
1098
  if (cb !== null) {
@@ -819,14 +1104,8 @@ export class Editor {
819
1104
  else {
820
1105
  this.#pendingLines.push(line); // nobody wired yet — hold it
821
1106
  }
822
- // A2: the history remembers submitted TURN lines — never question
823
- // answers, never empties; adjacent duplicates collapse.
824
- if (cb === null && line !== "") {
825
- if (this.#history[this.#history.length - 1] !== line)
826
- this.#history.push(line);
827
- if (this.#history.length > 100)
828
- this.#history.shift();
829
- }
1107
+ if (cb === null && line !== "")
1108
+ this.#remember(line);
830
1109
  this.#onRender();
831
1110
  }
832
1111
  /** A2: step the history browse; a delta past the newest exits back to
package/dist/index.d.ts CHANGED
@@ -11,3 +11,6 @@ export { Container, foldLine, visibleWidth, SPINNER, type Component, type FrameC
11
11
  export { Editor, MENU_ITEMS, PROMPT, PROMPT_WIDTH, displayWidth, charWidth, widthOf, type MenuItem, } from "./editor.js";
12
12
  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
13
  export { editFileDiff, truncateDiff, writeFileDiff, type DiffLine, type DiffResult } from "./diff.js";
14
+ export { STATUS_GLYPHS, idleStatus, runningStatus } from "./status.js";
15
+ export { interactivePrompt, projectTrustRows, projectTrustView, projectUntrustedNote, uncertainView, type TrustArtifact } from "./strings.js";
16
+ export { AT_CAP, AT_SKIP, AT_VISIBLE, atEmbed, atFilter, atPanelRows, atWindow, longestRun, type AtItem, type AtMatch } from "./at-picker.js";
package/dist/index.js CHANGED
@@ -14,3 +14,13 @@ export { Container, foldLine, visibleWidth, SPINNER } from "./components.js";
14
14
  export { Editor, MENU_ITEMS, PROMPT, PROMPT_WIDTH, displayWidth, charWidth, widthOf, } from "./editor.js";
15
15
  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";
16
16
  export { editFileDiff, truncateDiff, writeFileDiff } from "./diff.js";
17
+ // KC2 §5: the status rows' formatters — the CLI keeps the state and the
18
+ // repaint, the terminal layer owns what the row says.
19
+ export { STATUS_GLYPHS, idleStatus, runningStatus } from "./status.js";
20
+ // KC3 §1 (the extraction): the human-facing strings — the prompt, the
21
+ // project-trust listing/view/note, the uncertain execution's view. The
22
+ // FLOW (who is asked, what a verdict means) stays in the cli.
23
+ export { interactivePrompt, projectTrustRows, projectTrustView, projectUntrustedNote, uncertainView } from "./strings.js";
24
+ // KC3 §3/§5: the @ file picker's pure half — the subsequence filter, the
25
+ // deterministic rank, and the ONE cap the CLI's file source shares.
26
+ export { AT_CAP, AT_SKIP, AT_VISIBLE, atEmbed, atFilter, atPanelRows, atWindow, longestRun } from "./at-picker.js";
@@ -0,0 +1,38 @@
1
+ /**
2
+ * KC2 §5 — the status line's FORMATTERS, extracted from the CLI (the
3
+ * ADR-0041 escape hatch: extraction, never a fifth raise). The split is
4
+ * the one the ADR names: the CLI keeps the STATE (the rotating glyph,
5
+ * the run's start instant, the live usage, whether the dock is up) and
6
+ * the REPAINT; what a status row SAYS is presentation, and presentation
7
+ * belongs to the terminal layer.
8
+ *
9
+ * Two callers built these rows independently before the move — chat's
10
+ * REPL and the recovery flow — with the running row duplicated verbatim
11
+ * in both. One definition now serves both, and the tier stays a
12
+ * PARAMETER precisely because the two callers disagree on it (chat
13
+ * spells plan's read-only posture out per W19, the recovery flow prints
14
+ * the bare mode): the extraction must not silently unify a difference it
15
+ * was not asked to settle.
16
+ *
17
+ * The rows are byte-for-byte what the CLI built before the move — the
18
+ * v2b/v3 §03 shapes the e2e transcripts pin by substring — with ONE
19
+ * deliberate exception: the running row's interrupt hint, which KC2 §2
20
+ * widens to name the new gesture.
21
+ */
22
+ /** v3 §03/§05 — the working glyph family; the CLI's 200ms spinner walks
23
+ * it and hands each glyph back to `runningStatus`. */
24
+ export declare const STATUS_GLYPHS: readonly ["▖", "▘", "▝", "▗"];
25
+ /**
26
+ * The RUNNING row: the rotating glyph, the wall seconds since `since`
27
+ * (never below 1 — a run that just started still reads "1s", so the row
28
+ * never claims a turn took no time), the streamed output tokens once the
29
+ * count is known, the interrupt hints, and the live ctx estimate.
30
+ *
31
+ * KC2 §2: the hint names BOTH gestures. Esc still stops; alt+⏎ redirects
32
+ * — stop, and do THIS instead. The row is where the gesture is taught,
33
+ * because it is on screen exactly when the gesture is useful.
34
+ */
35
+ export declare function runningStatus(glyph: string, since: number, outTokens: number | null, ctxRatio: number): string;
36
+ /** The IDLE row: the approval tier as the CALLER names it, the /mode
37
+ * hint, the model driving the session, and the ctx estimate. */
38
+ export declare function idleStatus(tier: string, model: string, ctxRatio: number): string;
package/dist/status.js ADDED
@@ -0,0 +1,52 @@
1
+ /**
2
+ * KC2 §5 — the status line's FORMATTERS, extracted from the CLI (the
3
+ * ADR-0041 escape hatch: extraction, never a fifth raise). The split is
4
+ * the one the ADR names: the CLI keeps the STATE (the rotating glyph,
5
+ * the run's start instant, the live usage, whether the dock is up) and
6
+ * the REPAINT; what a status row SAYS is presentation, and presentation
7
+ * belongs to the terminal layer.
8
+ *
9
+ * Two callers built these rows independently before the move — chat's
10
+ * REPL and the recovery flow — with the running row duplicated verbatim
11
+ * in both. One definition now serves both, and the tier stays a
12
+ * PARAMETER precisely because the two callers disagree on it (chat
13
+ * spells plan's read-only posture out per W19, the recovery flow prints
14
+ * the bare mode): the extraction must not silently unify a difference it
15
+ * was not asked to settle.
16
+ *
17
+ * The rows are byte-for-byte what the CLI built before the move — the
18
+ * v2b/v3 §03 shapes the e2e transcripts pin by substring — with ONE
19
+ * deliberate exception: the running row's interrupt hint, which KC2 §2
20
+ * widens to name the new gesture.
21
+ */
22
+ import { kUnit } from "./render.js";
23
+ /** v3 §03/§05 — the working glyph family; the CLI's 200ms spinner walks
24
+ * it and hands each glyph back to `runningStatus`. */
25
+ export const STATUS_GLYPHS = ["▖", "▘", "▝", "▗"];
26
+ /** The ~ctx estimate as the whole-percent LEFT. A non-finite ratio (no
27
+ * window, no estimate) yields null and the row prints "~null%" — the
28
+ * long-standing shape, kept on purpose: an honest null beats an
29
+ * invented percentage. */
30
+ function ctxLeft(ratio) {
31
+ return Number.isFinite(ratio) ? Math.round((1 - ratio) * 100) : null;
32
+ }
33
+ /**
34
+ * The RUNNING row: the rotating glyph, the wall seconds since `since`
35
+ * (never below 1 — a run that just started still reads "1s", so the row
36
+ * never claims a turn took no time), the streamed output tokens once the
37
+ * count is known, the interrupt hints, and the live ctx estimate.
38
+ *
39
+ * KC2 §2: the hint names BOTH gestures. Esc still stops; alt+⏎ redirects
40
+ * — stop, and do THIS instead. The row is where the gesture is taught,
41
+ * because it is on screen exactly when the gesture is useful.
42
+ */
43
+ export function runningStatus(glyph, since, outTokens, ctxRatio) {
44
+ const out = outTokens !== null ? ` ↓ ${kUnit(outTokens)} tokens` : "";
45
+ const seconds = Math.max(1, Math.round((Date.now() - since) / 1000));
46
+ return `${glyph} working ${seconds}s${out} · esc stop · alt+⏎ redirect · ctx left ~${ctxLeft(ctxRatio)}%`;
47
+ }
48
+ /** The IDLE row: the approval tier as the CALLER names it, the /mode
49
+ * hint, the model driving the session, and the ctx estimate. */
50
+ export function idleStatus(tier, model, ctxRatio) {
51
+ return `▸ ${tier} · /mode to switch · ${model} · ctx left ~${ctxLeft(ctxRatio)}%`;
52
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * KC3 slice 1 — the strings shim: the human-facing strings moved to the
3
+ * 9th package (the ADR-0043 Amendment 4 hatch); the tui re-exports them
4
+ * so its consumers (the cli) import a stable path.
5
+ */
6
+ export * from "@vincemakes/kiso-tui-cells/strings";
@@ -0,0 +1,6 @@
1
+ /**
2
+ * KC3 slice 1 — the strings shim: the human-facing strings moved to the
3
+ * 9th package (the ADR-0043 Amendment 4 hatch); the tui re-exports them
4
+ * so its consumers (the cli) import a stable path.
5
+ */
6
+ export * from "@vincemakes/kiso-tui-cells/strings";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-tui",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
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",
@@ -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.5.0"
38
+ "@vincemakes/kiso-tui-cells": "0.7.0"
39
39
  }
40
40
  }