pi-advisor-flow 0.5.1 → 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.
- package/CHANGELOG.md +14 -0
- package/README.md +3 -2
- package/extensions/index.ts +7 -7
- package/package.json +2 -1
- package/src/commands/activation-preparation.ts +117 -0
- package/src/commands/activation.ts +135 -0
- package/src/commands/lifecycle.ts +83 -0
- package/src/commands/manual-command.ts +107 -0
- package/src/commands/manual-consultation.ts +144 -0
- package/src/commands/manual-progress.ts +93 -0
- package/src/commands/model-commands.ts +66 -0
- package/src/commands/model-options.ts +181 -0
- package/src/commands/model-picker.ts +110 -0
- package/src/commands/registration.ts +27 -0
- package/src/commands/renderers.ts +76 -0
- package/src/commands/runtime.ts +114 -0
- package/src/commands/settings-commands.ts +80 -0
- package/src/commands/settings-persistence.ts +81 -0
- package/src/commands/types.ts +78 -0
- package/src/commands.ts +9 -1259
- package/src/config/args.ts +37 -0
- package/src/config/defaults.ts +190 -0
- package/src/config/state.ts +199 -0
- package/src/config/storage.ts +224 -0
- package/src/config/types.ts +63 -0
- package/src/config/validation.ts +234 -0
- package/src/config.ts +104 -814
- package/src/conversation.ts +4 -2
- package/src/herdr.ts +1 -1
- package/src/model-stream.ts +15 -1
- package/src/scout-context.ts +2 -2
- package/src/scout.ts +1 -1
- package/src/tools/consultation.ts +337 -0
- package/src/tools/gate-policy.ts +132 -0
- package/src/tools/gate-protocol.ts +127 -0
- package/src/tools/loop-gate.ts +210 -0
- package/src/tools/prompts.ts +159 -0
- package/src/tools/register-ask-advisor.ts +225 -0
- package/src/tools/register-lifecycle.ts +89 -0
- package/src/tools/register-outcome.ts +75 -0
- package/src/tools/register-renderers.ts +88 -0
- package/src/tools/registration.ts +35 -0
- package/src/tools/render-advisor-result.ts +168 -0
- package/src/tools/render-common.ts +120 -0
- package/src/tools/scout-status.ts +206 -0
- package/src/tools/session.ts +3 -0
- package/src/tools/types.ts +119 -0
- package/src/tools.ts +42 -1865
- package/src/ui/manual-dialog-render.ts +150 -0
- package/src/ui/manual-dialog.ts +305 -0
- package/src/ui/model-selector.ts +166 -0
- package/src/ui/settings-formatting.ts +133 -0
- package/src/ui/settings-items.ts +323 -0
- package/src/ui/settings-list-adapter.ts +103 -0
- package/src/ui/settings-mutations.ts +80 -0
- package/src/ui/settings-selector.ts +161 -0
- package/src/ui/text-setting-submenu.ts +66 -0
- package/src/ui/types.ts +111 -0
- 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
|
+
}
|