pi-advisor-flow 0.5.0 → 0.5.2

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.
Files changed (59) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +37 -71
  3. package/extensions/index.ts +7 -7
  4. package/package.json +19 -6
  5. package/src/commands/activation-preparation.ts +117 -0
  6. package/src/commands/activation.ts +135 -0
  7. package/src/commands/lifecycle.ts +83 -0
  8. package/src/commands/manual-command.ts +107 -0
  9. package/src/commands/manual-consultation.ts +144 -0
  10. package/src/commands/manual-progress.ts +93 -0
  11. package/src/commands/model-commands.ts +66 -0
  12. package/src/commands/model-options.ts +181 -0
  13. package/src/commands/model-picker.ts +110 -0
  14. package/src/commands/registration.ts +27 -0
  15. package/src/commands/renderers.ts +76 -0
  16. package/src/commands/runtime.ts +114 -0
  17. package/src/commands/settings-commands.ts +80 -0
  18. package/src/commands/settings-persistence.ts +81 -0
  19. package/src/commands/types.ts +78 -0
  20. package/src/commands.ts +9 -946
  21. package/src/config/args.ts +37 -0
  22. package/src/config/defaults.ts +190 -0
  23. package/src/config/state.ts +199 -0
  24. package/src/config/storage.ts +224 -0
  25. package/src/config/types.ts +63 -0
  26. package/src/config/validation.ts +234 -0
  27. package/src/config.ts +104 -791
  28. package/src/conversation.ts +4 -2
  29. package/src/herdr.ts +1 -1
  30. package/src/model-stream.ts +25 -2
  31. package/src/scout-context.ts +2 -2
  32. package/src/scout.ts +1 -1
  33. package/src/tools/consultation.ts +337 -0
  34. package/src/tools/gate-policy.ts +132 -0
  35. package/src/tools/gate-protocol.ts +127 -0
  36. package/src/tools/loop-gate.ts +210 -0
  37. package/src/tools/prompts.ts +159 -0
  38. package/src/tools/register-ask-advisor.ts +225 -0
  39. package/src/tools/register-lifecycle.ts +89 -0
  40. package/src/tools/register-outcome.ts +75 -0
  41. package/src/tools/register-renderers.ts +88 -0
  42. package/src/tools/registration.ts +35 -0
  43. package/src/tools/render-advisor-result.ts +168 -0
  44. package/src/tools/render-common.ts +120 -0
  45. package/src/tools/scout-status.ts +206 -0
  46. package/src/tools/session.ts +3 -0
  47. package/src/tools/types.ts +119 -0
  48. package/src/tools.ts +42 -1811
  49. package/src/ui/manual-dialog-render.ts +150 -0
  50. package/src/ui/manual-dialog.ts +305 -0
  51. package/src/ui/model-selector.ts +166 -0
  52. package/src/ui/settings-formatting.ts +133 -0
  53. package/src/ui/settings-items.ts +323 -0
  54. package/src/ui/settings-list-adapter.ts +103 -0
  55. package/src/ui/settings-mutations.ts +80 -0
  56. package/src/ui/settings-selector.ts +161 -0
  57. package/src/ui/text-setting-submenu.ts +66 -0
  58. package/src/ui/types.ts +111 -0
  59. package/src/ui.ts +9 -1418
