@wuyaos/pi-model-config 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +200 -2
- package/extensions/model-config.ts +27 -0
- package/model-config.schema.json +269 -0
- package/package.json +52 -4
- package/src/command.ts +1053 -0
- package/src/config.ts +438 -0
- package/src/context-probe.ts +90 -0
- package/src/discovery.ts +427 -0
- package/src/i18n.ts +824 -0
- package/src/model-editor.ts +326 -0
- package/src/model-test.ts +212 -0
- package/src/panel.ts +477 -0
- package/src/runtime.ts +298 -0
- package/src/selector.ts +433 -0
- package/src/thinking-defaults.ts +39 -0
- package/src/thinking-probe.ts +84 -0
- package/src/transport.ts +94 -0
- package/src/types.ts +150 -0
- package/src/ui-config.ts +44 -0
- package/src/ui.ts +179 -0
package/src/panel.ts
ADDED
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
Input,
|
|
4
|
+
Key,
|
|
5
|
+
matchesKey,
|
|
6
|
+
truncateToWidth,
|
|
7
|
+
visibleWidth,
|
|
8
|
+
wrapTextWithAnsi,
|
|
9
|
+
type Component,
|
|
10
|
+
type Focusable,
|
|
11
|
+
type TUI,
|
|
12
|
+
} from "@earendil-works/pi-tui";
|
|
13
|
+
import { t, type UiLanguage } from "./i18n.ts";
|
|
14
|
+
|
|
15
|
+
export interface PanelItem {
|
|
16
|
+
id: string;
|
|
17
|
+
label: string;
|
|
18
|
+
details: string[];
|
|
19
|
+
modelDetails?: string[];
|
|
20
|
+
canTakeoverExisting?: boolean;
|
|
21
|
+
enabled?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface OverviewPanelState {
|
|
25
|
+
search: string;
|
|
26
|
+
selectedId?: string;
|
|
27
|
+
focus: "search" | "list" | "details" | "actions";
|
|
28
|
+
actionIndex: number;
|
|
29
|
+
detailScroll: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface MenuPanelState {
|
|
33
|
+
selectedId?: string;
|
|
34
|
+
focus: "list" | "details" | "actions";
|
|
35
|
+
actionIndex: number;
|
|
36
|
+
detailScroll: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type ProviderPanelResult =
|
|
40
|
+
| { action: "enter"; providerId: string }
|
|
41
|
+
| { action: "toggle" | "delete"; providerId: string }
|
|
42
|
+
| { action: "add" | "language" | "close" };
|
|
43
|
+
|
|
44
|
+
export type ConnectionPanelResult =
|
|
45
|
+
| { action: "field"; fieldId: string }
|
|
46
|
+
| { action: "models" | "takeover" | "testModels" | "preview" | "back" };
|
|
47
|
+
|
|
48
|
+
export type PreviewPanelResult = { action: "save" | "back" | "discard" };
|
|
49
|
+
|
|
50
|
+
export interface FooterAction {
|
|
51
|
+
id: string;
|
|
52
|
+
label: string;
|
|
53
|
+
key: string;
|
|
54
|
+
enabled?: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const MODEL_CONFIG_OVERLAY_OPTIONS = {
|
|
58
|
+
overlay: true,
|
|
59
|
+
overlayOptions: { anchor: "center", width: "96%", maxHeight: "90%", margin: 1 },
|
|
60
|
+
} as const;
|
|
61
|
+
|
|
62
|
+
function terminalRows(tui: TUI): number {
|
|
63
|
+
return Number.isFinite(tui.terminal?.rows) && tui.terminal.rows > 0 ? tui.terminal.rows : 24;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function overlayViewportRows(tui: TUI): number {
|
|
67
|
+
return Math.max(4, Math.floor(terminalRows(tui) * 0.9));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function pad(line: string, width: number): string {
|
|
71
|
+
const clipped = truncateToWidth(line, Math.max(1, width), "");
|
|
72
|
+
return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function renderPanelBox(lines: string[], width: number, title: string, theme: Theme): string[] {
|
|
76
|
+
const inner = Math.max(1, width - 2);
|
|
77
|
+
const titleText = truncateToWidth(` ${title} `, inner, "…");
|
|
78
|
+
const titleWidth = visibleWidth(titleText);
|
|
79
|
+
const left = Math.max(0, Math.floor((inner - titleWidth) / 2));
|
|
80
|
+
const right = Math.max(0, inner - titleWidth - left);
|
|
81
|
+
const border = (value: string) => theme.fg("border", value);
|
|
82
|
+
return [
|
|
83
|
+
`${border(`╭${"─".repeat(left)}`)}${theme.fg("accent", theme.bold(titleText))}${border(`${"─".repeat(right)}╮`)}`,
|
|
84
|
+
...lines.map((line) => `${border("│")}${pad(line, inner)}${border("│")}`),
|
|
85
|
+
border(`╰${"─".repeat(inner)}╯`),
|
|
86
|
+
];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function joinColumns(left: string[], right: string[], leftWidth: number, rightWidth: number, gap = 2): string[] {
|
|
90
|
+
const height = Math.max(left.length, right.length);
|
|
91
|
+
return Array.from({ length: height }, (_, index) => `${pad(left[index] ?? "", leftWidth)}${" ".repeat(gap)}${pad(right[index] ?? "", rightWidth)}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function wrapped(theme: Theme, lines: string[], width: number): string[] {
|
|
95
|
+
return lines.flatMap((line) => wrapTextWithAnsi(theme.fg("muted", line), Math.max(1, width)));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function viewportStart(index: number, count: number, visible: number): number {
|
|
99
|
+
return Math.max(0, Math.min(index - Math.floor(visible / 2), Math.max(0, count - visible)));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function renderActionFooter(actions: FooterAction[], selectedIndex: number, focused: boolean, width: number, theme: Theme): string[] {
|
|
103
|
+
const rows: string[] = [];
|
|
104
|
+
let current = "";
|
|
105
|
+
for (let index = 0; index < actions.length; index++) {
|
|
106
|
+
const action = actions[index]!;
|
|
107
|
+
const selected = focused && index === selectedIndex;
|
|
108
|
+
const marker = selected ? "→ " : " ";
|
|
109
|
+
const raw = `${marker}${action.label}`;
|
|
110
|
+
const styled = theme.fg(action.enabled === false ? "dim" : selected ? "accent" : "muted", selected ? theme.bold(raw) : raw);
|
|
111
|
+
if (current && visibleWidth(current) + 2 + visibleWidth(styled) > width) {
|
|
112
|
+
rows.push(truncateToWidth(current, width, ""));
|
|
113
|
+
current = styled;
|
|
114
|
+
} else current = current ? `${current} ${styled}` : styled;
|
|
115
|
+
}
|
|
116
|
+
if (current || rows.length === 0) rows.push(truncateToWidth(current, width, ""));
|
|
117
|
+
return rows;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function moveFocus<T extends string>(current: T, order: readonly T[], reverse: boolean): T {
|
|
121
|
+
const index = Math.max(0, order.indexOf(current));
|
|
122
|
+
return order[(index + (reverse ? -1 : 1) + order.length) % order.length]!;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
class OverviewPanel implements Component, Focusable {
|
|
126
|
+
private readonly search: Input;
|
|
127
|
+
private filtered: PanelItem[] = [];
|
|
128
|
+
private selectedIndex = 0;
|
|
129
|
+
private detailMaxScroll = 0;
|
|
130
|
+
private _focused = false;
|
|
131
|
+
|
|
132
|
+
constructor(
|
|
133
|
+
private readonly tui: TUI,
|
|
134
|
+
private readonly theme: Theme,
|
|
135
|
+
private readonly language: UiLanguage,
|
|
136
|
+
private readonly items: PanelItem[],
|
|
137
|
+
private readonly state: OverviewPanelState,
|
|
138
|
+
private readonly done: (result: ProviderPanelResult) => void,
|
|
139
|
+
) {
|
|
140
|
+
this.search = new Input({ prompt: t(language, "panelSearchProviderPrompt"), placeholder: t(language, "panelSearchProviderPlaceholder") });
|
|
141
|
+
this.search.setValue(state.search);
|
|
142
|
+
this.search.onSubmit = () => this.enterCurrent();
|
|
143
|
+
this.applyFilter();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
get focused(): boolean { return this._focused; }
|
|
147
|
+
set focused(value: boolean) { this._focused = value; this.updateInputFocus(); }
|
|
148
|
+
|
|
149
|
+
private actions(): FooterAction[] {
|
|
150
|
+
return [
|
|
151
|
+
{ id: "enter", label: t(this.language, "overviewEnter"), key: "enter", enabled: !!this.current() },
|
|
152
|
+
{ id: "search", label: t(this.language, "overviewSearch"), key: "/" },
|
|
153
|
+
{ id: "toggle", label: t(this.language, this.current()?.enabled === false ? "overviewEnable" : "overviewDisable"), key: Key.ctrl("x"), enabled: !!this.current() },
|
|
154
|
+
{ id: "delete", label: t(this.language, "overviewDelete"), key: Key.ctrl("delete"), enabled: !!this.current() },
|
|
155
|
+
{ id: "add", label: t(this.language, "overviewAdd"), key: Key.ctrl("n") },
|
|
156
|
+
{ id: "close", label: t(this.language, "overviewClose"), key: Key.escape },
|
|
157
|
+
{ id: "language", label: t(this.language, "overviewLanguage"), key: Key.f2 },
|
|
158
|
+
];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private current(): PanelItem | undefined { return this.filtered[this.selectedIndex]; }
|
|
162
|
+
private updateInputFocus(): void { this.search.focused = this._focused && this.state.focus === "search"; }
|
|
163
|
+
private request(): void { this.updateInputFocus(); this.tui.requestRender(); }
|
|
164
|
+
private syncCurrent(): void { this.state.selectedId = this.current()?.id; }
|
|
165
|
+
|
|
166
|
+
private applyFilter(): void {
|
|
167
|
+
const query = this.search.getValue().trim().toLowerCase();
|
|
168
|
+
this.state.search = this.search.getValue();
|
|
169
|
+
this.filtered = query ? this.items.filter((item) => `${item.id} ${item.label}`.toLowerCase().includes(query)) : [...this.items];
|
|
170
|
+
const remembered = this.state.selectedId ? this.filtered.findIndex((item) => item.id === this.state.selectedId) : -1;
|
|
171
|
+
this.selectedIndex = remembered >= 0 ? remembered : Math.min(this.selectedIndex, Math.max(0, this.filtered.length - 1));
|
|
172
|
+
this.syncCurrent();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private enterCurrent(): void {
|
|
176
|
+
const current = this.current();
|
|
177
|
+
if (current) this.done({ action: "enter", providerId: current.id });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private activate(action: FooterAction | undefined): void {
|
|
181
|
+
if (!action || action.enabled === false) return;
|
|
182
|
+
if (action.id === "enter") this.enterCurrent();
|
|
183
|
+
else if (action.id === "toggle" || action.id === "delete") { const current = this.current(); if (current) this.done({ action: action.id, providerId: current.id }); }
|
|
184
|
+
else if (action.id === "search") { this.state.focus = "search"; this.request(); }
|
|
185
|
+
else this.done({ action: action.id as "add" | "close" | "language" });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
handleInput(data: string): void {
|
|
189
|
+
if (data === "/") { this.state.focus = "search"; this.request(); return; }
|
|
190
|
+
if (matchesKey(data, Key.f2)) { this.done({ action: "language" }); return; }
|
|
191
|
+
if (matchesKey(data, Key.ctrl("n"))) { this.done({ action: "add" }); return; }
|
|
192
|
+
if (matchesKey(data, Key.ctrl("x"))) { const current = this.current(); if (current) this.done({ action: "toggle", providerId: current.id }); return; }
|
|
193
|
+
if (matchesKey(data, Key.ctrl("delete"))) { const current = this.current(); if (current) this.done({ action: "delete", providerId: current.id }); return; }
|
|
194
|
+
if (matchesKey(data, Key.escape)) { this.done({ action: "close" }); return; }
|
|
195
|
+
if (matchesKey(data, Key.tab) || matchesKey(data, "shift+tab")) {
|
|
196
|
+
this.state.focus = moveFocus(this.state.focus, ["search", "list", "details", "actions"] as const, matchesKey(data, "shift+tab"));
|
|
197
|
+
this.request();
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (matchesKey(data, Key.left)) {
|
|
201
|
+
if (this.state.focus === "actions") { const actions = this.actions(); this.state.actionIndex = (this.state.actionIndex - 1 + actions.length) % actions.length; this.request(); return; }
|
|
202
|
+
if (this.state.focus === "details") this.state.focus = "list";
|
|
203
|
+
this.request();
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (matchesKey(data, Key.right)) {
|
|
207
|
+
if (this.state.focus === "actions") { const actions = this.actions(); this.state.actionIndex = (this.state.actionIndex + 1) % actions.length; this.request(); return; }
|
|
208
|
+
if (this.state.focus === "search" || this.state.focus === "list") this.state.focus = "details";
|
|
209
|
+
else if (this.state.focus === "details") this.state.focus = "actions";
|
|
210
|
+
this.request();
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if (matchesKey(data, Key.up) || matchesKey(data, Key.down)) {
|
|
214
|
+
const delta = matchesKey(data, Key.up) ? -1 : 1;
|
|
215
|
+
if (this.state.focus === "actions") { this.request(); return; } else if ((this.state.focus === "search" || this.state.focus === "list" || this.state.focus === "details") && this.filtered.length) {
|
|
216
|
+
this.selectedIndex = Math.max(0, Math.min(this.filtered.length - 1, this.selectedIndex + delta));
|
|
217
|
+
this.state.detailScroll = 0;
|
|
218
|
+
this.syncCurrent();
|
|
219
|
+
}
|
|
220
|
+
this.request();
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (matchesKey(data, Key.pageUp) || matchesKey(data, Key.pageDown)) {
|
|
224
|
+
this.state.detailScroll = Math.min(this.detailMaxScroll, Math.max(0, this.state.detailScroll + (matchesKey(data, Key.pageUp) ? -5 : 5)));
|
|
225
|
+
this.request();
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
if (matchesKey(data, Key.enter)) {
|
|
229
|
+
if (this.state.focus === "actions") this.activate(this.actions()[this.state.actionIndex]);
|
|
230
|
+
else this.enterCurrent();
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (matchesKey(data, Key.space) && this.state.focus === "actions") {
|
|
234
|
+
this.activate(this.actions()[this.state.actionIndex]);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (this.state.focus === "search") {
|
|
238
|
+
this.search.handleInput(data);
|
|
239
|
+
this.applyFilter();
|
|
240
|
+
this.request();
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
render(width: number): string[] {
|
|
245
|
+
const safeWidth = Math.max(4, width);
|
|
246
|
+
const innerWidth = Math.max(1, safeWidth - 2);
|
|
247
|
+
const wide = innerWidth >= 68;
|
|
248
|
+
const contentBudget = Math.max(2, overlayViewportRows(this.tui) - 2);
|
|
249
|
+
const footer = renderActionFooter(this.actions(), this.state.actionIndex, this.state.focus === "actions", innerWidth, this.theme);
|
|
250
|
+
const top = this.search.render(innerWidth);
|
|
251
|
+
const bodyBudget = Math.max(0, contentBudget - top.length - footer.length - 1);
|
|
252
|
+
const leftWidth = wide ? Math.max(24, Math.floor((innerWidth - 2) * 0.4)) : innerWidth;
|
|
253
|
+
const rightWidth = wide ? innerWidth - leftWidth - 2 : innerWidth;
|
|
254
|
+
const visible = Math.max(0, bodyBudget);
|
|
255
|
+
const start = viewportStart(this.selectedIndex, this.filtered.length, visible);
|
|
256
|
+
const left: string[] = [];
|
|
257
|
+
for (let index = start; index < Math.min(start + visible, this.filtered.length); index++) {
|
|
258
|
+
const selected = index === this.selectedIndex;
|
|
259
|
+
const indicator = selected ? "→ " : " ";
|
|
260
|
+
const item = this.filtered[index]!;
|
|
261
|
+
const label = truncateToWidth(item.label, Math.max(1, leftWidth - 2), "…");
|
|
262
|
+
const styled = item.enabled === false
|
|
263
|
+
? this.theme.fg("dim", selected ? this.theme.bold(label) : label)
|
|
264
|
+
: selected ? this.theme.fg("accent", this.theme.bold(label)) : label;
|
|
265
|
+
left.push(truncateToWidth(`${indicator}${styled}`, leftWidth, ""));
|
|
266
|
+
}
|
|
267
|
+
if (!this.filtered.length && visible > 0) left.push(this.theme.fg("muted", t(this.language, "panelNoConfiguredProviders")));
|
|
268
|
+
const current = this.current();
|
|
269
|
+
const detailAll = current ? [this.theme.fg("accent", this.theme.bold(current.label)), ...wrapped(this.theme, current.details, rightWidth)] : [t(this.language, "panelNoProviderSelected")];
|
|
270
|
+
this.detailMaxScroll = Math.max(0, detailAll.length - bodyBudget);
|
|
271
|
+
this.state.detailScroll = Math.min(this.state.detailScroll, this.detailMaxScroll);
|
|
272
|
+
const right = detailAll.slice(this.state.detailScroll, this.state.detailScroll + bodyBudget);
|
|
273
|
+
const body = wide ? joinColumns(left, right, leftWidth, rightWidth) : this.state.focus === "details" ? right : left;
|
|
274
|
+
const content = [...top, ...body.slice(0, bodyBudget), ...footer].slice(0, contentBudget);
|
|
275
|
+
return renderPanelBox(content, safeWidth, t(this.language, "mainTitle"), this.theme);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
invalidate(): void { this.search.invalidate(); }
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
class MenuDetailPanel implements Component, Focusable {
|
|
282
|
+
private selectedIndex = 0;
|
|
283
|
+
private detailMaxScroll = 0;
|
|
284
|
+
private _focused = false;
|
|
285
|
+
constructor(
|
|
286
|
+
private readonly tui: TUI,
|
|
287
|
+
private readonly theme: Theme,
|
|
288
|
+
private readonly language: UiLanguage,
|
|
289
|
+
private readonly title: string,
|
|
290
|
+
private readonly items: PanelItem[],
|
|
291
|
+
private readonly actions: FooterAction[],
|
|
292
|
+
private readonly state: MenuPanelState,
|
|
293
|
+
private readonly itemActivates: boolean,
|
|
294
|
+
private readonly done: (result: { kind: "item" | "action" | "language" | "back"; id?: string }) => void,
|
|
295
|
+
) {
|
|
296
|
+
const remembered = state.selectedId ? items.findIndex((item) => item.id === state.selectedId) : -1;
|
|
297
|
+
this.selectedIndex = remembered >= 0 ? remembered : 0;
|
|
298
|
+
this.syncCurrent();
|
|
299
|
+
}
|
|
300
|
+
get focused(): boolean { return this._focused; }
|
|
301
|
+
set focused(value: boolean) { this._focused = value; }
|
|
302
|
+
private current(): PanelItem | undefined { return this.items[this.selectedIndex]; }
|
|
303
|
+
private syncCurrent(): void { this.state.selectedId = this.current()?.id; }
|
|
304
|
+
private request(): void { this.tui.requestRender(); }
|
|
305
|
+
private activate(action: FooterAction | undefined): void { if (action) this.done({ kind: "action", id: action.id }); }
|
|
306
|
+
|
|
307
|
+
handleInput(data: string): void {
|
|
308
|
+
const shortcut = this.actions.find((action) => matchesKey(data, action.key as any));
|
|
309
|
+
if (shortcut && !(matchesKey(data, Key.enter) && this.state.focus === "actions")) { this.activate(shortcut); return; }
|
|
310
|
+
if (matchesKey(data, Key.escape)) { this.done({ kind: "back" }); return; }
|
|
311
|
+
if (matchesKey(data, Key.tab) || matchesKey(data, "shift+tab")) {
|
|
312
|
+
this.state.focus = moveFocus(this.state.focus, ["list", "details", "actions"] as const, matchesKey(data, "shift+tab"));
|
|
313
|
+
this.request();
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
if (matchesKey(data, Key.left)) { if (this.state.focus === "actions") this.state.actionIndex = (this.state.actionIndex - 1 + this.actions.length) % this.actions.length; else this.state.focus = "list"; this.request(); return; }
|
|
317
|
+
if (matchesKey(data, Key.right) && this.state.focus === "actions") { this.state.actionIndex = (this.state.actionIndex + 1) % this.actions.length; this.request(); return; }
|
|
318
|
+
if (matchesKey(data, Key.right) && this.state.focus === "list") { this.state.focus = "details"; this.request(); return; }
|
|
319
|
+
if (matchesKey(data, Key.up) || matchesKey(data, Key.down)) {
|
|
320
|
+
const delta = matchesKey(data, Key.up) ? -1 : 1;
|
|
321
|
+
if (this.state.focus === "actions") { this.request(); return; }
|
|
322
|
+
else if ((this.state.focus === "list" || this.state.focus === "details") && this.items.length) {
|
|
323
|
+
this.selectedIndex = (this.selectedIndex + delta + this.items.length) % this.items.length;
|
|
324
|
+
this.state.detailScroll = 0;
|
|
325
|
+
this.syncCurrent();
|
|
326
|
+
}
|
|
327
|
+
this.request();
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if (matchesKey(data, Key.pageUp) || matchesKey(data, Key.pageDown)) {
|
|
331
|
+
this.state.detailScroll = Math.min(this.detailMaxScroll, Math.max(0, this.state.detailScroll + (matchesKey(data, Key.pageUp) ? -5 : 5)));
|
|
332
|
+
this.request();
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
if (matchesKey(data, Key.enter) || matchesKey(data, Key.space)) {
|
|
336
|
+
if (this.state.focus === "actions") this.activate(this.actions[this.state.actionIndex]);
|
|
337
|
+
else if (this.itemActivates && (this.state.focus === "list" || this.state.focus === "details")) this.done({ kind: "item", id: this.current()?.id });
|
|
338
|
+
else if (this.state.focus === "list") { this.state.focus = "details"; this.request(); }
|
|
339
|
+
else if (this.state.focus === "details") { this.state.focus = "actions"; this.request(); }
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
render(width: number): string[] {
|
|
344
|
+
const safeWidth = Math.max(4, width);
|
|
345
|
+
const innerWidth = Math.max(1, safeWidth - 2);
|
|
346
|
+
const wide = innerWidth >= 68;
|
|
347
|
+
const contentBudget = Math.max(2, overlayViewportRows(this.tui) - 2);
|
|
348
|
+
this.state.actionIndex = Math.min(this.state.actionIndex, Math.max(0, this.actions.length - 1));
|
|
349
|
+
const footer = renderActionFooter(this.actions, this.state.actionIndex, this.state.focus === "actions", innerWidth, this.theme);
|
|
350
|
+
const bodyBudget = Math.max(0, contentBudget - footer.length);
|
|
351
|
+
const leftWidth = wide ? Math.max(24, Math.floor((innerWidth - 2) * 0.4)) : innerWidth;
|
|
352
|
+
const rightWidth = wide ? innerWidth - leftWidth - 2 : innerWidth;
|
|
353
|
+
const visible = Math.max(0, bodyBudget);
|
|
354
|
+
const start = viewportStart(this.selectedIndex, this.items.length, visible);
|
|
355
|
+
const left: string[] = [];
|
|
356
|
+
for (let index = start; index < Math.min(start + visible, this.items.length); index++) {
|
|
357
|
+
const selected = index === this.selectedIndex;
|
|
358
|
+
const indicator = selected ? "→ " : " ";
|
|
359
|
+
const label = truncateToWidth(this.items[index]!.label, Math.max(1, leftWidth - 2), "…");
|
|
360
|
+
left.push(truncateToWidth(`${indicator}${selected ? this.theme.fg("accent", this.theme.bold(label)) : label}`, leftWidth, ""));
|
|
361
|
+
}
|
|
362
|
+
const rightAll = this.current() ? wrapped(this.theme, this.current()!.details, rightWidth) : [];
|
|
363
|
+
this.detailMaxScroll = Math.max(0, rightAll.length - bodyBudget);
|
|
364
|
+
this.state.detailScroll = Math.min(this.state.detailScroll, this.detailMaxScroll);
|
|
365
|
+
const right = rightAll.slice(this.state.detailScroll, this.state.detailScroll + bodyBudget);
|
|
366
|
+
const body = wide ? joinColumns(left, right, leftWidth, rightWidth) : this.state.focus === "details" ? right : left;
|
|
367
|
+
return renderPanelBox([...body.slice(0, bodyBudget), ...footer].slice(0, contentBudget), safeWidth, this.title, this.theme);
|
|
368
|
+
}
|
|
369
|
+
invalidate(): void {}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export interface ConfirmationAction {
|
|
373
|
+
id: string;
|
|
374
|
+
label: string;
|
|
375
|
+
shortcut?: string;
|
|
376
|
+
severity?: "normal" | "warning" | "error";
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export interface ConfirmationPanelOptions {
|
|
380
|
+
title: string;
|
|
381
|
+
summary: string[];
|
|
382
|
+
details?: string[];
|
|
383
|
+
actions: ConfirmationAction[];
|
|
384
|
+
defaultActionIndex: number;
|
|
385
|
+
cancelActionId: string;
|
|
386
|
+
severity?: "warning" | "error";
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
class ConfirmationPanel implements Component {
|
|
390
|
+
private actionIndex: number;
|
|
391
|
+
constructor(private tui: TUI, private theme: Theme, private options: ConfirmationPanelOptions, private done: (id: string) => void) {
|
|
392
|
+
this.actionIndex = Math.max(0, Math.min(options.defaultActionIndex, options.actions.length - 1));
|
|
393
|
+
}
|
|
394
|
+
handleInput(data: string): void {
|
|
395
|
+
if (matchesKey(data, Key.escape)) { this.done(this.options.cancelActionId); return; }
|
|
396
|
+
const shortcut = this.options.actions.find((action) => action.shortcut && matchesKey(data, action.shortcut as any));
|
|
397
|
+
if (shortcut) { this.done(shortcut.id); return; }
|
|
398
|
+
if (matchesKey(data, Key.tab) || matchesKey(data, "shift+tab") || matchesKey(data, Key.left) || matchesKey(data, Key.right)) {
|
|
399
|
+
const reverse = matchesKey(data, "shift+tab") || matchesKey(data, Key.left);
|
|
400
|
+
this.actionIndex = (this.actionIndex + (reverse ? -1 : 1) + this.options.actions.length) % this.options.actions.length;
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
if (matchesKey(data, Key.up) || matchesKey(data, Key.down)) return;
|
|
404
|
+
if (matchesKey(data, Key.enter) || matchesKey(data, Key.space)) this.done(this.options.actions[this.actionIndex]!.id);
|
|
405
|
+
}
|
|
406
|
+
render(width: number): string[] {
|
|
407
|
+
const inner = Math.max(1, width - 2);
|
|
408
|
+
const wide = inner >= 68;
|
|
409
|
+
const leftWidth = wide ? Math.max(24, Math.floor((inner - 2) * 0.45)) : inner;
|
|
410
|
+
const rightWidth = wide ? inner - leftWidth - 2 : inner;
|
|
411
|
+
const color = this.options.severity === "error" ? "error" : "warning";
|
|
412
|
+
const summary = this.options.summary.flatMap((line) => wrapTextWithAnsi(this.theme.fg(color, line), leftWidth));
|
|
413
|
+
const details = (this.options.details ?? []).flatMap((line) => wrapTextWithAnsi(this.theme.fg("muted", line), rightWidth));
|
|
414
|
+
const body = wide ? joinColumns(summary, details, leftWidth, rightWidth) : [...summary, ...(details.length ? ["", ...details] : [])];
|
|
415
|
+
const actionRows = renderActionFooter(this.options.actions.map((action) => ({ id: action.id, label: action.label, key: action.shortcut ?? "" })), this.actionIndex, true, inner, this.theme);
|
|
416
|
+
const budget = Math.max(2, overlayViewportRows(this.tui) - 2);
|
|
417
|
+
return renderPanelBox([...body.slice(0, Math.max(0, budget - actionRows.length)), ...actionRows], width, this.options.title, this.theme);
|
|
418
|
+
}
|
|
419
|
+
invalidate() {}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
export async function showConfirmationPanel(ctx: ExtensionCommandContext, options: ConfirmationPanelOptions): Promise<string> {
|
|
423
|
+
return ctx.ui.custom((tui, theme, _keys, done) => new ConfirmationPanel(tui, theme, options, done), MODEL_CONFIG_OVERLAY_OPTIONS);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export async function showProviderPanel(ctx: ExtensionCommandContext, language: UiLanguage, items: PanelItem[], state: OverviewPanelState = { search: "", focus: "list", actionIndex: 0, detailScroll: 0 }): Promise<ProviderPanelResult> {
|
|
427
|
+
return ctx.ui.custom((tui, theme, _keys, done) => new OverviewPanel(tui, theme, language, items, state, done), MODEL_CONFIG_OVERLAY_OPTIONS);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export async function showConnectionPanel(
|
|
431
|
+
ctx: ExtensionCommandContext,
|
|
432
|
+
language: UiLanguage,
|
|
433
|
+
providerId: string,
|
|
434
|
+
fields: PanelItem[],
|
|
435
|
+
state: MenuPanelState = { focus: "list", actionIndex: 0, detailScroll: 0 },
|
|
436
|
+
canTakeover = false,
|
|
437
|
+
): Promise<ConnectionPanelResult> {
|
|
438
|
+
const actions: FooterAction[] = [
|
|
439
|
+
{ id: "edit", label: t(language, "connectionModify"), key: Key.enter },
|
|
440
|
+
{ id: "models", label: t(language, "detailConfigureModels"), key: Key.ctrl("m") },
|
|
441
|
+
...(canTakeover ? [{ id: "takeover", label: t(language, "detailTakeover"), key: Key.ctrl("t") }] : []),
|
|
442
|
+
{ id: "testModels", label: t(language, "detailTestModels"), key: Key.ctrl("p") },
|
|
443
|
+
{ id: "preview", label: t(language, "connectionPreview"), key: Key.ctrl("s") },
|
|
444
|
+
{ id: "back", label: t(language, "connectionBack"), key: Key.escape },
|
|
445
|
+
];
|
|
446
|
+
const result = await ctx.ui.custom<{ kind: "item" | "action" | "language" | "back"; id?: string }>((tui, theme, _keys, done) =>
|
|
447
|
+
new MenuDetailPanel(tui, theme, language, t(language, "providerTitle", { id: providerId }), fields, actions, state, true, done), MODEL_CONFIG_OVERLAY_OPTIONS);
|
|
448
|
+
if (result.kind === "back" || result.id === "back") return { action: "back" };
|
|
449
|
+
if (result.kind === "item" && result.id) return { action: "field", fieldId: result.id };
|
|
450
|
+
if (result.id === "edit" && state.selectedId) return { action: "field", fieldId: state.selectedId };
|
|
451
|
+
if (result.id === "models" || result.id === "takeover" || result.id === "testModels" || result.id === "preview") return { action: result.id };
|
|
452
|
+
return { action: "back" };
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
export async function showPreviewPanel(
|
|
456
|
+
ctx: ExtensionCommandContext,
|
|
457
|
+
language: UiLanguage,
|
|
458
|
+
providerId: string,
|
|
459
|
+
providerDetails: string[],
|
|
460
|
+
modelDetails: string[],
|
|
461
|
+
state: MenuPanelState = { selectedId: "provider", focus: "list", actionIndex: 0, detailScroll: 0 },
|
|
462
|
+
): Promise<PreviewPanelResult> {
|
|
463
|
+
const items: PanelItem[] = [
|
|
464
|
+
{ id: "provider", label: t(language, "previewProviderSettings", { provider: providerId }), details: providerDetails },
|
|
465
|
+
{ id: "models", label: t(language, "previewModelConfig", { provider: providerId }), details: modelDetails },
|
|
466
|
+
];
|
|
467
|
+
const actions: FooterAction[] = [
|
|
468
|
+
{ id: "save", label: t(language, "previewConfirmSave"), key: Key.ctrl("s") },
|
|
469
|
+
{ id: "back", label: t(language, "previewBackEdit"), key: Key.escape },
|
|
470
|
+
{ id: "discard", label: t(language, "previewDiscard"), key: Key.ctrl("d") },
|
|
471
|
+
];
|
|
472
|
+
const result = await ctx.ui.custom<{ kind: "item" | "action" | "language" | "back"; id?: string }>((tui, theme, _keys, done) =>
|
|
473
|
+
new MenuDetailPanel(tui, theme, language, t(language, "previewTitle"), items, actions, state, false, done), MODEL_CONFIG_OVERLAY_OPTIONS);
|
|
474
|
+
if (result.kind === "back" || result.id === "back") return { action: "back" };
|
|
475
|
+
if (result.id === "save" || result.id === "discard") return { action: result.id };
|
|
476
|
+
return showPreviewPanel(ctx, language, providerId, providerDetails, modelDetails, state);
|
|
477
|
+
}
|