diff --git a/media/style.css b/media/style.css index 3d7f7f7..58fd806 100644 --- a/media/style.css +++ b/media/style.css @@ -3313,6 +3313,39 @@ html.pi-viewport-recovering #pi-web-app { cursor: pointer; } +.option-picker-set-default, +.option-picker-clear-default { + margin-left: auto; + padding: 0 2px; + border: 0; + background: transparent; + font-size: 13px; + line-height: 1; + cursor: pointer; +} + +.option-picker-set-default { + color: var(--pi-muted); + opacity: 0; +} + +.option-picker-row:hover .option-picker-set-default, +.option-picker-row:focus-within .option-picker-set-default { + opacity: 1; +} + +.option-picker-clear-default { + color: var(--pi-lavender); +} + +.option-picker-set-default:hover, +.option-picker-set-default:focus-visible, +.option-picker-clear-default:hover, +.option-picker-clear-default:focus-visible { + color: var(--pi-lavender); + outline: none; +} + .option-picker-row.selected, .option-picker-row:hover { background: var(--vscode-list-hoverBackground, var(--pi-hover)); diff --git a/src/pi-service.ts b/src/pi-service.ts index 9877ebc..0ed4b9e 100644 --- a/src/pi-service.ts +++ b/src/pi-service.ts @@ -2532,21 +2532,38 @@ export class PiService { // ── Default model / thinking persistence ────────────── + /** Save a specific model as the default for future sessions. */ + setDefaultModel(provider: string, modelId: string): void { + const cfg = vscode.workspace.getConfiguration("pi-on-code"); + cfg.update("defaultModelProvider", provider, vscode.ConfigurationTarget.Global); + cfg.update("defaultModelId", modelId, vscode.ConfigurationTarget.Global); + } + + /** Set the default thinking level for future sessions. */ + setDefaultThinking(level: string): void { + const cfg = vscode.workspace.getConfiguration("pi-on-code"); + cfg.update("defaultThinkingLevel", level, vscode.ConfigurationTarget.Global); + } + + /** Clear the configured default model (empty values resolve to none). */ + clearDefaultModel(): void { + const cfg = vscode.workspace.getConfiguration("pi-on-code"); + cfg.update("defaultModelProvider", "", vscode.ConfigurationTarget.Global); + cfg.update("defaultModelId", "", vscode.ConfigurationTarget.Global); + } + /** Save the current model as the default for future sessions. */ saveDefaultModel(): void { if (!this._model?.provider || !this._model?.id) { piWarn("saveDefaultModel() called but no model is active — ignoring"); return; } - const cfg = vscode.workspace.getConfiguration("pi-on-code"); - cfg.update("defaultModelProvider", this._model.provider, vscode.ConfigurationTarget.Global); - cfg.update("defaultModelId", this._model.id, vscode.ConfigurationTarget.Global); + this.setDefaultModel(this._model.provider, this._model.id); } /** Save the current thinking level as the default for future sessions. */ saveDefaultThinking(): void { - const cfg = vscode.workspace.getConfiguration("pi-on-code"); - cfg.update("defaultThinkingLevel", this._thinkingLevel, vscode.ConfigurationTarget.Global); + this.setDefaultThinking(this._thinkingLevel); } /** Get the configured default model (if any). */ @@ -2797,16 +2814,32 @@ export class PiService { : undefined; return { key: `${m.provider}::${m.modelId}`, - label: isDefault ? `${m.label} ★` : m.label, + label: m.label, description: detail ? `${m.provider} · ${detail}` : m.provider, icon: m.modelId === currentId ? "●" : undefined, selected: m.modelId === currentId, + isDefault, }; }); } - /** Apply a selection from the reusable webview status picker. */ - async applyStatusPickerOption(kind: "model" | "thinking" | "effort" | "budget", key: string): Promise { + /** Apply a selection from the reusable webview status picker. When asDefault + * is set, persist that option as the default without changing the active one. */ + async applyStatusPickerOption(kind: "model" | "thinking" | "effort" | "budget", key: string, asDefault?: "set" | "clear"): Promise { + if (asDefault) { + if (kind === "model") { + const separator = key.lastIndexOf("::"); + if (separator <= 0 || separator >= key.length - 2) { return; } + if (asDefault === "set") { + this.setDefaultModel(key.slice(0, separator), key.slice(separator + 2)); + } else if (asDefault === "clear") { + this.clearDefaultModel(); + } + } else if (kind === "thinking" && asDefault === "set") { + this.setDefaultThinking(key); + } + return; + } if (kind === "effort") { await this.setEffort(key); return; @@ -2818,10 +2851,6 @@ export class PiService { } if (kind === "thinking") { await this.setThinkingLevel(key); - if (key !== this.getDefaultThinking()) { - const save = await this.askSaveAsDefault(`Use "${key}" thinking as the default?`); - if (save) { this.saveDefaultThinking(); } - } return; } // model @@ -2830,12 +2859,6 @@ export class PiService { const provider = key.slice(0, separator); const modelId = key.slice(separator + 2); await this.setModel(provider, modelId); - const defModel = this.getDefaultModel(); - const isDefault = Boolean(defModel && provider === defModel.provider && modelId === defModel.id); - if (!isDefault) { - const save = await this.askSaveAsDefault("Save this model as default?"); - if (save) { this.saveDefaultModel(); } - } } /** Thinking levels the current session/model actually supports, or null when @@ -2852,43 +2875,6 @@ export class PiService { } } - /** Ask the Webview (via the custom picker) whether to save the selection as default. */ - private async askSaveAsDefault(prompt: string): Promise { - const requestId = `confirm-${++this.pickerConfirmSequence}-${Date.now()}`; - return new Promise((resolve) => { - const timer = setTimeout(() => { - this.pickerConfirmResolvers.delete(requestId); - resolve(false); - }, 60_000); - this.pickerConfirmResolvers.set(requestId, (save) => { - clearTimeout(timer); - this.pickerConfirmResolvers.delete(requestId); - resolve(save); - }); - this.emit({ - type: "picker-confirm", - data: { - requestId, - prompt, - align: "topRight", - options: [ - { key: "save", label: "★ Save as default", description: prompt }, - { key: "skip", label: "Not now" }, - ], - }, - }); - }); - } - - /** Resolve a pending picker confirm (called from the Webview result message). */ - resolvePickerConfirm(requestId: string, key: string): void { - const pending = this.pickerConfirmResolvers.get(requestId); - if (pending) { pending(key === "save"); } - } - - private pickerConfirmResolvers = new Map void>(); - private pickerConfirmSequence = 0; - emitSettings(): void { this.emit({ type: "settings-update", diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index 58e966f..d9c33a0 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -396,19 +396,7 @@ const ExtensionToWebviewSchema = z.discriminatedUnion("type", [ icon: z.string().optional(), selected: z.boolean().optional(), })).max(5000), - }), - }), - z.object({ - type: z.literal("picker-confirm"), - data: z.object({ - requestId: z.string().min(1).max(64), - prompt: z.string(), - align: z.enum(["topLeft", "topRight", "bottomLeft", "bottomRight"]).optional(), - options: z.array(z.object({ - key: z.string(), - label: z.string(), - description: z.string().optional(), - })), + recent: z.array(z.string().min(1)).max(64).optional(), }), }), @@ -584,11 +572,7 @@ const WebviewToExtensionSchema = z.discriminatedUnion("type", [ type: z.literal("applyPickerOption"), kind: z.enum(["model", "thinking", "effort", "budget"]), key: z.string().min(1).max(512), - }), - z.object({ - type: z.literal("picker-confirm-result"), - requestId: z.string().min(1).max(64), - key: z.string().min(1).max(64), + asDefault: z.enum(["set", "clear"]).optional(), }), z.object({ type: z.literal("resolveLocalImage"), path: z.string().min(1).max(4096), requestId: z.string().min(1).max(100) }), z.object({ type: z.literal("promoteToSteer"), text: z.string() }), diff --git a/src/test/option-picker-helpers.test.ts b/src/test/option-picker-helpers.test.ts index 25a8629..f4eebe0 100644 --- a/src/test/option-picker-helpers.test.ts +++ b/src/test/option-picker-helpers.test.ts @@ -94,7 +94,8 @@ suite("Option picker helpers", () => { const off = options.find((option) => option.key === "off"); assert.strictEqual(low?.selected, true); assert.strictEqual(low?.icon, "●"); - assert.ok(off?.label.includes("★"), "default level is starred"); + assert.strictEqual(off?.isDefault, true); + assert.ok(!(off?.label.includes("★") ?? false)); assert.ok(options.some((option) => option.key === "xhigh")); }); @@ -139,7 +140,10 @@ suite("Option picker helpers", () => { assert.match(panel, /case "requestPickerOptions"/); assert.match(panel, /case "applyPickerOption"/); assert.match(panel, /buildStatusPickerOptions\(message\.kind\)/); - assert.match(panel, /applyStatusPickerOption\(message\.kind, message\.key\)/); + assert.match(panel, /this\.piService\.applyStatusPickerOption\(/); + assert.match(panel, /message\.kind,/); + assert.match(panel, /message\.key,/); + assert.match(panel, /message\.asDefault === "set" \|\| message\.asDefault === "clear"/); const service = readFileSync(new URL("../../src/pi-service.ts", import.meta.url), "utf8"); assert.match(service, /async buildStatusPickerOptions/); diff --git a/src/webview-panel.ts b/src/webview-panel.ts index 9739b15..f212716 100644 --- a/src/webview-panel.ts +++ b/src/webview-panel.ts @@ -54,6 +54,7 @@ function isStatusPickerKind(value: unknown): value is StatusPickerKind { } export class PiWebviewPanel { + private static readonly MODEL_RECENT_KEY = "pi.modelRecent"; private panel: vscode.WebviewPanel | null = null; private piService: PiService; private disposables: vscode.Disposable[] = []; @@ -758,6 +759,7 @@ export class PiWebviewPanel { placeholder: meta.placeholder, align: "topRight", items, + recent: message.kind === "model" ? this.readModelRecent() : undefined, }, }); } catch (error: unknown) { @@ -773,7 +775,14 @@ export class PiWebviewPanel { case "applyPickerOption": if (isStatusPickerKind(message.kind) && typeof message.key === "string") { try { - await this.piService.applyStatusPickerOption(message.kind, message.key); + await this.piService.applyStatusPickerOption( + message.kind, + message.key, + message.asDefault === "set" || message.asDefault === "clear" ? message.asDefault : undefined, + ); + if (message.kind === "model" && !message.asDefault) { + this.recordModelRecent(message.key); + } } catch (error: unknown) { this.postMessage({ type: "error", @@ -783,12 +792,6 @@ export class PiWebviewPanel { } break; - case "picker-confirm-result": - if (typeof message.requestId === "string" && typeof message.key === "string") { - this.piService.resolvePickerConfirm(message.requestId, message.key); - } - break; - // Request settings state (#2, #8) case "open-session": if (typeof message.sessionId === "string") { @@ -840,6 +843,16 @@ export class PiWebviewPanel { ); } + private readModelRecent(): string[] { + const raw = this.context.workspaceState.get(PiWebviewPanel.MODEL_RECENT_KEY); + return Array.isArray(raw) ? raw.filter((key): key is string => typeof key === "string") : []; + } + + private recordModelRecent(key: string): void { + const next = [key, ...this.readModelRecent().filter((candidate) => candidate !== key)].slice(0, 8); + void this.context.workspaceState.update(PiWebviewPanel.MODEL_RECENT_KEY, next); + } + private async resolveLocalImage(href: string, requestId: string): Promise { const bases = [ getWorkspaceCwd(), diff --git a/src/webview/components/option-picker.ts b/src/webview/components/option-picker.ts index 84550fa..f03905d 100644 --- a/src/webview/components/option-picker.ts +++ b/src/webview/components/option-picker.ts @@ -17,6 +17,10 @@ export interface StatusPickerConfig { align: PickerAlign; /** Optional recency memory: recent keys surface first and are updated on pick. */ memory?: StatusPickerMemory; + /** Optional: show a per-row ☆ to set that option as default without selecting. */ + onSetDefault?: (key: string) => void; + /** Optional: show a per-row ★ on the current default to clear it. */ + onClearDefault?: (key: string) => void; } export interface StatusPickerMemory { @@ -32,6 +36,7 @@ export interface StatusPickerResult { const MAX_WIDTH = 320; const MAX_HEIGHT = 400; const VIEWPORT_MARGIN = 8; +const SEARCH_THRESHOLD = 10; interface PickerDom { host: HTMLElement; @@ -50,6 +55,10 @@ function element(tag: K, className: strin let openDom: PickerDom | null = null; let resolveCurrent: ((result: StatusPickerResult) => void) | null = null; let activeMemory: StatusPickerMemory | null = null; +let activeSetDefault: ((key: string) => void) | null = null; +let activeClearDefault: ((key: string) => void) | null = null; +const forcedDefaultKeys = new Set(); +const forcedClearedKeys = new Set(); let currentItems: PickerOptionItem[] = []; let activeItems: PickerOptionItem[] = []; let selectedIndex = 0; @@ -87,8 +96,8 @@ function renderList(query: string): void { } dom.empty.hidden = true; matches.forEach((item, index) => { - const row = element("button", "option-picker-row"); - row.type = "button"; + const row = element("div", "option-picker-row"); + row.setAttribute("role", "option"); row.dataset.index = String(index); if (item.icon) { const glyph = element("span", "option-picker-icon"); @@ -105,6 +114,38 @@ function renderList(query: string): void { text.appendChild(description); } row.appendChild(text); + const isDefaultNow = forcedClearedKeys.has(item.key) + ? false + : (item.isDefault === true || forcedDefaultKeys.has(item.key)); + if (activeClearDefault && isDefaultNow) { + const star = element("button", "option-picker-clear-default"); + star.type = "button"; + star.textContent = "★"; + star.title = "Remove as default"; + star.setAttribute("aria-label", `Remove \u201c${item.label}\u201d as default`); + star.addEventListener("click", (event) => { + event.stopPropagation(); + forcedClearedKeys.add(item.key); + forcedDefaultKeys.delete(item.key); + activeClearDefault?.(item.key); + renderList(query); + }); + row.appendChild(star); + } else if (activeSetDefault && !isDefaultNow) { + const star = element("button", "option-picker-set-default"); + star.type = "button"; + star.textContent = "☆"; + star.title = "Set as default"; + star.setAttribute("aria-label", `Set \u201c${item.label}\u201d as default`); + star.addEventListener("click", (event) => { + event.stopPropagation(); + forcedDefaultKeys.add(item.key); + forcedClearedKeys.delete(item.key); + activeSetDefault?.(item.key); + renderList(query); + }); + row.appendChild(star); + } row.addEventListener("click", () => finish(item.key)); row.addEventListener("pointermove", () => { selectRow(index); }); dom.list.appendChild(row); @@ -141,6 +182,8 @@ function finish(key: string | null): void { const resolve = resolveCurrent; resolveCurrent = null; activeMemory = null; + activeSetDefault = null; + activeClearDefault = null; resolve?.({ key }); } @@ -174,6 +217,12 @@ export function openStatusPicker(config: StatusPickerConfig): Promise = {}; -let lastPickerAnchor: PendingPickerRequest["anchor"] | null = null; - -const MODEL_RECENT_KEY = "pi.modelRecent.v1"; -function getModelRecentKeys(): string[] { - try { - const raw = localStorage.getItem(MODEL_RECENT_KEY); - const parsed = raw ? JSON.parse(raw) : []; - return Array.isArray(parsed) ? parsed.filter((key): key is string => typeof key === "string") : []; - } catch { return []; } -} -function recordModelRecentKey(key: string): void { - try { - const next = [key, ...getModelRecentKeys().filter((candidate) => candidate !== key)].slice(0, 8); - localStorage.setItem(MODEL_RECENT_KEY, JSON.stringify(next)); - } catch { /* storage unavailable */ } -} function elementAnchorRect(el: HTMLElement): PendingPickerRequest["anchor"] { const rect = el.getBoundingClientRect(); @@ -88,15 +72,31 @@ function handlePickerOptions(data: any): void { const pending = pendingPickerRequests[data.requestId]; delete pendingPickerRequests[data.requestId]; if (!pending) { return; } - lastPickerAnchor = pending.anchor; + const requestedAlign = data.align === "topLeft" || data.align === "bottomLeft" || data.align === "bottomRight" + ? data.align + : null; + const align = requestedAlign ?? (pending.anchor.left >= window.innerWidth / 2 ? "topRight" : "topLeft"); void openStatusPicker({ title: typeof data.title === "string" ? data.title : undefined, placeholder: typeof data.placeholder === "string" ? data.placeholder : undefined, items: data.items, anchor: pending.anchor, - align: data.align === "topLeft" || data.align === "bottomLeft" || data.align === "bottomRight" ? data.align : "topRight", + align, memory: pending.kind === "model" - ? { list: getModelRecentKeys, record: recordModelRecentKey } + ? { + list: () => Array.isArray(data.recent) ? data.recent.filter((key: unknown): key is string => typeof key === "string") : [], + record: () => undefined, // host persists recency on selection + } + : undefined, + onSetDefault: pending.kind === "model" || pending.kind === "thinking" + ? (key) => { + window.__vscode.postMessage({ type: "applyPickerOption", kind: pending.kind, key, asDefault: "set" }); + } + : undefined, + onClearDefault: pending.kind === "model" + ? (key) => { + window.__vscode.postMessage({ type: "applyPickerOption", kind: pending.kind, key, asDefault: "clear" }); + } : undefined, }).then((result) => { if (result.key !== null) { @@ -105,33 +105,6 @@ function handlePickerOptions(data: any): void { }); } -function handlePickerConfirm(data: any): void { - if (!data || typeof data.requestId !== "string") { return; } - const options: Array<{ key: string; label: string; description?: string }> = Array.isArray(data.options) - ? data.options.filter((o: unknown) => o && typeof o === "object" && typeof (o as { key?: unknown }).key === "string" && typeof (o as { label?: unknown }).label === "string") - : []; - const anchor = lastPickerAnchor ?? { - top: window.innerHeight - 8, - left: 8, - right: window.innerWidth - 8, - bottom: window.innerHeight - 8, - width: Math.max(0, window.innerWidth - 16), - height: 0, - }; - void openStatusPicker({ - title: typeof data.prompt === "string" ? data.prompt : undefined, - items: options.map((o) => ({ key: o.key, label: o.label, description: o.description })), - anchor, - align: data.align === "topLeft" || data.align === "bottomLeft" || data.align === "bottomRight" ? data.align : "topRight", - }).then((result) => { - window.__vscode.postMessage({ - type: "picker-confirm-result", - requestId: data.requestId, - key: result.key ?? "skip", - }); - }); -} - import { handleToolStart, handleToolUpdate, handleToolEnd, @@ -308,7 +281,6 @@ function handleExtensionMessage(msg: any): void { case "scoped-models-update": handleScopedModelsUpdate(msg.data); break; case "settings-update": handleSettingsUpdate(msg.data); break; case "picker-options": handlePickerOptions(msg.data); break; - case "picker-confirm": handlePickerConfirm(msg.data); break; case "revealEntry": handleRevealEntry(msg.entryId, msg.toolCallId); break; // Errors diff --git a/src/webview/render/option-picker-helpers.ts b/src/webview/render/option-picker-helpers.ts index eaee7d0..d62503a 100644 --- a/src/webview/render/option-picker-helpers.ts +++ b/src/webview/render/option-picker-helpers.ts @@ -12,6 +12,8 @@ export interface PickerOptionItem { /** Optional single-glyph prefix such as "●", "★", "☰". */ icon?: string; selected?: boolean; + /** True when this option is the current default for its kind. */ + isDefault?: boolean; } export interface PickerAnchorRect { @@ -127,10 +129,11 @@ export function buildThinkingOptions( : THINKING_LEVELS; return levels.map((level) => ({ key: level.key, - label: level.key === defaultLevel ? `${level.key} ★` : level.key, + label: level.key, description: level.description, icon: level.key === current ? "●" : undefined, selected: level.key === current, + isDefault: level.key === defaultLevel, })); }