@@ -0,0 +1,150 @@
1
+ import {
2
+ truncateToWidth,
3
+ visibleWidth,
4
+ wrapTextWithAnsi,
5
+ } from "@earendil-works/pi-tui";
6
+ import type { GitContextLevel } from "../git.js";
7
+ import type { ManualAdvisorDialogView } from "./types.js";
8
+
9
+ const MANUAL_GIT_CONTEXT_COPY: Record<
10
+ GitContextLevel,
11
+ { description: string; label: string }
12
+ > = {
13
+ full: { description: "summary plus patch", label: "Full" },
14
+ off: { description: "no Git data", label: "None" },
15
+ summary: { description: "changed paths and status", label: "Summary" },
16
+ };
17
+
18
+ export const renderManualAdvisorDialog = (
19
+ view: ManualAdvisorDialogView
20
+ ): string[] => {
21
+ const {
22
+ actionIndex,
23
+ editorLines,
24
+ focusTarget,
25
+ gitContext,
26
+ gitIndex,
27
+ gitLevels,
28
+ horizontalPadding,
29
+ renderWidth,
30
+ theme,
31
+ } = view;
32
+ const innerWidth = Math.max(0, renderWidth - 2);
33
+ const contentWidth = Math.max(0, innerWidth - horizontalPadding * 2);
34
+ const lines: string[] = [];
35
+
36
+ const focusMarker = (target: ManualAdvisorDialogView["focusTarget"]) =>
37
+ focusTarget === target ? theme.fg("accent", "▸ ") : " ";
38
+ const addLine = (text: string) => {
39
+ if (renderWidth < 2) {
40
+ lines.push(truncateToWidth(text, renderWidth, ""));
41
+ return;
42
+ }
43
+ lines.push(
44
+ `${theme.fg("border", "│")}${" ".repeat(
45
+ horizontalPadding
46
+ )}${truncateToWidth(text, contentWidth, "", true)}${" ".repeat(
47
+ horizontalPadding
48
+ )}${theme.fg("border", "│")}`
49
+ );
50
+ };
51
+ const addWrapped = (
52
+ text: string,
53
+ color: "accent" | "text" | "muted" | "dim" = "text"
54
+ ) => {
55
+ const content = theme.fg(color, text);
56
+ const wrapped = wrapTextWithAnsi(content, Math.max(1, contentWidth - 2));
57
+ for (const line of wrapped.length > 0 ? wrapped : [""]) {
58
+ addLine(` ${line}`);
59
+ }
60
+ };
61
+
62
+ if (renderWidth >= 2) {
63
+ const title = truncateToWidth(
64
+ " Ask Advisor ",
65
+ Math.max(0, innerWidth),
66
+ "",
67
+ false
68
+ );
69
+ const titleWidth = visibleWidth(title);
70
+ const remaining = Math.max(0, innerWidth - titleWidth);
71
+ const left = Math.floor(remaining / 2);
72
+ const right = remaining - left;
73
+ lines.push(
74
+ theme.fg("border", `╭${"─".repeat(left)}`) +
75
+ theme.fg("accent", title) +
76
+ theme.fg("border", `${"─".repeat(right)}╮`)
77
+ );
78
+ addLine("");
79
+ }
80
+
81
+ addLine(
82
+ `${focusMarker("editor")}${theme.bold("Message for Advisor (optional)")}`
83
+ );
84
+ for (const editorLine of editorLines) {
85
+ addLine(editorLine);
86
+ }
87
+ addLine("");
88
+ addWrapped(
89
+ "Automatic context follows your settings: conversation history, tool disclosure, project preferences, redaction, and configured limits remain unchanged.",
90
+ "muted"
91
+ );
92
+ addWrapped(
93
+ "This dialog does not expose drafts or explicit file handoff controls.",
94
+ "dim"
95
+ );
96
+ addWrapped(
97
+ "Git None only withholds repository data; it does not remove configured conversation history.",
98
+ "dim"
99
+ );
100
+ addLine("");
101
+ const gitCeiling = MANUAL_GIT_CONTEXT_COPY[gitContext].label;
102
+ addLine(
103
+ `${focusMarker("git")}${theme.bold(
104
+ `Git repository context (max: ${gitCeiling}; ↑/↓ or Space to choose)`
105
+ )}`
106
+ );
107
+ for (let index = 0; index < gitLevels.length; index += 1) {
108
+ const level = gitLevels[index];
109
+ const copy = MANUAL_GIT_CONTEXT_COPY[level];
110
+ const selected = index === gitIndex;
111
+ const marker = selected ? "●" : "○";
112
+ const optionFocus = focusTarget === "git" && selected ? "▸ " : " ";
113
+ const color = selected ? "accent" : "text";
114
+ addWrapped(
115
+ `${optionFocus}${marker} ${copy.label} — ${copy.description}`,
116
+ color
117
+ );
118
+ }
119
+ addLine("");
120
+
121
+ let interactionHint =
122
+ "Enter submit · Shift+Enter newline · Tab/Shift+Tab focus · Esc cancel";
123
+ if (focusTarget === "git") {
124
+ interactionHint =
125
+ "↑/↓ or Space choose · Enter next · Tab/Shift+Tab focus · Esc cancel";
126
+ } else if (focusTarget === "actions") {
127
+ interactionHint =
128
+ "←/→ choose · Enter/Space activate · Tab/Shift+Tab focus · Esc cancel";
129
+ }
130
+ const submit = focusTarget === "actions" && actionIndex === 0;
131
+ const submitLabel = submit
132
+ ? theme.fg("accent", "[Submit]")
133
+ : theme.fg("text", "[Submit]");
134
+ const cancel = focusTarget === "actions" && actionIndex === 1;
135
+ const cancelLabel = cancel
136
+ ? theme.fg("accent", "[Cancel]")
137
+ : theme.fg("text", "[Cancel]");
138
+ addLine(
139
+ `${focusMarker("actions")} ${submitLabel} ${cancelLabel}`
140
+ );
141
+ addWrapped(interactionHint, "dim");
142
+ addLine("");
143
+
144
+ if (renderWidth >= 2) {
145
+ lines.push(
146
+ theme.fg("border", `╰${"─".repeat(Math.max(0, renderWidth - 2))}╯`)
147
+ );
148
+ }
149
+ return lines.map((line) => truncateToWidth(line, renderWidth, ""));
150
+ };
@@ -0,0 +1,305 @@
1
+ import {
2
+ type Component,
3
+ Editor,
4
+ type EditorTheme,
5
+ type Focusable,
6
+ Key,
7
+ type Keybindings,
8
+ type KeyId,
9
+ matchesKey,
10
+ } from "@earendil-works/pi-tui";
11
+ import {
12
+ clampGitContextLevel,
13
+ GIT_CONTEXT_LEVELS,
14
+ type GitContextLevel,
15
+ } from "../git.js";
16
+ import { renderManualAdvisorDialog } from "./manual-dialog-render.js";
17
+ import type {
18
+ ManualAdvisorDialogOptions,
19
+ ManualAdvisorFocus,
20
+ } from "./types.js";
21
+
22
+ // Keep the keybinding components separate from Socket's URL-string heuristic.
23
+ const TUI_INPUT_TAB: keyof Keybindings = ["tui", "input", "tab"].join(
24
+ "."
25
+ ) as keyof Keybindings;
26
+
27
+ /**
28
+ * The small, deliberately non-persistent form used by `/advisor-manual`.
29
+ *
30
+ * The dialog owns only local input state. Callers decide when to consume a
31
+ * budget or start a consultation after the submitted request is returned.
32
+ */
33
+ export class ManualAdvisorDialog implements Component, Focusable {
34
+ private readonly options: ManualAdvisorDialogOptions;
35
+ private readonly editor: Editor;
36
+ private readonly gitLevels: GitContextLevel[];
37
+ private gitIndex: number;
38
+ private actionIndex = 0;
39
+ private focusTarget: ManualAdvisorFocus = "editor";
40
+ private readonly state: { completed: boolean; focused: boolean } = {
41
+ completed: false,
42
+ focused: false,
43
+ };
44
+
45
+ get focused(): boolean {
46
+ return this.state.focused;
47
+ }
48
+
49
+ set focused(value: boolean) {
50
+ this.state.focused = value;
51
+ this.updateEditorFocus();
52
+ this.options.tui.requestRender();
53
+ }
54
+
55
+ constructor(options: ManualAdvisorDialogOptions) {
56
+ this.options = options;
57
+ // Show the configured default first, followed by progressively narrower
58
+ // choices. That makes Down do the useful thing from the initial selection
59
+ // while the configured level remains the hard ceiling.
60
+ this.gitLevels = GIT_CONTEXT_LEVELS.filter(
61
+ (level) => clampGitContextLevel(level, options.gitContext) === level
62
+ ).reverse();
63
+ this.gitIndex = Math.max(0, this.gitLevels.indexOf(options.gitContext));
64
+
65
+ const editorTheme: EditorTheme = {
66
+ borderColor: (text) => options.theme.fg("border", text),
67
+ selectList: {
68
+ description: (text) => options.theme.fg("muted", text),
69
+ noMatch: (text) => options.theme.fg("warning", text),
70
+ scrollInfo: (text) => options.theme.fg("dim", text),
71
+ selectedPrefix: (text) => options.theme.fg("accent", text),
72
+ selectedText: (text) => options.theme.fg("accent", text),
73
+ },
74
+ };
75
+ this.editor = new Editor(options.tui, editorTheme);
76
+ this.editor.setText(options.initialMessage ?? "");
77
+ this.editor.onChange = () => options.tui.requestRender();
78
+ this.editor.onSubmit = (message) => this.submit(message);
79
+ this.updateEditorFocus();
80
+ }
81
+
82
+ invalidate(): void {
83
+ this.editor.invalidate();
84
+ }
85
+
86
+ dispose(): void {
87
+ this.state.completed = true;
88
+ }
89
+
90
+ handleInput(keyData: string): void {
91
+ if (this.state.completed) {
92
+ return;
93
+ }
94
+ if (this.isCancel(keyData)) {
95
+ this.cancel();
96
+ return;
97
+ }
98
+ if (this.isShiftTab(keyData)) {
99
+ this.changeFocus(-1);
100
+ return;
101
+ }
102
+ if (this.isTab(keyData)) {
103
+ this.changeFocus(1);
104
+ return;
105
+ }
106
+
107
+ switch (this.focusTarget) {
108
+ case "editor":
109
+ // Editor owns Enter/Shift+Enter and all cursor/editing semantics. Its
110
+ // onSubmit callback above is the sole editor submission path. At the
111
+ // edge of the message, a directional key also moves to the adjacent
112
+ // form control so the Git selector is reachable without Tab.
113
+ this.handleEditorInput(keyData);
114
+ return;
115
+ case "git":
116
+ this.handleGitInput(keyData);
117
+ return;
118
+ case "actions":
119
+ this.handleActionInput(keyData);
120
+ return;
121
+ default:
122
+ return;
123
+ }
124
+ }
125
+
126
+ render(width: number): string[] {
127
+ const renderWidth = Math.max(1, Math.floor(width));
128
+ const innerWidth = Math.max(0, renderWidth - 2);
129
+ const horizontalPadding = renderWidth >= 4 ? 1 : 0;
130
+ const contentWidth = Math.max(0, innerWidth - horizontalPadding * 2);
131
+ return renderManualAdvisorDialog({
132
+ actionIndex: this.actionIndex,
133
+ editorLines: this.editor.render(Math.max(1, contentWidth)),
134
+ focusTarget: this.focusTarget,
135
+ gitContext: this.options.gitContext,
136
+ gitIndex: this.gitIndex,
137
+ gitLevels: this.gitLevels,
138
+ horizontalPadding,
139
+ renderWidth,
140
+ theme: this.options.theme,
141
+ });
142
+ }
143
+
144
+ private handleEditorInput(keyData: string): void {
145
+ let direction: -1 | 1 | undefined;
146
+ if (this.matches(keyData, "tui.editor.cursorDown", Key.down)) {
147
+ direction = 1;
148
+ } else if (this.matches(keyData, "tui.editor.cursorUp", Key.up)) {
149
+ direction = -1;
150
+ }
151
+
152
+ const beforeCursor =
153
+ direction === undefined ? undefined : this.editor.getCursor();
154
+ const beforeText =
155
+ direction === undefined ? undefined : this.editor.getText();
156
+ this.editor.handleInput(keyData);
157
+
158
+ if (direction !== undefined) {
159
+ const afterCursor = this.editor.getCursor();
160
+ if (
161
+ beforeCursor?.line === afterCursor.line &&
162
+ beforeCursor.col === afterCursor.col &&
163
+ beforeText === this.editor.getText()
164
+ ) {
165
+ this.changeFocus(direction);
166
+ return;
167
+ }
168
+ }
169
+ this.options.tui.requestRender();
170
+ }
171
+
172
+ private handleGitInput(keyData: string): void {
173
+ // Accept both the selector bindings and the editor arrow bindings. The
174
+ // latter keeps the form usable with user keymaps that customize selector
175
+ // actions, while the raw-key fallback keeps terminal arrow sequences
176
+ // working across Pi/pi-tui versions.
177
+ const up =
178
+ this.matches(keyData, "tui.select.up", Key.up) ||
179
+ this.matches(keyData, "tui.editor.cursorUp", Key.up);
180
+ const down =
181
+ this.matches(keyData, "tui.select.down", Key.down) ||
182
+ this.matches(keyData, "tui.editor.cursorDown", Key.down);
183
+ if (up) {
184
+ if (this.gitIndex === 0) {
185
+ this.changeFocus(-1);
186
+ } else {
187
+ this.moveGit(-1);
188
+ }
189
+ return;
190
+ }
191
+ if (down) {
192
+ if (this.gitIndex === this.gitLevels.length - 1) {
193
+ this.changeFocus(1);
194
+ } else {
195
+ this.moveGit(1);
196
+ }
197
+ return;
198
+ }
199
+ if (matchesKey(keyData, Key.space)) {
200
+ this.moveGit(1);
201
+ return;
202
+ }
203
+ if (this.matches(keyData, "tui.input.submit", Key.enter)) {
204
+ this.changeFocus(1);
205
+ return;
206
+ }
207
+ this.options.tui.requestRender();
208
+ }
209
+
210
+ private handleActionInput(keyData: string): void {
211
+ if (this.matches(keyData, "tui.editor.cursorUp", Key.up)) {
212
+ this.changeFocus(-1);
213
+ return;
214
+ }
215
+ if (this.matches(keyData, "tui.editor.cursorDown", Key.down)) {
216
+ this.changeFocus(1);
217
+ return;
218
+ }
219
+ if (matchesKey(keyData, Key.left) || matchesKey(keyData, Key.right)) {
220
+ this.actionIndex = this.actionIndex === 0 ? 1 : 0;
221
+ this.options.tui.requestRender();
222
+ return;
223
+ }
224
+ if (
225
+ this.matches(keyData, "tui.input.submit", Key.enter) ||
226
+ matchesKey(keyData, Key.space)
227
+ ) {
228
+ if (this.actionIndex === 0) {
229
+ this.submit();
230
+ } else {
231
+ this.cancel();
232
+ }
233
+ }
234
+ }
235
+
236
+ private changeFocus(direction: -1 | 1): void {
237
+ const targets: ManualAdvisorFocus[] = ["editor", "git", "actions"];
238
+ const current = targets.indexOf(this.focusTarget);
239
+ this.focusTarget =
240
+ targets[(current + direction + targets.length) % targets.length];
241
+ this.updateEditorFocus();
242
+ this.options.tui.requestRender();
243
+ }
244
+
245
+ private moveGit(direction: -1 | 1): void {
246
+ if (this.gitLevels.length > 0) {
247
+ this.gitIndex =
248
+ (this.gitIndex + direction + this.gitLevels.length) %
249
+ this.gitLevels.length;
250
+ }
251
+ this.options.tui.requestRender();
252
+ }
253
+
254
+ private updateEditorFocus(): void {
255
+ this.editor.focused = this.state.focused && this.focusTarget === "editor";
256
+ }
257
+
258
+ private isTab(keyData: string): boolean {
259
+ return (
260
+ this.matches(keyData, TUI_INPUT_TAB, Key.tab) &&
261
+ !matchesKey(keyData, Key.shift("tab"))
262
+ );
263
+ }
264
+
265
+ private isShiftTab(keyData: string): boolean {
266
+ return matchesKey(keyData, Key.shift("tab"));
267
+ }
268
+
269
+ private isCancel(keyData: string): boolean {
270
+ return (
271
+ matchesKey(keyData, Key.escape) ||
272
+ this.options.keybindings.matches(keyData, "tui.select.cancel")
273
+ );
274
+ }
275
+
276
+ private matches(
277
+ keyData: string,
278
+ action: keyof Keybindings,
279
+ fallback: KeyId
280
+ ): boolean {
281
+ return (
282
+ this.options.keybindings.matches(keyData, action) ||
283
+ matchesKey(keyData, fallback)
284
+ );
285
+ }
286
+
287
+ private submit(message = this.editor.getText()): void {
288
+ if (this.state.completed) {
289
+ return;
290
+ }
291
+ this.state.completed = true;
292
+ this.options.onSubmit({
293
+ gitContext: this.gitLevels[this.gitIndex] ?? "off",
294
+ ...(message ? { message } : {}),
295
+ });
296
+ }
297
+
298
+ private cancel(): void {
299
+ if (this.state.completed) {
300
+ return;
301
+ }
302
+ this.state.completed = true;
303
+ this.options.onCancel();
304
+ }
305
+ }
@@ -0,0 +1,166 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ type Component,
4
+ type Focusable,
5
+ fuzzyFilter,
6
+ Input,
7
+ type Keybindings,
8
+ type KeybindingsManager,
9
+ } from "@earendil-works/pi-tui";
10
+ import type { SearchableModelSelectorOptions } from "./types.js";
11
+
12
+ export class SearchableModelSelector implements Component, Focusable {
13
+ private readonly tui: SearchableModelSelectorOptions["tui"];
14
+ private readonly searchInput: Input;
15
+ private readonly allOptions: string[];
16
+ private readonly currentOption: string | undefined;
17
+ private filteredOptions: string[];
18
+ private selectedIndex = 0;
19
+ private readonly title: string;
20
+ private readonly onSelect: (value: string) => void;
21
+ private readonly onCancel: () => void;
22
+ private readonly theme: Theme;
23
+ private readonly keybindings: KeybindingsManager;
24
+ private _focused = false;
25
+
26
+ get focused(): boolean {
27
+ return this._focused;
28
+ }
29
+ set focused(val: boolean) {
30
+ this._focused = val;
31
+ this.searchInput.focused = val;
32
+ }
33
+
34
+ constructor(options: SearchableModelSelectorOptions) {
35
+ this.tui = options.tui;
36
+ this.title = options.title;
37
+ this.currentOption =
38
+ options.currentOption &&
39
+ options.allOptions.includes(options.currentOption)
40
+ ? options.currentOption
41
+ : undefined;
42
+ this.allOptions = this.currentOption
43
+ ? [
44
+ this.currentOption,
45
+ ...options.allOptions.filter((item) => item !== this.currentOption),
46
+ ]
47
+ : options.allOptions;
48
+ this.theme = options.theme;
49
+ this.keybindings = options.keybindings;
50
+ this.onSelect = options.onSelect;
51
+ this.onCancel = options.onCancel;
52
+ this.searchInput = new Input();
53
+ this.filteredOptions = this.allOptions;
54
+ }
55
+
56
+ invalidate(): void {
57
+ this.searchInput.invalidate();
58
+ }
59
+
60
+ render(width: number): string[] {
61
+ const lines: string[] = ["═".repeat(width)];
62
+ lines.push(` ${this.theme.fg("accent", this.theme.bold(this.title))}`);
63
+ const inputLines = this.searchInput.render(width - 12);
64
+ lines.push(
65
+ ` ${this.theme.fg("accent", "Search: ")}${inputLines[0] || ""}`
66
+ );
67
+ lines.push("");
68
+
69
+ const query = this.searchInput.getValue().trim();
70
+ this.filteredOptions = query
71
+ ? fuzzyFilter(this.allOptions, query, (item) => item)
72
+ : this.allOptions;
73
+ this.selectedIndex = Math.min(
74
+ this.selectedIndex,
75
+ Math.max(0, this.filteredOptions.length - 1)
76
+ );
77
+
78
+ const maxVisible = 10;
79
+ const total = this.filteredOptions.length;
80
+ if (total === 0) {
81
+ lines.push(` ${this.theme.fg("muted", "No matching models found.")}`);
82
+ } else {
83
+ const startIndex = Math.max(
84
+ 0,
85
+ Math.min(
86
+ this.selectedIndex - Math.floor(maxVisible / 2),
87
+ total - maxVisible
88
+ )
89
+ );
90
+ const endIndex = Math.min(startIndex + maxVisible, total);
91
+ for (let i = startIndex; i < endIndex; i += 1) {
92
+ const item = this.filteredOptions[i];
93
+ const tick = item === this.currentOption ? "✓ " : " ";
94
+ if (i === this.selectedIndex) {
95
+ lines.push(
96
+ ` ${this.theme.fg("accent", "→ ")}${this.theme.fg("accent", `${tick}${item}`)}`
97
+ );
98
+ } else {
99
+ lines.push(` ${this.theme.fg("text", `${tick}${item}`)}`);
100
+ }
101
+ }
102
+ if (total > maxVisible) {
103
+ lines.push(
104
+ " " +
105
+ this.theme.fg("muted", ` (${this.selectedIndex + 1}/${total})`)
106
+ );
107
+ }
108
+ }
109
+ lines.push("");
110
+ lines.push(
111
+ ` ${this.theme.fg("muted", "Type to search · ↑↓: navigate · Enter: select · Esc: cancel")}`
112
+ );
113
+ lines.push("═".repeat(width));
114
+ return lines;
115
+ }
116
+
117
+ handleInput(keyData: string): void {
118
+ if (this.matchesAction(keyData, "tui.select.up", "\u001b[A")) {
119
+ this.moveSelection(-1);
120
+ return;
121
+ }
122
+ if (this.matchesAction(keyData, "tui.select.down", "\u001b[B")) {
123
+ this.moveSelection(1);
124
+ return;
125
+ }
126
+ if (
127
+ this.matchesAction(keyData, "tui.select.confirm", "\n") ||
128
+ keyData === "\r"
129
+ ) {
130
+ if (this.filteredOptions.length > 0) {
131
+ this.onSelect(this.filteredOptions[this.selectedIndex]);
132
+ }
133
+ return;
134
+ }
135
+ if (this.matchesAction(keyData, "tui.select.cancel", "\u001b")) {
136
+ this.onCancel();
137
+ return;
138
+ }
139
+ this.searchInput.handleInput(keyData);
140
+ this.selectedIndex = 0;
141
+ this.tui.requestRender();
142
+ }
143
+
144
+ private matchesAction(
145
+ keyData: string,
146
+ action: keyof Keybindings,
147
+ fallback: string
148
+ ) {
149
+ return this.keybindings.matches(keyData, action) || keyData === fallback;
150
+ }
151
+
152
+ private moveSelection(direction: -1 | 1) {
153
+ if (this.filteredOptions.length > 0) {
154
+ const lastIndex = this.filteredOptions.length - 1;
155
+ const nextIndex = this.selectedIndex + direction;
156
+ if (nextIndex < 0) {
157
+ this.selectedIndex = lastIndex;
158
+ } else if (nextIndex > lastIndex) {
159
+ this.selectedIndex = 0;
160
+ } else {
161
+ this.selectedIndex = nextIndex;
162
+ }
163
+ }
164
+ this.tui.requestRender();
165
+ }
166
+ }