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:
Pawan Osman
2026-07-05 23:30:49 +03:00
parent 7bb5801a8c
commit f8ea587099
17 changed files with 283 additions and 306 deletions
+22
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+2 -3
View File
@@ -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 });
-30
View File
@@ -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);
}
+27
View File
@@ -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); };
+26 -5
View File
@@ -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> {
+2 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
+3 -1
View File
@@ -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>
+1 -1
View File
@@ -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
+3 -172
View File
@@ -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 &amp; 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
View File
@@ -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 });
+59 -28
View File
@@ -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>
);
+1 -1
View File
@@ -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); }