@oh-my-pi/pi-coding-agent 15.10.8 → 15.10.10
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 +41 -1
- package/dist/types/config/model-registry.d.ts +13 -0
- package/dist/types/config/settings-schema.d.ts +0 -9
- package/dist/types/debug/terminal-info.d.ts +0 -1
- package/dist/types/extensibility/custom-tools/loader.d.ts +22 -3
- package/dist/types/extensibility/extensions/index.d.ts +1 -1
- package/dist/types/extensibility/extensions/loader.d.ts +17 -1
- package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +8 -0
- package/dist/types/mcp/transports/stdio.d.ts +12 -0
- package/dist/types/modes/components/custom-editor.d.ts +3 -2
- package/dist/types/modes/components/transcript-container.d.ts +12 -26
- package/dist/types/sdk.d.ts +42 -2
- package/dist/types/task/discovery.d.ts +1 -2
- package/dist/types/task/executor.d.ts +16 -0
- package/dist/types/tiny/title-client.d.ts +1 -1
- package/dist/types/tools/index.d.ts +17 -0
- package/dist/types/tools/todo.d.ts +2 -0
- package/dist/types/tui/hyperlink.d.ts +8 -0
- package/package.json +9 -9
- package/src/cli/list-models.ts +5 -11
- package/src/config/model-registry.ts +91 -20
- package/src/config/settings-schema.ts +0 -10
- package/src/debug/terminal-info.ts +0 -3
- package/src/edit/diff.ts +48 -15
- package/src/eval/js/shared/rewrite-imports.ts +9 -1
- package/src/extensibility/custom-tools/loader.ts +43 -19
- package/src/extensibility/extensions/index.ts +1 -0
- package/src/extensibility/extensions/loader.ts +29 -6
- package/src/extensibility/plugins/legacy-pi-compat.ts +30 -6
- package/src/internal-urls/docs-index.generated.ts +4 -4
- package/src/mcp/transports/stdio.ts +139 -3
- package/src/modes/components/custom-editor.ts +69 -9
- package/src/modes/components/model-selector.ts +62 -52
- package/src/modes/components/transcript-container.ts +204 -125
- package/src/modes/controllers/event-controller.ts +0 -45
- package/src/modes/controllers/input-controller.ts +5 -5
- package/src/modes/controllers/mcp-command-controller.ts +2 -2
- package/src/modes/controllers/selector-controller.ts +0 -4
- package/src/modes/interactive-mode.ts +2 -10
- package/src/prompts/system/system-prompt.md +3 -3
- package/src/prompts/tools/bash.md +3 -3
- package/src/prompts/tools/todo.md +5 -1
- package/src/sdk.ts +138 -56
- package/src/ssh/ssh-executor.ts +60 -4
- package/src/task/discovery.ts +17 -24
- package/src/task/executor.ts +19 -0
- package/src/task/index.ts +4 -0
- package/src/tiny/title-client.ts +6 -3
- package/src/tools/index.ts +17 -0
- package/src/tools/todo.ts +16 -7
- package/src/tui/hyperlink.ts +27 -3
- package/src/web/search/providers/anthropic.ts +8 -2
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
* Messages are newline-delimited JSON.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import * as fs from "node:fs/promises";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
|
|
8
11
|
import { getProjectDir, readJsonl, Snowflake } from "@oh-my-pi/pi-utils";
|
|
9
12
|
import { type Subprocess, spawn } from "bun";
|
|
10
13
|
import type {
|
|
@@ -19,6 +22,134 @@ import type {
|
|
|
19
22
|
import { toJsonRpcError } from "../../mcp/types";
|
|
20
23
|
import { isMCPTimeoutEnabled, resolveMCPTimeoutMs } from "../timeout";
|
|
21
24
|
|
|
25
|
+
/** Subprocess argv for launching an MCP stdio server. */
|
|
26
|
+
export interface StdioSpawnCommand {
|
|
27
|
+
cmd: string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Inputs used to resolve platform-specific stdio spawn behavior. */
|
|
31
|
+
export interface ResolveStdioSpawnOptions {
|
|
32
|
+
cwd: string;
|
|
33
|
+
env: Record<string, string | undefined>;
|
|
34
|
+
platform?: NodeJS.Platform;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const DEFAULT_WINDOWS_PATHEXT = [".COM", ".EXE", ".BAT", ".CMD"];
|
|
38
|
+
const WINDOWS_BATCH_EXTENSIONS = new Set([".bat", ".cmd"]);
|
|
39
|
+
|
|
40
|
+
function getCaseInsensitiveEnv(env: Record<string, string | undefined>, name: string): string | undefined {
|
|
41
|
+
const direct = env[name];
|
|
42
|
+
if (direct !== undefined) return direct;
|
|
43
|
+
const normalized = name.toLowerCase();
|
|
44
|
+
for (const [key, value] of Object.entries(env)) {
|
|
45
|
+
if (key.toLowerCase() === normalized) return value;
|
|
46
|
+
}
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getWindowsPathExt(env: Record<string, string | undefined>): string[] {
|
|
51
|
+
const raw = getCaseInsensitiveEnv(env, "PATHEXT");
|
|
52
|
+
if (!raw) return DEFAULT_WINDOWS_PATHEXT;
|
|
53
|
+
const extensions: string[] = [];
|
|
54
|
+
for (const part of raw.split(";")) {
|
|
55
|
+
const trimmed = part.trim();
|
|
56
|
+
if (!trimmed) continue;
|
|
57
|
+
extensions.push(trimmed.startsWith(".") ? trimmed : `.${trimmed}`);
|
|
58
|
+
}
|
|
59
|
+
return extensions.length > 0 ? extensions : DEFAULT_WINDOWS_PATHEXT;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function fileExists(filePath: string): Promise<boolean> {
|
|
63
|
+
try {
|
|
64
|
+
await fs.access(filePath);
|
|
65
|
+
return true;
|
|
66
|
+
} catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function hasPathSegment(command: string): boolean {
|
|
72
|
+
return command.includes("/") || command.includes("\\") || path.isAbsolute(command);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function hasExecutableExtension(command: string, extensions: string[]): boolean {
|
|
76
|
+
const ext = path.extname(command).toLowerCase();
|
|
77
|
+
if (!ext) return false;
|
|
78
|
+
return extensions.some(candidate => candidate.toLowerCase() === ext);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function resolveWindowsCommandPath(
|
|
82
|
+
command: string,
|
|
83
|
+
cwd: string,
|
|
84
|
+
env: Record<string, string | undefined>,
|
|
85
|
+
): Promise<string | null> {
|
|
86
|
+
const extensions = getWindowsPathExt(env);
|
|
87
|
+
if (hasExecutableExtension(command, extensions)) return command;
|
|
88
|
+
|
|
89
|
+
const candidates = extensions.map(ext => `${command}${ext}`);
|
|
90
|
+
if (hasPathSegment(command)) {
|
|
91
|
+
for (const candidate of candidates) {
|
|
92
|
+
const resolved = path.isAbsolute(candidate) ? candidate : path.resolve(cwd, candidate);
|
|
93
|
+
if (await fileExists(resolved)) return resolved;
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const pathValue = getCaseInsensitiveEnv(env, "PATH");
|
|
99
|
+
if (!pathValue) return null;
|
|
100
|
+
for (const dir of pathValue.split(";")) {
|
|
101
|
+
if (!dir) continue;
|
|
102
|
+
for (const candidate of candidates) {
|
|
103
|
+
const resolved = path.join(dir, candidate);
|
|
104
|
+
if (await fileExists(resolved)) return resolved;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function quoteCmdArg(value: string): string {
|
|
111
|
+
if (value.length === 0) return '""';
|
|
112
|
+
let result = '"';
|
|
113
|
+
for (const char of value) {
|
|
114
|
+
if (char === '"') {
|
|
115
|
+
result += '^"';
|
|
116
|
+
} else if (char === "^") {
|
|
117
|
+
result += "^^";
|
|
118
|
+
} else if (char === "%") {
|
|
119
|
+
result += "^%";
|
|
120
|
+
} else {
|
|
121
|
+
result += char;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return `${result}"`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function isWindowsBatchCommand(command: string): boolean {
|
|
128
|
+
return WINDOWS_BATCH_EXTENSIONS.has(path.extname(command).toLowerCase());
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function resolveComSpec(env: Record<string, string | undefined>): string {
|
|
132
|
+
const comspec = getCaseInsensitiveEnv(env, "COMSPEC");
|
|
133
|
+
return comspec && comspec.length > 0 ? comspec : "cmd.exe";
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Resolve the subprocess argv used to launch an MCP stdio server. */
|
|
137
|
+
export async function resolveStdioSpawnCommand(
|
|
138
|
+
config: MCPStdioServerConfig,
|
|
139
|
+
options: ResolveStdioSpawnOptions,
|
|
140
|
+
): Promise<StdioSpawnCommand> {
|
|
141
|
+
const args = config.args ?? [];
|
|
142
|
+
if (options.platform !== "win32") return { cmd: [config.command, ...args] };
|
|
143
|
+
|
|
144
|
+
const resolvedCommand =
|
|
145
|
+
(await resolveWindowsCommandPath(config.command, options.cwd, options.env)) ?? config.command;
|
|
146
|
+
if (!isWindowsBatchCommand(resolvedCommand)) return { cmd: [resolvedCommand, ...args] };
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
cmd: [resolveComSpec(options.env), "/d", "/s", "/c", [resolvedCommand, ...args].map(quoteCmdArg).join(" ")],
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
22
153
|
/** Minimal write surface of `Subprocess.stdin` we need for framed sends. */
|
|
23
154
|
interface FrameSink {
|
|
24
155
|
write(chunk: string): unknown;
|
|
@@ -100,15 +231,20 @@ export class StdioTransport implements MCPTransport {
|
|
|
100
231
|
async connect(): Promise<void> {
|
|
101
232
|
if (this.#connected) return;
|
|
102
233
|
|
|
103
|
-
const args = this.config.args ?? [];
|
|
104
234
|
const env = {
|
|
105
235
|
...Bun.env,
|
|
106
236
|
...this.config.env,
|
|
107
237
|
};
|
|
238
|
+
const cwd = this.config.cwd ?? getProjectDir();
|
|
239
|
+
const spawnCommand = await resolveStdioSpawnCommand(this.config, {
|
|
240
|
+
cwd,
|
|
241
|
+
env,
|
|
242
|
+
platform: process.platform,
|
|
243
|
+
});
|
|
108
244
|
|
|
109
245
|
this.#process = spawn({
|
|
110
|
-
cmd:
|
|
111
|
-
cwd
|
|
246
|
+
cmd: spawnCommand.cmd,
|
|
247
|
+
cwd,
|
|
112
248
|
env,
|
|
113
249
|
stdin: "pipe",
|
|
114
250
|
stdout: "pipe",
|
|
@@ -58,16 +58,72 @@ function buildMatchKeys(keys: readonly KeyId[]): Set<string> {
|
|
|
58
58
|
const BRACKETED_PASTE_START = "\x1b[200~";
|
|
59
59
|
const BRACKETED_PASTE_END = "\x1b[201~";
|
|
60
60
|
const BRACKETED_IMAGE_PATH_REGEX = /\.(?:png|jpe?g|gif|webp)$/i;
|
|
61
|
+
const BRACKETED_IMAGE_PATH_BOUNDARY_REGEX = /\.(?:png|jpe?g|gif|webp)(?=$|["']?\s)/gi;
|
|
62
|
+
const SHELL_ESCAPED_PATH_CHAR_REGEX = /\\([\\\s'"()[\]{}&;<>|?*!$`])/g;
|
|
61
63
|
|
|
62
|
-
|
|
64
|
+
function isPastedPathSeparator(char: string | undefined): boolean {
|
|
65
|
+
return char === undefined || char === " " || char === "\t" || char === "\r" || char === "\n";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function imagePathBoundaryEnd(payload: string, segmentStart: number, extensionEnd: number): number | undefined {
|
|
69
|
+
const quote = payload[segmentStart];
|
|
70
|
+
const afterExtension = payload[extensionEnd];
|
|
71
|
+
if (quote === '"' || quote === "'") {
|
|
72
|
+
return afterExtension === quote && isPastedPathSeparator(payload[extensionEnd + 1])
|
|
73
|
+
? extensionEnd + 1
|
|
74
|
+
: undefined;
|
|
75
|
+
}
|
|
76
|
+
if (isPastedPathSeparator(afterExtension)) return extensionEnd;
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function normalizePastedImagePath(path: string): string {
|
|
81
|
+
const trimmed = path.trim();
|
|
82
|
+
const first = trimmed[0];
|
|
83
|
+
const last = trimmed[trimmed.length - 1];
|
|
84
|
+
const unquoted =
|
|
85
|
+
trimmed.length > 1 && (first === '"' || first === "'") && last === first ? trimmed.slice(1, -1) : trimmed;
|
|
86
|
+
return unquoted.replace(SHELL_ESCAPED_PATH_CHAR_REGEX, "$1");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function extractBracketedImagePastePaths(data: string): string[] | undefined {
|
|
63
90
|
if (!data.startsWith(BRACKETED_PASTE_START)) return undefined;
|
|
64
91
|
const endIndex = data.indexOf(BRACKETED_PASTE_END, BRACKETED_PASTE_START.length);
|
|
65
92
|
if (endIndex === -1 || endIndex + BRACKETED_PASTE_END.length !== data.length) return undefined;
|
|
66
93
|
|
|
67
94
|
const pasted = data.slice(BRACKETED_PASTE_START.length, endIndex).trim();
|
|
68
|
-
if (!pasted
|
|
69
|
-
|
|
70
|
-
|
|
95
|
+
if (!pasted) return undefined;
|
|
96
|
+
|
|
97
|
+
const paths: string[] = [];
|
|
98
|
+
let segmentStart = 0;
|
|
99
|
+
BRACKETED_IMAGE_PATH_BOUNDARY_REGEX.lastIndex = 0;
|
|
100
|
+
for (
|
|
101
|
+
let match = BRACKETED_IMAGE_PATH_BOUNDARY_REGEX.exec(pasted);
|
|
102
|
+
match;
|
|
103
|
+
match = BRACKETED_IMAGE_PATH_BOUNDARY_REGEX.exec(pasted)
|
|
104
|
+
) {
|
|
105
|
+
const extensionEnd = match.index + match[0].length;
|
|
106
|
+
const boundaryEnd = imagePathBoundaryEnd(pasted, segmentStart, extensionEnd);
|
|
107
|
+
if (boundaryEnd === undefined) continue;
|
|
108
|
+
|
|
109
|
+
const path = normalizePastedImagePath(pasted.slice(segmentStart, boundaryEnd));
|
|
110
|
+
if (!path || !BRACKETED_IMAGE_PATH_REGEX.test(path)) return undefined;
|
|
111
|
+
paths.push(path);
|
|
112
|
+
|
|
113
|
+
segmentStart = boundaryEnd;
|
|
114
|
+
while (segmentStart < pasted.length && isPastedPathSeparator(pasted[segmentStart])) {
|
|
115
|
+
segmentStart++;
|
|
116
|
+
}
|
|
117
|
+
BRACKETED_IMAGE_PATH_BOUNDARY_REGEX.lastIndex = segmentStart;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (paths.length === 0 || segmentStart !== pasted.length) return undefined;
|
|
121
|
+
return paths;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function extractBracketedImagePastePath(data: string): string | undefined {
|
|
125
|
+
const paths = extractBracketedImagePastePaths(data);
|
|
126
|
+
return paths?.length === 1 ? paths[0] : undefined;
|
|
71
127
|
}
|
|
72
128
|
|
|
73
129
|
/**
|
|
@@ -111,8 +167,8 @@ export class CustomEditor extends Editor {
|
|
|
111
167
|
onCopyPrompt?: () => void;
|
|
112
168
|
/** Called when the configured image-paste shortcut is pressed. */
|
|
113
169
|
onPasteImage?: () => Promise<boolean>;
|
|
114
|
-
/** Called when a bracketed paste contains
|
|
115
|
-
onPasteImagePath?: (path: string) => void
|
|
170
|
+
/** Called when a bracketed paste contains one or more image-file paths. */
|
|
171
|
+
onPasteImagePath?: (path: string) => void | Promise<void>;
|
|
116
172
|
/** Called when the configured raw text-paste shortcut is pressed. */
|
|
117
173
|
onPasteTextRaw?: () => void;
|
|
118
174
|
/** Called when the configured dequeue shortcut is pressed. */
|
|
@@ -188,9 +244,13 @@ export class CustomEditor extends Editor {
|
|
|
188
244
|
return;
|
|
189
245
|
}
|
|
190
246
|
|
|
191
|
-
const
|
|
192
|
-
if (
|
|
193
|
-
|
|
247
|
+
const pastedImagePaths = extractBracketedImagePastePaths(data);
|
|
248
|
+
if (pastedImagePaths && this.onPasteImagePath) {
|
|
249
|
+
void (async () => {
|
|
250
|
+
for (const path of pastedImagePaths) {
|
|
251
|
+
await this.onPasteImagePath?.(path);
|
|
252
|
+
}
|
|
253
|
+
})();
|
|
194
254
|
return;
|
|
195
255
|
}
|
|
196
256
|
|
|
@@ -263,21 +263,26 @@ export class ModelSelectorComponent extends Container {
|
|
|
263
263
|
// Add bottom border
|
|
264
264
|
this.addChild(new DynamicBorder());
|
|
265
265
|
|
|
266
|
-
//
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
266
|
+
// Hydrate synchronously from the current registry snapshot so the first
|
|
267
|
+
// Enter after opening the selector acts on cached models instead of being
|
|
268
|
+
// dropped while the offline refresh promise is still pending. This stays
|
|
269
|
+
// on the open path, so it must remain cheap — heavy lifting lives in the
|
|
270
|
+
// registry's one-pass getCanonicalModelSelections.
|
|
271
|
+
this.#syncFromRegistryState();
|
|
272
|
+
|
|
273
|
+
// Reconcile with cached discovery state in the background. A --models
|
|
274
|
+
// scope is registry-independent, so the offline reload would only repeat
|
|
275
|
+
// the synchronous hydration above.
|
|
276
|
+
if (this.#scopedModels.length === 0) {
|
|
277
|
+
this.#modelRegistry
|
|
278
|
+
.refresh("offline")
|
|
279
|
+
.then(() => this.#syncFromRegistryState())
|
|
280
|
+
.catch(error => {
|
|
281
|
+
this.#errorMessage = error instanceof Error ? error.message : String(error);
|
|
282
|
+
this.#updateList();
|
|
283
|
+
})
|
|
284
|
+
.finally(() => this.#tui.requestRender());
|
|
285
|
+
}
|
|
281
286
|
}
|
|
282
287
|
|
|
283
288
|
#buildMenuRoleActions(): void {
|
|
@@ -477,37 +482,30 @@ export class ModelSelectorComponent extends Container {
|
|
|
477
482
|
|
|
478
483
|
const candidates = models.map(item => item.model);
|
|
479
484
|
this.#loadRoleModels(candidates);
|
|
480
|
-
const
|
|
485
|
+
const canonicalSelections = this.#modelRegistry.getCanonicalModelSelections({
|
|
481
486
|
availableOnly: this.#scopedModels.length === 0,
|
|
482
487
|
candidates,
|
|
483
488
|
});
|
|
484
|
-
const canonicalModels =
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
variantCount: record.variants.length,
|
|
505
|
-
searchText,
|
|
506
|
-
normalizedSearchText: normalizeSearchText(searchText),
|
|
507
|
-
compactSearchText: compactSearchText(searchText),
|
|
508
|
-
};
|
|
509
|
-
})
|
|
510
|
-
.filter((item): item is CanonicalModelItem => item !== undefined);
|
|
489
|
+
const canonicalModels = canonicalSelections.map(({ record, model: selectedModel }): CanonicalModelItem => {
|
|
490
|
+
const searchText = [
|
|
491
|
+
record.id,
|
|
492
|
+
record.name,
|
|
493
|
+
selectedModel.provider,
|
|
494
|
+
selectedModel.id,
|
|
495
|
+
selectedModel.name,
|
|
496
|
+
...record.variants.flatMap(variant => [variant.selector, variant.model.name]),
|
|
497
|
+
].join(" ");
|
|
498
|
+
return {
|
|
499
|
+
kind: "canonical",
|
|
500
|
+
id: record.id,
|
|
501
|
+
model: selectedModel,
|
|
502
|
+
selector: record.id,
|
|
503
|
+
variantCount: record.variants.length,
|
|
504
|
+
searchText,
|
|
505
|
+
normalizedSearchText: normalizeSearchText(searchText),
|
|
506
|
+
compactSearchText: compactSearchText(searchText),
|
|
507
|
+
};
|
|
508
|
+
});
|
|
511
509
|
|
|
512
510
|
this.#sortModels(models);
|
|
513
511
|
this.#sortCanonicalModels(canonicalModels);
|
|
@@ -523,12 +521,27 @@ export class ModelSelectorComponent extends Container {
|
|
|
523
521
|
);
|
|
524
522
|
}
|
|
525
523
|
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
524
|
+
/**
|
|
525
|
+
* Rebuild the visible model lists from the registry's in-memory state.
|
|
526
|
+
* Re-entrant: runs once synchronously at construction and again whenever a
|
|
527
|
+
* background refresh lands, so it re-applies the live search query and pins
|
|
528
|
+
* the highlighted item by selector — a refresh that reorders or inserts
|
|
529
|
+
* models must not yank the user's selection out from under a pending Enter.
|
|
530
|
+
*/
|
|
531
|
+
#syncFromRegistryState(): void {
|
|
532
|
+
const selectedKey = this.#getSelectedItem()?.selector;
|
|
531
533
|
this.#loadModelsFromCurrentRegistryState();
|
|
534
|
+
this.#buildProviderTabs();
|
|
535
|
+
this.#updateTabBar();
|
|
536
|
+
this.#applyTabFilter();
|
|
537
|
+
if (selectedKey) {
|
|
538
|
+
const visibleItems = this.#getVisibleItems();
|
|
539
|
+
const restoredIndex = visibleItems.findIndex(item => item.selector === selectedKey);
|
|
540
|
+
if (restoredIndex >= 0 && restoredIndex !== this.#selectedIndex) {
|
|
541
|
+
this.#selectedIndex = this.#coerceSelectedIndex(restoredIndex, visibleItems);
|
|
542
|
+
this.#updateList();
|
|
543
|
+
}
|
|
544
|
+
}
|
|
532
545
|
}
|
|
533
546
|
|
|
534
547
|
#buildProviderTabs(): void {
|
|
@@ -631,10 +644,7 @@ export class ModelSelectorComponent extends Container {
|
|
|
631
644
|
// here must stay purely in-memory — do not call modelRegistry.refresh()
|
|
632
645
|
// again or tab switches will pay an extra whole-registry reload after the
|
|
633
646
|
// network round-trip completes.
|
|
634
|
-
this.#
|
|
635
|
-
this.#buildProviderTabs();
|
|
636
|
-
this.#updateTabBar();
|
|
637
|
-
this.#applyTabFilter();
|
|
647
|
+
this.#syncFromRegistryState();
|
|
638
648
|
} catch (error) {
|
|
639
649
|
this.#errorMessage = error instanceof Error ? error.message : String(error);
|
|
640
650
|
this.#updateList();
|