mirror of
https://github.com/PawanOsman/ChatGPT.git
synced 2026-07-18 08:05:57 +02:00
Update CHANGELOG for version 0.0.2 and implement workspace-specific conversation storage
- Added per-workspace conversations with automatic migration from global conversations. - Implemented auto-loading of GGUF models with a loading card in chat. - Enhanced llama.cpp server to use random free ports with retry on bind failure. - Improved composer dropdown positioning and shared model selection across composers. - Fixed production error related to missing '@huggingface/hub' package. - Removed MCP tool marketplace. - Updated version in package.json to 0.0.2.
This commit is contained in:
@@ -4,6 +4,28 @@ All notable changes to the "ocursor" extension will be documented in this file.
|
||||
|
||||
Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how to structure this file.
|
||||
|
||||
## [0.0.2] - 2026-07-05
|
||||
|
||||
### Added
|
||||
|
||||
- Per-workspace conversations (existing global conversations migrate automatically)
|
||||
- GGUF models auto-load on first message with a "loading model" card in chat
|
||||
- llama.cpp server uses random free ports with retry on bind failure
|
||||
|
||||
### Changed
|
||||
|
||||
- Composer dropdowns (model picker, mode menu) now position themselves within the viewport and work in edit mode
|
||||
- All composers share one selected model and mode
|
||||
- Auto model selection hidden for now; first enabled model is the default
|
||||
|
||||
### Fixed
|
||||
|
||||
- Production error: `Cannot find package '@huggingface/hub'` (runtime deps now resolved via file URLs)
|
||||
|
||||
### Removed
|
||||
|
||||
- MCP tool marketplace
|
||||
|
||||
## [0.0.1] - 2026-07-05
|
||||
|
||||
### Added
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "ocursor",
|
||||
"displayName": "OpenCursor Agent",
|
||||
"description": "AI coding agent chat inside VS Code",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.2",
|
||||
"publisher": "pkrd",
|
||||
"license": "MIT",
|
||||
"icon": "media/icon.png",
|
||||
|
||||
+61
-18
@@ -20,9 +20,10 @@ import * as fs from "fs/promises";
|
||||
import { createWriteStream } from "fs";
|
||||
import * as path from "path";
|
||||
import * as os from "os";
|
||||
import * as net from "net";
|
||||
import { spawn, execFile } from "child_process";
|
||||
import * as vscode from "vscode";
|
||||
import { ensureRuntimeDeps } from "../runtimeDeps";
|
||||
import { importRuntimeDep } from "../runtimeDeps";
|
||||
|
||||
/**
|
||||
* llama-server launch configuration. Used both as the global default and as a
|
||||
@@ -132,7 +133,18 @@ const LEGACY_BIN = "llama-server";
|
||||
/** Resolved at install-check time: argv prefix to launch the server. */
|
||||
let serverCmd: { bin: string; pre: string[] } = { bin: UNIFIED_BIN, pre: ["serve"] };
|
||||
const MAX_LOG_LINES = 500;
|
||||
let BASE_PORT = 8080;
|
||||
|
||||
/** Ask the OS for a free ephemeral port (bind :0, read the assigned port). */
|
||||
function getFreePort(host = "127.0.0.1"): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const srv = net.createServer();
|
||||
srv.once("error", reject);
|
||||
srv.listen(0, host, () => {
|
||||
const port = (srv.address() as net.AddressInfo).port;
|
||||
srv.close(() => resolve(port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
let modelsDir: string | undefined;
|
||||
let extCtx: vscode.ExtensionContext | undefined;
|
||||
@@ -225,8 +237,7 @@ export async function installLlamacpp(): Promise<void> {
|
||||
|
||||
// ---- HF GGUF search ----
|
||||
export async function searchGguf(query: string, limit = 20): Promise<HfGgufResult[]> {
|
||||
if (!(await ensureRuntimeDeps())) throw new Error("runtime deps unavailable");
|
||||
const hub = await import("@huggingface/hub");
|
||||
const hub = await importRuntimeDep("@huggingface/hub");
|
||||
const out: HfGgufResult[] = [];
|
||||
for await (const m of hub.listModels({
|
||||
search: { query, tags: ["gguf"] },
|
||||
@@ -240,8 +251,7 @@ export async function searchGguf(query: string, limit = 20): Promise<HfGgufResul
|
||||
|
||||
/** List the .gguf files inside a repo so the user can pick a quantization. */
|
||||
export async function listRepoGgufFiles(repo: string): Promise<HfGgufResult[]> {
|
||||
if (!(await ensureRuntimeDeps())) throw new Error("runtime deps unavailable");
|
||||
const hub = await import("@huggingface/hub");
|
||||
const hub = await importRuntimeDep("@huggingface/hub");
|
||||
const files: HfGgufResult[] = [];
|
||||
for await (const f of hub.listFiles({ repo, recursive: true })) {
|
||||
if (f.type === "file" && /\.gguf$/i.test(f.path)) {
|
||||
@@ -297,7 +307,6 @@ export async function importGguf(srcPath: string): Promise<LlamacppModel> {
|
||||
return makeModel({ file: base, filePath: dest, sizeBytes: stat.size, name: path.basename(base, ".gguf") });
|
||||
}
|
||||
|
||||
let portCursor = 0;
|
||||
function makeModel(p: { repo?: string; file: string; filePath: string; sizeBytes?: number; name: string }): LlamacppModel {
|
||||
return {
|
||||
id: modelId(p.repo, p.file),
|
||||
@@ -306,7 +315,7 @@ function makeModel(p: { repo?: string; file: string; filePath: string; sizeBytes
|
||||
repo: p.repo,
|
||||
file: p.file,
|
||||
sizeBytes: p.sizeBytes,
|
||||
port: BASE_PORT + (portCursor++ % 100),
|
||||
port: 0, // assigned per-load: a fresh random free port every time
|
||||
autoLoad: false,
|
||||
};
|
||||
}
|
||||
@@ -347,9 +356,11 @@ export function effectiveContextLength(m: LlamacppModel, globalCtx: number): num
|
||||
return cfg.ctxSize ?? globalCtx;
|
||||
}
|
||||
|
||||
/** Effective bind port: per-model config override (when set) else the model's assigned port. */
|
||||
/** Effective bind port: the running server's port, else a per-model config override. */
|
||||
function effectivePort(m: LlamacppModel, cfg: LlamacppServerConfig): number {
|
||||
return m.useCustomConfig && cfg.port ? cfg.port : m.port;
|
||||
const r = running.get(m.id);
|
||||
if (r) return r.port;
|
||||
return (m.useCustomConfig && cfg.port) || 0;
|
||||
}
|
||||
|
||||
/** Base URL of a model's local OpenAI-compatible server (no trailing slash). */
|
||||
@@ -360,8 +371,8 @@ export function serverUrlFor(m: LlamacppModel, globalCfg?: LlamacppServerConfig)
|
||||
}
|
||||
|
||||
/** Build the llama-server argv from a model + effective config. */
|
||||
function buildArgs(m: LlamacppModel, cfg: LlamacppServerConfig): string[] {
|
||||
const args: string[] = ["-m", m.filePath, "--port", String(effectivePort(m, cfg)), "--host", cfg.host || "127.0.0.1"];
|
||||
function buildArgs(m: LlamacppModel, cfg: LlamacppServerConfig, port: number): string[] {
|
||||
const args: string[] = ["-m", m.filePath, "--port", String(port), "--host", cfg.host || "127.0.0.1"];
|
||||
if (cfg.ctxSize != null) args.push("--ctx-size", String(cfg.ctxSize));
|
||||
args.push(cfg.jinja === false ? "--no-jinja" : "--jinja");
|
||||
if (cfg.flashAttn) args.push("-fa", cfg.flashAttn);
|
||||
@@ -390,19 +401,53 @@ export async function loadModel(m: LlamacppModel, globalCfg?: LlamacppServerConf
|
||||
errors.delete(m.id);
|
||||
logs.set(m.id, []); // fresh log per load
|
||||
loading.set(m.id, true);
|
||||
emit(); // surface loading state immediately
|
||||
// Back-compat: callers used to pass a global context length number.
|
||||
const gcfg: LlamacppServerConfig | undefined =
|
||||
typeof globalCfg === "number" ? { ctxSize: globalCfg } : globalCfg;
|
||||
const cfg = effectiveConfig(m, gcfg);
|
||||
const port = effectivePort(m, cfg);
|
||||
const host = cfg.host && cfg.host !== "0.0.0.0" ? cfg.host : "127.0.0.1";
|
||||
const argv = [...serverCmd.pre, ...buildArgs(m, cfg)];
|
||||
|
||||
// Always launch on a fresh OS-assigned random port. If the server still
|
||||
// fails to bind (TOCTOU race with another process), retry with a new one.
|
||||
const MAX_BIND_TRIES = 3;
|
||||
let lastErr: Error | null = null;
|
||||
for (let attempt = 1; attempt <= MAX_BIND_TRIES; attempt++) {
|
||||
let port: number;
|
||||
try {
|
||||
port = await getFreePort(cfg.host || "127.0.0.1");
|
||||
} catch (e: any) {
|
||||
lastErr = new Error(`could not find a free port: ${e?.message || e}`);
|
||||
break;
|
||||
}
|
||||
try {
|
||||
await spawnServer(m, cfg, host, port);
|
||||
return; // loaded
|
||||
} catch (e: any) {
|
||||
lastErr = e instanceof Error ? e : new Error(String(e));
|
||||
// Bind failure → retry on a new random port; anything else is fatal.
|
||||
const bindFail = /couldn't bind|address already in use|EADDRINUSE|HTTP server error/i.test(lastErr.message) ||
|
||||
(logs.get(m.id) || []).some((l) => /couldn't bind|address already in use/i.test(l));
|
||||
if (!bindFail) break;
|
||||
appendLog(m.id, `[retry] port ${port} unavailable, trying a new random port (${attempt}/${MAX_BIND_TRIES})`);
|
||||
}
|
||||
}
|
||||
loading.delete(m.id);
|
||||
const msg = lastErr?.message || "failed to start server";
|
||||
errors.set(m.id, msg);
|
||||
emit();
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
/** Spawn one llama-server on `port` and resolve when /health reports ready. */
|
||||
function spawnServer(m: LlamacppModel, cfg: LlamacppServerConfig, host: string, port: number): Promise<void> {
|
||||
const argv = [...serverCmd.pre, ...buildArgs(m, cfg, port)];
|
||||
appendLog(m.id, `$ ${serverCmd.bin} ${argv.join(" ")}`);
|
||||
const proc = spawn(serverCmd.bin, argv, { stdio: ["ignore", "pipe", "pipe"] });
|
||||
running.set(m.id, { proc, port });
|
||||
proc.stdout?.on("data", (b) => appendLog(m.id, b.toString()));
|
||||
proc.stderr?.on("data", (b) => appendLog(m.id, b.toString()));
|
||||
emit(); // surface loading state immediately
|
||||
emit();
|
||||
|
||||
let resolved = false;
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
@@ -410,8 +455,6 @@ export async function loadModel(m: LlamacppModel, globalCfg?: LlamacppServerConf
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
running.delete(m.id);
|
||||
loading.delete(m.id);
|
||||
errors.set(m.id, msg);
|
||||
appendLog(m.id, `[error] ${msg}`);
|
||||
emit();
|
||||
reject(new Error(msg));
|
||||
@@ -432,7 +475,7 @@ export async function loadModel(m: LlamacppModel, globalCfg?: LlamacppServerConf
|
||||
if (r.ok) {
|
||||
resolved = true;
|
||||
loading.delete(m.id);
|
||||
appendLog(m.id, "[ready] model loaded");
|
||||
appendLog(m.id, `[ready] model loaded on port ${port}`);
|
||||
emit();
|
||||
resolve();
|
||||
return;
|
||||
|
||||
+23
-1
@@ -311,10 +311,32 @@ function parseTitle(content: string): string {
|
||||
}
|
||||
|
||||
/** Auto mode judge: pick the best-suited model id from candidates for a task. */
|
||||
export async function pickModel(apiBaseUrl: string, apiKey: string, judge: string, candidates: string[], task: string, anthropic?: boolean): Promise<string> {
|
||||
export async function pickModel(apiBaseUrl: string, apiKey: string, judge: string, candidates: string[], task: string, anthropic?: boolean, oauthKind?: OAuthKind): Promise<string> {
|
||||
const useAnthropic = anthropic ?? isAnthropic(apiBaseUrl);
|
||||
const sys = `You route a coding task to the best model. Available models: ${candidates.join(", ")}. Reply with EXACTLY one model id from the list, nothing else.`;
|
||||
const prompt = task.slice(0, 2000);
|
||||
if (oauthKind) {
|
||||
// OAuth judges (Claude Code / Codex) have no raw HTTP endpoint; stream a tiny completion.
|
||||
let text = "";
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), 30_000);
|
||||
try {
|
||||
const gen = streamOAuthChat(oauthKind, {
|
||||
model: judge,
|
||||
messages: [{ role: "system", content: sys }, { role: "user", content: prompt }],
|
||||
maxTokens: 64,
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
for await (const ev of gen) {
|
||||
if (ev.type === "text-delta") text += ev.text;
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
// Reasoning models may emit <think> blocks; last non-empty line is the answer.
|
||||
const lines = text.replace(/<think>[\s\S]*?<\/think>/gi, "").split("\n").map((l) => l.trim()).filter(Boolean);
|
||||
return lines.length ? lines[lines.length - 1] : text.trim();
|
||||
}
|
||||
if (useAnthropic) {
|
||||
const r = await fetch(`${apiBaseUrl}/messages`, {
|
||||
method: "POST",
|
||||
|
||||
@@ -21,7 +21,7 @@ import * as fs from "fs/promises";
|
||||
import * as path from "path";
|
||||
import * as crypto from "crypto";
|
||||
import { walk } from "./tools/shared";
|
||||
import { ensureRuntimeDeps } from "../runtimeDeps";
|
||||
import { importRuntimeDep } from "../runtimeDeps";
|
||||
|
||||
// Selectable local embedding models. Add entries here to offer more choices.
|
||||
export interface EmbedModel {
|
||||
@@ -111,8 +111,7 @@ async function getExtractor(): Promise<any | null> {
|
||||
if (!extractorP) {
|
||||
const m = activeModel;
|
||||
extractorP = (async () => {
|
||||
if (!(await ensureRuntimeDeps())) throw new Error("runtime deps unavailable");
|
||||
const t = await import("@huggingface/transformers");
|
||||
const t = await importRuntimeDep("@huggingface/transformers");
|
||||
t.env.allowRemoteModels = true;
|
||||
t.env.cacheDir = path.join(storageDir!, "models");
|
||||
return t.pipeline("feature-extraction", m.repo, { dtype: m.dtype });
|
||||
|
||||
@@ -295,33 +295,3 @@ export class McpManager {
|
||||
}
|
||||
|
||||
export const mcpManager = new McpManager();
|
||||
|
||||
/** A server entry from the official MCP registry (registry.modelcontextprotocol.io). */
|
||||
export interface RegistryServer {
|
||||
name: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
version?: string;
|
||||
packages?: {
|
||||
registryType?: string;
|
||||
identifier?: string;
|
||||
version?: string;
|
||||
runtimeHint?: string;
|
||||
transport?: { type?: string };
|
||||
runtimeArguments?: { type?: string; name?: string; value?: string }[];
|
||||
packageArguments?: { type?: string; name?: string; value?: string }[];
|
||||
environmentVariables?: { name?: string; description?: string; isRequired?: boolean; isSecret?: boolean }[];
|
||||
}[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Search the official MCP registry. Runs in the extension host (not the webview)
|
||||
* so it isn't blocked by the webview CSP. Empty query returns a default listing.
|
||||
*/
|
||||
export async function searchMcpRegistry(query: string, limit = 30): Promise<RegistryServer[]> {
|
||||
const url = `https://registry.modelcontextprotocol.io/v0.1/servers?limit=${limit}${query.trim() ? `&search=${encodeURIComponent(query.trim())}` : ""}`;
|
||||
const r = await fetch(url);
|
||||
if (!r.ok) throw new Error(`registry ${r.status}`);
|
||||
const data = (await r.json()) as { servers?: { server: RegistryServer }[] };
|
||||
return (data.servers ?? []).map((s) => s.server);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import * as zlib from "zlib";
|
||||
import * as crypto from "crypto";
|
||||
import { pathToFileURL } from "url";
|
||||
|
||||
const REGISTRY = "https://registry.npmjs.org";
|
||||
|
||||
@@ -252,6 +253,32 @@ export function ensureRuntimeDeps(): Promise<boolean> {
|
||||
return readyP;
|
||||
}
|
||||
|
||||
/** Resolve a package's ESM entry point from its package.json. */
|
||||
function entryOf(pkg: any): string {
|
||||
const pick = (v: any): string | undefined => {
|
||||
if (typeof v === "string") return v;
|
||||
if (v && typeof v === "object") return pick(v.import ?? v.node ?? v.default ?? v.require);
|
||||
return undefined;
|
||||
};
|
||||
return pick(pkg.exports?.["."] ?? pkg.exports) || pkg.module || pkg.main || "index.js";
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a runtime dep. In production the extension host's ESM resolver can't
|
||||
* see runtime-deps/node_modules (the CJS require hook doesn't apply to
|
||||
* import()), so we resolve the entry file ourselves and import it by file URL.
|
||||
*/
|
||||
export async function importRuntimeDep<T = any>(name: string): Promise<T> {
|
||||
if (!(await ensureRuntimeDeps())) throw new Error("runtime deps unavailable");
|
||||
try {
|
||||
return await import(name); // dev: real node_modules next to us
|
||||
} catch { /* fall back to runtime-deps dir */ }
|
||||
if (!rootDir) throw new Error("runtime deps not initialized");
|
||||
const dir = path.join(rootDir, "node_modules", ...name.split("/"));
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
|
||||
return await import(pathToFileURL(path.join(dir, entryOf(pkg))).href);
|
||||
}
|
||||
|
||||
// Self-check: OPENCURSOR_SELFCHECK=1 node -e "require('./dist/extension.js')"
|
||||
if (process.env.OPENCURSOR_SELFCHECK) {
|
||||
const assert = (c: boolean, m: string) => { if (!c) throw new Error("selfcheck: " + m); };
|
||||
|
||||
@@ -35,14 +35,35 @@ const KEY = "ocursor.conversations";
|
||||
const ACTIVE_KEY = "ocursor.activeConversation";
|
||||
|
||||
export class ConversationStore {
|
||||
constructor(private readonly context: vscode.ExtensionContext) {}
|
||||
constructor(private readonly context: vscode.ExtensionContext) {
|
||||
void this.migrateFromGlobal();
|
||||
}
|
||||
|
||||
/** workspaceState = per-workspace storage; VS Code scopes it for us. */
|
||||
private get state(): vscode.Memento {
|
||||
return this.context.workspaceState;
|
||||
}
|
||||
|
||||
/** One-time: move old globalState conversations into this workspace. */
|
||||
private async migrateFromGlobal(): Promise<void> {
|
||||
const old = this.context.globalState.get<Conversation[]>(KEY);
|
||||
if (!old?.length || this.state.get<Conversation[]>(KEY)?.length) {
|
||||
if (old) await this.context.globalState.update(KEY, undefined);
|
||||
return;
|
||||
}
|
||||
await this.state.update(KEY, old);
|
||||
const active = this.context.globalState.get<string>(ACTIVE_KEY);
|
||||
if (active) await this.state.update(ACTIVE_KEY, active);
|
||||
await this.context.globalState.update(KEY, undefined);
|
||||
await this.context.globalState.update(ACTIVE_KEY, undefined);
|
||||
}
|
||||
|
||||
private all(): Conversation[] {
|
||||
return this.context.globalState.get<Conversation[]>(KEY, []);
|
||||
return this.state.get<Conversation[]>(KEY, []);
|
||||
}
|
||||
|
||||
private async persist(list: Conversation[]): Promise<void> {
|
||||
await this.context.globalState.update(KEY, list);
|
||||
await this.state.update(KEY, list);
|
||||
}
|
||||
|
||||
list(): ConversationSummary[] {
|
||||
@@ -57,11 +78,11 @@ export class ConversationStore {
|
||||
}
|
||||
|
||||
getActiveId(): string | undefined {
|
||||
return this.context.globalState.get<string>(ACTIVE_KEY);
|
||||
return this.state.get<string>(ACTIVE_KEY);
|
||||
}
|
||||
|
||||
async setActiveId(id: string | undefined): Promise<void> {
|
||||
await this.context.globalState.update(ACTIVE_KEY, id);
|
||||
await this.state.update(ACTIVE_KEY, id);
|
||||
}
|
||||
|
||||
async create(personaId?: string): Promise<Conversation> {
|
||||
|
||||
@@ -19,7 +19,8 @@ export interface Settings {
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: Settings = {
|
||||
model: "auto",
|
||||
// Auto hidden for now (bring back later): "" resolves to first enabled model.
|
||||
model: "",
|
||||
// 0 = don't send max_tokens; the model decides when to stop.
|
||||
maxResponseLength: 0,
|
||||
enableWorkspaceContext: true,
|
||||
|
||||
+1
-10
@@ -13,7 +13,7 @@ import { listModels } from "../agent/provider";
|
||||
import { renderWebviewHtml } from "./webviewHtml";
|
||||
import { FeatureStore, MODEL_CATALOG } from "../stores/featureStore";
|
||||
import { listRules, listSkills } from "../context/workspaceContext";
|
||||
import { mcpManager, searchMcpRegistry } from "../integrations/mcpClient";
|
||||
import { mcpManager } from "../integrations/mcpClient";
|
||||
import { BUILTIN_PERSONAS } from "../agent/personas";
|
||||
import { getStatus, onIndexStatus, buildIndex, deleteIndex, EMBED_MODELS } from "../agent/semanticIndex";
|
||||
import { indexDocSource, deleteDocIndex, onDocsStatus, getDocsStatus, getDocLogs, type DocSource } from "../agent/docsIndex";
|
||||
@@ -234,15 +234,6 @@ export class SettingsPanel {
|
||||
case "resetStorage":
|
||||
await this._resetStorage();
|
||||
break;
|
||||
case "mcpRegistrySearch": {
|
||||
try {
|
||||
const servers = await searchMcpRegistry(message.query || "");
|
||||
this._panel.webview.postMessage({ type: "mcpRegistryResults", servers });
|
||||
} catch (err: any) {
|
||||
this._panel.webview.postMessage({ type: "mcpRegistryResults", servers: [], error: String(err?.message || err) });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "saveProviderKey":
|
||||
await this.settingsManager.setProviderKey(message.providerId, message.apiKey ?? "");
|
||||
this.featureStore.notifyChanged();
|
||||
|
||||
+33
-17
@@ -839,7 +839,8 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
|
||||
|
||||
/** Whether a model id maps to a managed local llama.cpp model. */
|
||||
private _localModel(modelId: string) {
|
||||
return this.featureStore.get().llamacppModels.find((m) => m.id === modelId);
|
||||
const bare = stripModelScope(modelId);
|
||||
return this.featureStore.get().llamacppModels.find((m) => m.id === modelId || m.id === bare);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1021,8 +1022,16 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
|
||||
const judge = features.autoJudgeModel || candidates[0];
|
||||
try {
|
||||
const jprov = await this._resolveProviderForModel(judge);
|
||||
const picked = await pickModel(jprov.baseUrl, jprov.apiKey, judge, candidates, task, jprov.anthropic);
|
||||
// Local judge whose server isn't running would need a full model load just
|
||||
// to route — not worth it; fall back to the first candidate instead.
|
||||
const judgeIsLocal = !!this._localModel(judge);
|
||||
if (judgeIsLocal && !isRunning(stripModelScope(judge))) return candidates[0];
|
||||
if (!jprov.oauthKind && !jprov.baseUrl) return candidates[0]; // unroutable judge
|
||||
const picked = await pickModel(jprov.baseUrl, jprov.apiKey, jprov.model, candidates, task, jprov.anthropic, jprov.oauthKind);
|
||||
if (picked && candidates.includes(picked)) return picked;
|
||||
// Judge may reply with a bare model id (no provider scope) — match it.
|
||||
const scoped = candidates.find((c) => stripModelScope(c) === stripModelScope(picked));
|
||||
if (scoped) return scoped;
|
||||
} catch {
|
||||
// fall through to default
|
||||
}
|
||||
@@ -1079,12 +1088,13 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
|
||||
|
||||
const allIds = fetched.flatMap((f) => f.ids);
|
||||
const modelList = this._buildModelList(fetched);
|
||||
// Selected model vanished (e.g. account disabled) -> fall back to auto.
|
||||
// Selected model vanished (e.g. account disabled) or is "auto" (hidden for
|
||||
// now) -> fall back to the first enabled model.
|
||||
const settings = this.settingsManager.getSettings();
|
||||
if (settings.model && settings.model !== "auto" && !modelList.some((m) => m.id === settings.model)) {
|
||||
settings.model = "auto";
|
||||
if (settings.model === "auto" || (settings.model && !modelList.some((m) => m.id === settings.model))) {
|
||||
settings.model = modelList[0]?.id || "";
|
||||
await this.settingsManager.saveSettings(settings);
|
||||
this._view?.webview.postMessage({ type: "modelSelected", model: "auto" });
|
||||
this._view?.webview.postMessage({ type: "modelSelected", model: settings.model });
|
||||
}
|
||||
this._view?.webview.postMessage({ type: "modelsFetched", models: allIds, modelList });
|
||||
}
|
||||
@@ -1165,10 +1175,12 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
|
||||
if (edit?.mode) this._currentMode = edit.mode as Mode;
|
||||
|
||||
const settings = this.settingsManager.getSettings();
|
||||
// Auto mode: let a judge model pick the best enabled model for this task.
|
||||
let modelId = settings.model;
|
||||
if (modelId === "auto") {
|
||||
modelId = await this._resolveAutoModel(text);
|
||||
// Auto mode is hidden for now; a lingering "auto" selection (or empty)
|
||||
// resolves to the first enabled model. (Judge-based routing kept in
|
||||
// _resolveAutoModel for when Auto returns.)
|
||||
if (modelId === "auto" || !modelId) {
|
||||
modelId = this._buildModelList([]).find((m) => m.id !== "auto")?.id || modelId;
|
||||
}
|
||||
// Resolve connection details without starting a local server yet — we want
|
||||
// the chat UI to show a "loading model" state while it boots (below).
|
||||
@@ -1286,15 +1298,19 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
|
||||
// Local model not yet running → boot it now, showing a loading state in the
|
||||
// chat (selecting a model never loads it; only sending a message does).
|
||||
const local = this._localModel(modelId);
|
||||
if (local && !isRunning(local.id)) {
|
||||
emit({ type: "shell-notify", message: `Loading ${local.name}…` });
|
||||
try {
|
||||
await ensureLoaded(local, features.llamacppConfig);
|
||||
} catch (e: any) {
|
||||
emit({ type: "error", message: `Failed to load ${local.name}: ${e?.message || e}` });
|
||||
emit({ type: "run-status", status: "error" });
|
||||
return; // `finally` clears the session + persists.
|
||||
if (local) {
|
||||
if (!isRunning(local.id)) {
|
||||
emit({ type: "shell-notify", message: `Loading ${local.name}…` });
|
||||
try {
|
||||
await ensureLoaded(local, features.llamacppConfig);
|
||||
} catch (e: any) {
|
||||
emit({ type: "error", message: `Failed to load ${local.name}: ${e?.message || e}` });
|
||||
emit({ type: "run-status", status: "error" });
|
||||
return; // `finally` clears the session + persists.
|
||||
}
|
||||
}
|
||||
// The server binds a random port each load — resolve the URL only now.
|
||||
prov.baseUrl = serverUrlFor(local, features.llamacppConfig);
|
||||
}
|
||||
|
||||
// Generate a short AI title once the provider/server is ready (local
|
||||
|
||||
@@ -77,7 +77,7 @@ const SECTION_KEYWORDS: Partial<Record<Section, string>> = {
|
||||
behavior: "workspace context file reading terminal tools auto edits approval allow deny ask review policy allowlist denylist commands mcp web",
|
||||
personas: "persona system prompt custom",
|
||||
rules: "rules skills subagents",
|
||||
mcp: "mcp tools servers marketplace",
|
||||
mcp: "mcp tools servers",
|
||||
hooks: "hooks events commands",
|
||||
indexing: "codebase index embedding docs semantic sync",
|
||||
advanced: "system prompt custom instructions",
|
||||
@@ -730,6 +730,7 @@ export function App() {
|
||||
<Row title="Auto-Generate Chat Titles" desc="Generate a short AI title for new conversations after the first message.">
|
||||
<Toggle checked={features.autoGenerateTitles !== false} onChange={(v) => setFeatures({ autoGenerateTitles: v })} />
|
||||
</Row>
|
||||
{/* Auto model (judge routing) hidden for now — bring back later.
|
||||
<Row title="Auto Judge Model" desc="When the chat model is set to Auto, this judge model picks the best enabled model for each task.">
|
||||
<ModelSelect
|
||||
models={modelList.length ? modelList : [...modelCatalog, ...(features.customModels || [])].filter((m) => features.enabledModels.includes(m.id))}
|
||||
@@ -739,6 +740,7 @@ export function App() {
|
||||
style={{ maxWidth: 240 }}
|
||||
/>
|
||||
</Row>
|
||||
*/}
|
||||
</Group>
|
||||
|
||||
<div className="section-label">Notifications</div>
|
||||
|
||||
@@ -220,7 +220,7 @@ export function LlamacppPanel({
|
||||
<Icon name="model" size={14} />
|
||||
<span>{m.name}</span>
|
||||
{isLoading && <span className="badge-tag loading"><span className="llama-spinner" /> loading…</span>}
|
||||
{isRunning && !isLoading && <span className="badge-tag always">running :{m.port}</span>}
|
||||
{isRunning && !isLoading && <span className="badge-tag always">running</span>}
|
||||
</div>
|
||||
<label className="fc-inline" title="Load this model automatically on startup">
|
||||
<input
|
||||
|
||||
@@ -9,8 +9,7 @@
|
||||
|
||||
import * as React from "react";
|
||||
import { Icon } from "../../shared/icons";
|
||||
import { vscode } from "../../shared/vscode";
|
||||
import { FeatureConfig, McpServerConfig, McpStatus, uid } from "../features";
|
||||
import { FeatureConfig, McpServerConfig, McpStatus } from "../features";
|
||||
import { Toggle } from "./Toggle";
|
||||
|
||||
export function McpPanel({
|
||||
@@ -26,7 +25,6 @@ export function McpPanel({
|
||||
}) {
|
||||
// null = closed; { index: -1 } = adding a new server; otherwise editing that index.
|
||||
const [editing, setEditing] = React.useState<{ index: number; draft: McpServerConfig } | null>(null);
|
||||
const [tab, setTab] = React.useState<"installed" | "marketplace">("installed");
|
||||
|
||||
const remove = (i: number) => {
|
||||
setFeatures({ mcpServers: features.mcpServers.filter((_, idx) => idx !== i) });
|
||||
@@ -51,27 +49,14 @@ export function McpPanel({
|
||||
|
||||
const statusFor = (name: string) => status.find((s) => s.name === name);
|
||||
|
||||
// One-click install from the marketplace: append the derived config and reconnect.
|
||||
const install = (cfg: McpServerConfig) => {
|
||||
const name = features.mcpServers.some((s) => s.name === cfg.name) ? `${cfg.name}-${uid("").slice(0, 4)}` : cfg.name;
|
||||
setFeatures({ mcpServers: [...features.mcpServers, { ...cfg, name }] });
|
||||
setTimeout(onSync, 0);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="page-title">Tools & MCPs</h1>
|
||||
|
||||
<div className="sub-tabs">
|
||||
<button className={"sub-tab" + (tab === "installed" ? " active" : "")} onClick={() => setTab("installed")}>Installed</button>
|
||||
<button className={"sub-tab" + (tab === "marketplace" ? " active" : "")} onClick={() => setTab("marketplace")}>Marketplace</button>
|
||||
</div>
|
||||
|
||||
{tab === "installed" && (<>
|
||||
<div className="section-label">MCP Servers</div>
|
||||
<p className="panel-hint">Connected Model Context Protocol servers and the tools they expose. Browse the <button className="link-btn" onClick={() => setTab("marketplace")}>Marketplace</button> to install with one click.</p>
|
||||
<p className="panel-hint">Connected Model Context Protocol servers and the tools they expose.</p>
|
||||
{features.mcpServers.length === 0 && (
|
||||
<div className="empty-card">No MCP servers yet. Add one or install from the Marketplace.</div>
|
||||
<div className="empty-card">No MCP servers yet. Add one to get started.</div>
|
||||
)}
|
||||
{features.mcpServers.map((srv, i) => {
|
||||
const st = statusFor(srv.name);
|
||||
@@ -118,11 +103,6 @@ export function McpPanel({
|
||||
Reconnect
|
||||
</button>
|
||||
</div>
|
||||
</>)}
|
||||
|
||||
{tab === "marketplace" && (
|
||||
<McpMarketplace installedNames={new Set(features.mcpServers.map((s) => s.name))} onInstall={install} />
|
||||
)}
|
||||
|
||||
{editing && (
|
||||
<McpModal
|
||||
@@ -175,152 +155,3 @@ function McpModal({ server, isNew, onClose, onSave }: { server: McpServerConfig;
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Marketplace: registry.modelcontextprotocol.io
|
||||
interface RegistryPackage {
|
||||
registryType?: string;
|
||||
identifier?: string;
|
||||
version?: string;
|
||||
runtimeHint?: string;
|
||||
transport?: { type?: string };
|
||||
runtimeArguments?: { type?: string; name?: string; value?: string }[];
|
||||
packageArguments?: { type?: string; name?: string; value?: string }[];
|
||||
environmentVariables?: { name?: string; description?: string; isRequired?: boolean; isSecret?: boolean }[];
|
||||
}
|
||||
interface RegistryServer {
|
||||
name: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
version?: string;
|
||||
packages?: RegistryPackage[];
|
||||
}
|
||||
|
||||
/** Default runtime command for a registry package type. */
|
||||
function runtimeFor(pkg: RegistryPackage): string {
|
||||
if (pkg.runtimeHint) return pkg.runtimeHint;
|
||||
switch (pkg.registryType) {
|
||||
case "npm": return "npx";
|
||||
case "pypi": return "uvx";
|
||||
case "oci": return "docker";
|
||||
case "nuget": return "dnx";
|
||||
default: return "npx";
|
||||
}
|
||||
}
|
||||
|
||||
/** Build an args list from registry argument descriptors (positional/named values only). */
|
||||
function argValues(args?: { type?: string; name?: string; value?: string }[]): string[] {
|
||||
if (!args) return [];
|
||||
const out: string[] = [];
|
||||
for (const a of args) {
|
||||
if (a.name) out.push(a.name);
|
||||
if (a.value) out.push(a.value);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a runnable stdio McpServerConfig from a registry server, or null if it
|
||||
* has no installable stdio package (e.g. remote-only servers).
|
||||
*/
|
||||
function configFromRegistry(srv: RegistryServer): McpServerConfig | null {
|
||||
const pkg = (srv.packages || []).find((p) => (p.transport?.type ?? "stdio") === "stdio" && p.identifier);
|
||||
if (!pkg) return null;
|
||||
const runtime = runtimeFor(pkg);
|
||||
const args: string[] = [...argValues(pkg.runtimeArguments)];
|
||||
// npx/dnx default to a non-interactive install flag, then the package id.
|
||||
if (runtime === "npx") args.push("-y");
|
||||
if (pkg.registryType === "oci") args.push("run", "-i", "--rm");
|
||||
args.push(pkg.identifier!);
|
||||
args.push(...argValues(pkg.packageArguments));
|
||||
const env: Record<string, string> = {};
|
||||
for (const e of pkg.environmentVariables ?? []) if (e.name) env[e.name] = "";
|
||||
const shortName = srv.name.split("/").pop() || srv.name;
|
||||
return {
|
||||
name: shortName,
|
||||
transport: "stdio",
|
||||
command: runtime,
|
||||
args,
|
||||
env: Object.keys(env).length ? env : undefined,
|
||||
enabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
function McpMarketplace({ installedNames, onInstall }: { installedNames: Set<string>; onInstall: (cfg: McpServerConfig) => void }) {
|
||||
const [query, setQuery] = React.useState("");
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [error, setError] = React.useState("");
|
||||
const [results, setResults] = React.useState<RegistryServer[]>([]);
|
||||
|
||||
// The registry fetch runs in the extension host (webview CSP blocks direct fetch).
|
||||
const search = React.useCallback((q: string) => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
vscode.postMessage({ type: "mcpRegistrySearch", query: q.trim() });
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handler = (e: MessageEvent) => {
|
||||
const m = e.data;
|
||||
if (m?.type === "mcpRegistryResults") {
|
||||
setLoading(false);
|
||||
setResults(m.servers || []);
|
||||
setError(m.error || "");
|
||||
}
|
||||
};
|
||||
window.addEventListener("message", handler);
|
||||
search("");
|
||||
return () => window.removeEventListener("message", handler);
|
||||
}, [search]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="section-label">Marketplace</div>
|
||||
<p className="panel-hint">Search the official <code>registry.modelcontextprotocol.io</code> and install a server with one click. Servers with required environment variables are added with empty values — fill them in on the Installed tab.</p>
|
||||
<div style={{ display: "flex", gap: 8, marginBottom: 12 }}>
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
placeholder="e.g. filesystem, github, playwright"
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") search(query); }}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<button className="btn-primary" onClick={() => search(query)} disabled={loading}>
|
||||
{loading ? "Searching…" : "Search"}
|
||||
</button>
|
||||
</div>
|
||||
{error && <div className="fc-error">{error}</div>}
|
||||
{!loading && results.length === 0 && !error && <div className="empty-card">No servers found.</div>}
|
||||
{results.map((srv) => {
|
||||
const cfg = configFromRegistry(srv);
|
||||
const shortName = srv.name.split("/").pop() || srv.name;
|
||||
const installed = installedNames.has(shortName);
|
||||
return (
|
||||
<div className="feature-card" key={srv.name}>
|
||||
<div className="fc-head">
|
||||
<div className="fc-title-input" style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<Icon name="link" size={14} />
|
||||
<span>{srv.title || shortName}</span>
|
||||
{srv.version && <span className="badge-tag glob">v{srv.version}</span>}
|
||||
</div>
|
||||
{installed ? (
|
||||
<span className="badge-tag glob">added</span>
|
||||
) : cfg ? (
|
||||
<button className="btn-primary sm" onClick={() => onInstall(cfg)}>
|
||||
<Icon name="plus" size={13} /> Install
|
||||
</button>
|
||||
) : (
|
||||
<span className="badge-tag" title="No stdio package — remote/unsupported">remote</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="fc-body">
|
||||
{srv.description && <div className="row-desc">{srv.description}</div>}
|
||||
{cfg && <div className="row-desc" style={{ marginTop: 6, opacity: 0.7, fontFamily: "var(--vscode-editor-font-family, monospace)" }}>{cfg.command} {(cfg.args || []).join(" ")}</div>}
|
||||
<div className="row-desc" style={{ marginTop: 4, opacity: 0.6 }}>{srv.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+18
-17
@@ -621,10 +621,9 @@ export function App() {
|
||||
// Pending in-chat approval requests, keyed by conversation id.
|
||||
const [approvals, setApprovals] = React.useState<Record<string, ApprovalRequestInfo[]>>({});
|
||||
const [reviewOpen, setReviewOpen] = React.useState(false);
|
||||
// Editing an earlier user message: index of that turn + its edit-local model/mode.
|
||||
// Editing an earlier user message: index of that turn. The edit composer
|
||||
// shares the global model/mode selection (one selection for all composers).
|
||||
const [editingIndex, setEditingIndex] = React.useState<number | null>(null);
|
||||
const [editModel, setEditModel] = React.useState("");
|
||||
const [editMode, setEditMode] = React.useState<Mode>("agent");
|
||||
// Pending edit awaiting the revert-confirm dialog. `restore` = return the
|
||||
// message to the bottom composer instead of resending it.
|
||||
const [revertPrompt, setRevertPrompt] = React.useState<{ index: number; text: string; attachments: Attachment[]; restore?: boolean } | null>(null);
|
||||
@@ -891,7 +890,7 @@ export function App() {
|
||||
force();
|
||||
break;
|
||||
case "modelSelected":
|
||||
setSelectedModel(msg.model || "auto");
|
||||
setSelectedModel(msg.model || ""); // auto hidden for now
|
||||
break;
|
||||
case "configState":
|
||||
setPersonas(msg.personas || []);
|
||||
@@ -1102,16 +1101,15 @@ export function App() {
|
||||
React.useEffect(() => {
|
||||
if (editingIndex === null) return;
|
||||
const h = (e: MouseEvent) => {
|
||||
if (!(e.target as HTMLElement).closest(".msg.user.editing, .modal-overlay")) setEditingIndex(null);
|
||||
// Portaled dropdowns (model picker / mode menu) live in document.body.
|
||||
if (!(e.target as HTMLElement).closest(".msg.user.editing, .modal-overlay, .model-picker, .mode-dropdown")) setEditingIndex(null);
|
||||
};
|
||||
document.addEventListener("mousedown", h);
|
||||
return () => document.removeEventListener("mousedown", h);
|
||||
}, [editingIndex]);
|
||||
|
||||
const startEdit = (index: number, turn: UserTurn) => {
|
||||
const startEdit = (index: number, _turn: UserTurn) => {
|
||||
setEditingIndex(index);
|
||||
setEditModel(turn.model || selectedModel);
|
||||
setEditMode((turn.mode as Mode) || mode);
|
||||
};
|
||||
|
||||
// Resend an edited earlier message. If there are file changes below it, ask the
|
||||
@@ -1147,15 +1145,12 @@ export function App() {
|
||||
const commitEdit = (index: number, text: string, attachments: Attachment[], revertFiles: boolean) => {
|
||||
const s = sessionFor(activeIdRef.current);
|
||||
// Drop this turn and everything after it, then append the edited message.
|
||||
s.turns = [...s.turns.slice(0, index), { role: "user", text, attachments: attachments.length ? attachments : undefined, model: editModel, mode: editMode }];
|
||||
s.turns = [...s.turns.slice(0, index), { role: "user", text, attachments: attachments.length ? attachments : undefined, model: selectedModel, mode }];
|
||||
setEditingIndex(null);
|
||||
setRevertPrompt(null);
|
||||
// Apply the edited model/mode as the active selection too.
|
||||
if (editModel && editModel !== selectedModel) { setSelectedModel(editModel); post({ type: "selectModel", model: editModel }); }
|
||||
if (editMode !== mode) { setMode(editMode); post({ type: "setMode", mode: editMode }); }
|
||||
pinTopRef.current = true;
|
||||
force();
|
||||
post({ type: "sendMessage", text, attachments: attachments.length ? attachments : undefined, fromIndex: index, model: editModel, mode: editMode, revertFiles });
|
||||
post({ type: "sendMessage", text, attachments: attachments.length ? attachments : undefined, fromIndex: index, model: selectedModel, mode, revertFiles });
|
||||
};
|
||||
|
||||
// Switch to agent mode and kick off implementation of a written plan.
|
||||
@@ -1370,12 +1365,18 @@ export function App() {
|
||||
initialText={turn.text}
|
||||
initialAttachments={turn.attachments}
|
||||
focusKey={`edit-${index}`}
|
||||
mode={editMode}
|
||||
onMode={setEditMode}
|
||||
mode={mode}
|
||||
onMode={(m) => {
|
||||
setMode(m);
|
||||
post({ type: "setMode", mode: m });
|
||||
}}
|
||||
models={models}
|
||||
modelList={modelList}
|
||||
selectedModel={editModel}
|
||||
onSelectModel={setEditModel}
|
||||
selectedModel={selectedModel}
|
||||
onSelectModel={(m) => {
|
||||
setSelectedModel(m);
|
||||
post({ type: "selectModel", model: m });
|
||||
}}
|
||||
onSaveModelOptions={(modelId, options) => {
|
||||
setModelList((prev) => prev.map((m) => (m.id === modelId ? { ...m, options } : m)));
|
||||
post({ type: "saveModelOptions", modelId, options });
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { ArrowUp, AtSign, ChevronRight, Square } from "lucide-react";
|
||||
import { Icon, IconName } from "../../shared/icons";
|
||||
import { vscode } from "../../shared/vscode";
|
||||
@@ -261,12 +262,58 @@ function useOutsideClose(open: boolean, close: () => void) {
|
||||
}, [open, close]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Anchor a position:fixed dropdown to its trigger, adapting to viewport space:
|
||||
* opens above when there's room, flips below otherwise; clamps horizontally.
|
||||
* Returns inline styles (left/top or left/bottom) + max height for the menu.
|
||||
*/
|
||||
function useAnchoredMenu(open: boolean, triggerRef: React.RefObject<HTMLElement | null>, menuRef: React.RefObject<HTMLElement | null>, deps: unknown[] = []) {
|
||||
const [style, setStyle] = React.useState<React.CSSProperties>({});
|
||||
const [maxH, setMaxH] = React.useState(340);
|
||||
React.useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
const place = () => {
|
||||
const t = triggerRef.current?.getBoundingClientRect();
|
||||
const m = menuRef.current;
|
||||
if (!t || !m) return;
|
||||
const margin = 8;
|
||||
const w = m.offsetWidth;
|
||||
let left = t.left;
|
||||
if (left + w > window.innerWidth - margin) left = window.innerWidth - margin - w;
|
||||
if (left < margin) left = margin;
|
||||
const spaceAbove = t.top - margin * 2;
|
||||
const spaceBelow = window.innerHeight - t.bottom - margin * 2;
|
||||
const needed = Math.min(340, m.scrollHeight || 340);
|
||||
if (spaceAbove >= needed || spaceAbove >= spaceBelow) {
|
||||
setStyle({ left, bottom: window.innerHeight - t.top + 6, top: "auto" });
|
||||
setMaxH(Math.min(340, spaceAbove));
|
||||
} else {
|
||||
setStyle({ left, top: t.bottom + 6, bottom: "auto" });
|
||||
setMaxH(Math.min(340, spaceBelow));
|
||||
}
|
||||
};
|
||||
place();
|
||||
window.addEventListener("resize", place);
|
||||
window.addEventListener("scroll", place, true);
|
||||
return () => {
|
||||
window.removeEventListener("resize", place);
|
||||
window.removeEventListener("scroll", place, true);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, ...deps]);
|
||||
return { style, maxH };
|
||||
}
|
||||
|
||||
function ModePicker({ mode, onMode }: { mode: Mode; onMode: (m: Mode) => void }) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
useOutsideClose(open, () => setOpen(false));
|
||||
const triggerRef = React.useRef<HTMLSpanElement>(null);
|
||||
const menuRef = React.useRef<HTMLDivElement>(null);
|
||||
const { style } = useAnchoredMenu(open, triggerRef, menuRef);
|
||||
const meta = MODES.find((m) => m.id === mode) || MODES[0];
|
||||
return (
|
||||
<span
|
||||
ref={triggerRef}
|
||||
className="pill mode-pill"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -276,8 +323,8 @@ function ModePicker({ mode, onMode }: { mode: Mode; onMode: (m: Mode) => void })
|
||||
<Icon name={meta.icon} />
|
||||
<span>{meta.label}</span>
|
||||
<Icon name="chevD" className="cd" />
|
||||
{open && (
|
||||
<div className="mode-dropdown">
|
||||
{open && createPortal(
|
||||
<div ref={menuRef} className="mode-dropdown" style={style}>
|
||||
{MODES.map((o) => (
|
||||
<div
|
||||
key={o.id}
|
||||
@@ -299,7 +346,8 @@ function ModePicker({ mode, onMode }: { mode: Mode; onMode: (m: Mode) => void })
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
@@ -519,28 +567,8 @@ function ModelPicker({
|
||||
|
||||
const triggerRef = React.useRef<HTMLSpanElement>(null);
|
||||
const pickerRef = React.useRef<HTMLDivElement>(null);
|
||||
const [pos, setPos] = React.useState<{ left: number; bottom: number; maxH: number }>({ left: 0, bottom: 0, maxH: 340 });
|
||||
|
||||
// Anchor the fixed picker above the trigger, clamped inside the viewport.
|
||||
React.useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
const place = () => {
|
||||
const t = triggerRef.current?.getBoundingClientRect();
|
||||
const p = pickerRef.current;
|
||||
if (!t || !p) return;
|
||||
const w = p.offsetWidth;
|
||||
const margin = 8;
|
||||
let left = t.left;
|
||||
if (left + w > window.innerWidth - margin) left = window.innerWidth - margin - w;
|
||||
if (left < margin) left = margin;
|
||||
const bottom = window.innerHeight - t.top + 8;
|
||||
// Never extend past the top of the viewport; keep a margin above.
|
||||
setPos({ left, bottom, maxH: Math.min(340, window.innerHeight - bottom - margin) });
|
||||
};
|
||||
place();
|
||||
window.addEventListener("resize", place);
|
||||
return () => window.removeEventListener("resize", place);
|
||||
}, [open, list.length, !!editing]);
|
||||
// Anchor the fixed picker to the trigger; flips below when no room above.
|
||||
const { style: pickerStyle, maxH } = useAnchoredMenu(open, triggerRef, pickerRef, [list.length, !!editing]);
|
||||
|
||||
const pick = (id: string) => {
|
||||
onSelect(id);
|
||||
@@ -561,8 +589,8 @@ function ModelPicker({
|
||||
<span className="label">{selLabel}</span>
|
||||
{summary && <span className="model-summary">{summary}</span>}
|
||||
<Icon name="chevD" className="cd" />
|
||||
{open && (
|
||||
<div ref={pickerRef} className="model-picker" style={{ left: pos.left, bottom: pos.bottom, "--mp-max-h": `${pos.maxH}px` } as React.CSSProperties} onClick={(e) => e.stopPropagation()}>
|
||||
{open && createPortal(
|
||||
<div ref={pickerRef} className="model-picker" style={{ ...pickerStyle, "--mp-max-h": `${maxH}px` } as React.CSSProperties} onClick={(e) => e.stopPropagation()}>
|
||||
{editing ? (
|
||||
<div className="model-picker-view">
|
||||
<div className="mp-head">
|
||||
@@ -589,12 +617,14 @@ function ModelPicker({
|
||||
/>
|
||||
</div>
|
||||
<div className="mp-body">
|
||||
{/* Auto (judge-picked model) hidden for now — bring back later.
|
||||
<div className={"model-item auto" + (selected === "auto" ? " active" : "")} onClick={() => pick("auto")}>
|
||||
<Icon name="infinity" className="model-item-ico" />
|
||||
<span className="model-item-name">Auto</span>
|
||||
<span className="model-item-sum">picks a model for you</span>
|
||||
{selected === "auto" && <Icon name="check" className="model-item-check" />}
|
||||
</div>
|
||||
*/}
|
||||
{filtered.length === 0 && <div className="model-item dim">No matches</div>}
|
||||
{byProvider.map(([provName, list]) => (
|
||||
<React.Fragment key={provName}>
|
||||
@@ -625,7 +655,8 @@ function ModelPicker({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -676,7 +676,7 @@ body {
|
||||
.pill .cd { width: 11px; height: 11px; }
|
||||
.pill span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.mode-dropdown { position: absolute; bottom: calc(100% + 6px); left: 0; min-width: 150px; background: var(--dropdown-bg); border: 1px solid var(--dropdown-border); border-radius: 8px; padding: 3px; box-shadow: 0 8px 24px var(--shadow); z-index: 60; animation: pop-up .12s ease; }
|
||||
.mode-dropdown { position: fixed; min-width: 150px; background: var(--dropdown-bg); border: 1px solid var(--dropdown-border); border-radius: 8px; padding: 3px; box-shadow: 0 8px 24px var(--shadow); z-index: 200; animation: pop-up .12s ease; }
|
||||
.mode-item { display: flex; align-items: center; gap: 7px; height: 26px; padding: 0 7px; border-radius: 5px; font-size: 12px; color: var(--fg); cursor: pointer; }
|
||||
.mode-item:hover { background: var(--hover); }
|
||||
.mode-item .mi-icon { display: inline-flex; flex: 0 0 auto; color: var(--fg-dim); }
|
||||
|
||||
Reference in New Issue
Block a user