From 2de41df8ad6284517889c8c6a9bb4b704dfd97a0 Mon Sep 17 00:00:00 2001 From: Pi on Code Agent <313040514+pi-on-code-agent[bot]@users.noreply.github.com> Date: Sun, 6 Sep 2026 05:05:51 +0000 Subject: [PATCH 1/7] feat: add reusable JSON-driven option picker --- media/style.css | 120 ++++++++++++ src/pi-service.ts | 106 +++++++++++ src/shared/protocol.ts | 29 +++ src/test/option-picker-helpers.test.ts | 103 +++++++++++ src/webview-panel.ts | 47 +++++ src/webview/components/option-picker.ts | 192 ++++++++++++++++++++ src/webview/handlers/index.ts | 56 +++++- src/webview/render/option-picker-helpers.ts | 162 +++++++++++++++++ 8 files changed, 811 insertions(+), 4 deletions(-) create mode 100644 src/test/option-picker-helpers.test.ts create mode 100644 src/webview/components/option-picker.ts create mode 100644 src/webview/render/option-picker-helpers.ts diff --git a/media/style.css b/media/style.css index 4cd191d..b3bfb4c 100644 --- a/media/style.css +++ b/media/style.css @@ -3233,3 +3233,123 @@ html.pi-viewport-recovering #pi-web-app { margin-bottom: 0; border-bottom: 0; } + +/* ═══ Reusable status-bar option picker ══════════════════════ */ + +.option-picker-root { + position: fixed; + z-index: 900; + inset: 0; +} + +.option-picker-backdrop { + position: absolute; + inset: 0; +} + +.option-picker { + position: fixed; + display: flex; + flex-direction: column; + overflow: hidden; + border: 1px solid var(--pi-line); + border-radius: 6px; + background: var(--vscode-editorWidget-background, var(--pi-surface)); + background: color-mix(in srgb, var(--pi-surface) 97%, transparent); + box-shadow: 0 8px 24px rgb(0 0 0 / 32%); + color: var(--pi-text); +} + +.option-picker-header { + padding: 8px 10px 0; +} + +.option-picker-title { + display: block; + color: var(--pi-faint); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.option-picker-input { + width: 100%; + margin: 6px 0 2px; + padding: 4px 8px; + border: 1px solid var(--pi-line); + border-radius: 3px; + background: var(--vscode-input-background, var(--pi-surface-raised)); + color: var(--vscode-input-foreground, var(--pi-text)); + font: inherit; + font-size: 12px; +} + +.option-picker-input:focus { + outline: 1px solid var(--pi-lavender); + outline-offset: -1px; +} + +.option-picker-list { + max-height: min(60vh, 340px); + overflow-y: auto; + padding: 4px; +} + +.option-picker-row { + display: flex; + width: 100%; + align-items: center; + gap: 8px; + padding: 5px 8px; + border: 0; + border-radius: 3px; + background: transparent; + color: var(--pi-text); + font: inherit; + text-align: left; + cursor: pointer; +} + +.option-picker-row.selected, +.option-picker-row:hover { + background: var(--vscode-list-hoverBackground, var(--pi-hover)); + color: var(--pi-strong); +} + +.option-picker-icon { + flex: 0 0 auto; + width: 14px; + color: var(--pi-lavender); + font-size: 12px; + text-align: center; +} + +.option-picker-text { + min-width: 0; + flex: 1 1 auto; +} + +.option-picker-label { + display: block; + overflow: hidden; + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.option-picker-desc { + display: block; + overflow: hidden; + color: var(--pi-muted); + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.option-picker-empty { + padding: 12px; + color: var(--pi-faint); + font-size: 12px; + text-align: center; +} diff --git a/src/pi-service.ts b/src/pi-service.ts index d653085..0286467 100644 --- a/src/pi-service.ts +++ b/src/pi-service.ts @@ -9,6 +9,11 @@ import { findHistoryPageStart, isVisibleHistoryEntry, } from "./history-pagination.js"; +import { + buildBudgetOptions, + buildEffortOptions, + buildThinkingOptions, +} from "./webview/render/option-picker-helpers.js"; import { buildScopedModels, completeWithModelRuntime, getRuntimeModel, selectInitialModel } from "./pi-model-runtime.js"; import { buildConversationTurnPreviews } from "./conversation-turns.js"; import { type ImageContent, type PiServiceEvent, validateExtensionToWebview } from "./types.js"; @@ -2731,6 +2736,107 @@ export class PiService { this.emit({ type: "scoped-models-update", data: { models: this.getScopedModels() } }); } + /** + * JSON payload for the reusable webview status picker. Labels are plain text + * (no VS Code codicon markup); selection and default markers are conveyed + * via `selected` and the ★ suffix so the webview owns all rendering. + */ + async buildStatusPickerOptions(kind: "model" | "thinking" | "effort" | "budget"): Promise< + Array<{ key: string; label: string; description?: string; icon?: string; selected?: boolean }> + > { + if (kind === "thinking") { + return buildThinkingOptions(this.thinkingLevel, this.getDefaultThinking()); + } + if (kind === "effort") { + return buildEffortOptions(this.effort || "auto"); + } + if (kind === "budget") { + return buildBudgetOptions(this.getContextBudget()); + } + + interface ModelChoice { label: string; provider: string; modelId: string; cost?: { input: number; output: number }; contextWindow?: number } + let models: ModelChoice[] = []; + try { + const available = await this.getAvailableModels(); + if (available.length > 0) { + models = available.map((m) => ({ + label: m.name || m.id, + provider: m.provider, + modelId: m.id, + cost: m.cost, + contextWindow: m.contextWindow, + })); + } +// eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (e: any) { + piWarn(`buildStatusPickerOptions(model) failed (${e.message}), using static fallback`); + } + if (models.length === 0) { + models = [ + { label: "Claude Sonnet 4.5", provider: "anthropic", modelId: "claude-sonnet-4-5" }, + { label: "Claude Haiku 4.5", provider: "anthropic", modelId: "claude-haiku-4-5" }, + { label: "Claude Opus 4.5", provider: "anthropic", modelId: "claude-opus-4-5" }, + { label: "GPT 4o", provider: "openai", modelId: "gpt-4o" }, + { label: "Gemini 2.5 Pro", provider: "google", modelId: "gemini-2.5-pro" }, + { label: "DeepSeek V3", provider: "deepseek", modelId: "deepseek-chat" }, + ]; + } + const currentId = this.model?.id; + const defModel = this.getDefaultModel(); + return models.map((m) => { + const isDefault = Boolean(defModel && m.provider === defModel.provider && m.modelId === defModel.id); + const detail = m.cost || m.contextWindow + ? PiService.formatModelDetail(m.cost, m.contextWindow) + : undefined; + return { + key: `${m.provider}::${m.modelId}`, + label: isDefault ? `${m.label} ★` : m.label, + description: detail ? `${m.provider} · ${detail}` : m.provider, + icon: m.modelId === currentId ? "●" : undefined, + selected: m.modelId === currentId, + }; + }); + } + + /** Apply a selection from the reusable webview status picker. */ + async applyStatusPickerOption(kind: "model" | "thinking" | "effort" | "budget", key: string): Promise { + if (kind === "effort") { + await this.setEffort(key); + return; + } + if (kind === "budget") { + const tokens = Number(key); + await this.setContextBudget(Number.isFinite(tokens) ? tokens : 0); + return; + } + if (kind === "thinking") { + await this.setThinkingLevel(key); + if (key !== this.getDefaultThinking()) { + const save = await vscode.window.showQuickPick( + [{ label: "\u2605 Save as default", description: "Use this thinking level for future sessions" }], + { placeHolder: `Use "${key}" thinking as the default?` }, + ); + if (save) { this.saveDefaultThinking(); } + } + return; + } + // model + const separator = key.lastIndexOf("::"); + if (separator <= 0 || separator >= key.length - 2) { return; } + 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 vscode.window.showQuickPick( + [{ label: "\u2605 Save as default", description: "Use this model for future sessions" }], + { placeHolder: "Save this model as default?" }, + ); + if (save) { this.saveDefaultModel(); } + } + } + emitSettings(): void { this.emit({ type: "settings-update", diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index 4038cc2..c5c4514 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -380,6 +380,25 @@ const ExtensionToWebviewSchema = z.discriminatedUnion("type", [ }), }), + // Status-bar option picker payloads + z.object({ + type: z.literal("picker-options"), + data: z.object({ + requestId: z.string().min(1).max(64), + kind: z.enum(["model", "thinking", "effort", "budget"]), + title: z.string().optional(), + placeholder: z.string().optional(), + align: z.enum(["topLeft", "topRight", "bottomLeft", "bottomRight"]).optional(), + items: z.array(z.object({ + key: z.string(), + label: z.string(), + description: z.string().optional(), + icon: z.string().optional(), + selected: z.boolean().optional(), + })).max(500), + }), + }), + // Scroll to entry z.object({ type: z.literal("revealEntry"), entryId: z.string(), toolCallId: z.string().optional() }), @@ -543,6 +562,16 @@ const WebviewToExtensionSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal("toggleAutoAttachActiveEditor") }), z.object({ type: z.literal("openUrl"), url: z.string() }), z.object({ type: z.literal("openFile"), path: z.string() }), + z.object({ + type: z.literal("requestPickerOptions"), + requestId: z.string().min(1).max(64), + kind: z.enum(["model", "thinking", "effort", "budget"]), + }), + z.object({ + type: z.literal("applyPickerOption"), + kind: z.enum(["model", "thinking", "effort", "budget"]), + key: z.string().min(1).max(512), + }), 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() }), z.object({ diff --git a/src/test/option-picker-helpers.test.ts b/src/test/option-picker-helpers.test.ts new file mode 100644 index 0000000..f4fd927 --- /dev/null +++ b/src/test/option-picker-helpers.test.ts @@ -0,0 +1,103 @@ +import * as assert from "node:assert"; +import { readFileSync } from "node:fs"; +import { + buildBudgetOptions, + buildEffortOptions, + buildThinkingOptions, + fuzzyFilterOptions, + resolvePickerPlacement, + STATUS_PICKER_META, + type PickerOptionItem, +} from "../webview/render/option-picker-helpers.js"; + +const items: PickerOptionItem[] = [ + { key: "claude", label: "Claude Sonnet 4.5", description: "anthropic · $3/m" }, + { key: "gpt", label: "GPT 4o", description: "openai" }, + { key: "deepseek", label: "DeepSeek V3", description: "deepseek" }, +]; + +suite("Option picker helpers", () => { + test("fuzzy filters across label and description with tokens", () => { + assert.deepStrictEqual( + fuzzyFilterOptions(items, "DEEP").map((item) => item.key), + ["deepseek"], + ); + assert.deepStrictEqual( + fuzzyFilterOptions(items, "claude anthr").map((item) => item.key), + ["claude"], + ); + assert.strictEqual(fuzzyFilterOptions(items, "").length, 3); + assert.strictEqual(fuzzyFilterOptions(items, "zzz").length, 0); + }); + + test("clamps four-corner placement inside the viewport", () => { + const anchor = { top: 800, left: 600, right: 1000, bottom: 830, width: 400, height: 30 }; + const size = { width: 320, height: 400 }; + const viewport = { width: 1000, height: 900, margin: 8 }; + + const bottomRight = resolvePickerPlacement(anchor, "bottomRight", size, viewport); + assert.strictEqual(bottomRight.left, 672); // right edge aligned then clamped inside + assert.strictEqual(bottomRight.top, 492); // pushed up so the whole menu stays on screen + + const topLeft = resolvePickerPlacement(anchor, "topLeft", size, viewport); + assert.strictEqual(topLeft.left, 600); + assert.strictEqual(topLeft.top, 396); + + // A huge anchor near the bottom-right corner keeps both axes on screen. + const corner = resolvePickerPlacement( + { top: 890, left: 900, right: 990, bottom: 899, width: 90, height: 9 }, + "topRight", + size, + viewport, + ); + assert.ok(corner.left >= 8 && corner.left + size.width <= 1000 - 8); + assert.ok(corner.top >= 8 && corner.top + size.height <= 900 - 8); + }); + + test("builds thinking options with current and default markers", () => { + const options = buildThinkingOptions("low", "off"); + const low = options.find((option) => option.key === "low"); + 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"); + }); + + test("builds effort and budget options with the current value marked", () => { + const effort = buildEffortOptions("high"); + assert.strictEqual(effort.find((option) => option.key === "high")?.icon, "●"); + assert.strictEqual(effort.find((option) => option.key === "high")?.selected, true); + + const budget = buildBudgetOptions(200000); + assert.strictEqual(budget.find((option) => option.key === "200000")?.selected, true); + assert.strictEqual(budget.find((option) => option.key === "0")?.label, "Model default"); + }); + + test("exposes metadata for every status picker kind", () => { + assert.deepStrictEqual(Object.keys(STATUS_PICKER_META).sort(), ["budget", "effort", "model", "thinking"]); + }); + + test("wires the picker end-to-end across webview, host, and protocol", () => { + const protocol = readFileSync(new URL("../../src/shared/protocol.ts", import.meta.url), "utf8"); + assert.match(protocol, /z\.literal\("picker-options"\)/); + assert.match(protocol, /z\.literal\("requestPickerOptions"\)/); + assert.match(protocol, /z\.literal\("applyPickerOption"\)/); + + const handlers = readFileSync(new URL("../../src/webview/handlers/index.ts", import.meta.url), "utf8"); + assert.match(handlers, /requestStatusPicker\("model", sbModel\)/); + assert.match(handlers, /requestStatusPicker\("thinking", sbThinking\)/); + assert.match(handlers, /requestStatusPicker\("effort", sbEffort\)/); + assert.match(handlers, /requestStatusPicker\("budget", sbUsage\)/); + assert.match(handlers, /case "picker-options":/); + + const panel = readFileSync(new URL("../../src/webview-panel.ts", import.meta.url), "utf8"); + assert.match(panel, /case "requestPickerOptions"/); + assert.match(panel, /case "applyPickerOption"/); + assert.match(panel, /buildStatusPickerOptions\(message\.kind\)/); + assert.match(panel, /applyStatusPickerOption\(message\.kind, message\.key\)/); + + const service = readFileSync(new URL("../../src/pi-service.ts", import.meta.url), "utf8"); + assert.match(service, /async buildStatusPickerOptions/); + assert.match(service, /async applyStatusPickerOption/); + }); +}); diff --git a/src/webview-panel.ts b/src/webview-panel.ts index 4ba905f..c04fdaa 100644 --- a/src/webview-panel.ts +++ b/src/webview-panel.ts @@ -12,6 +12,7 @@ import { extensionSettingsQuery } from "./vscode-settings.js"; import { SessionCapabilitySnapshot } from "./capability-snapshot.js"; import { piError } from "./logger.js"; import { getWorkspaceCwd } from "./workspace-context.js"; +import { STATUS_PICKER_META } from "./webview/render/option-picker-helpers.js"; import { limitTabLabel } from "./tab-label.js"; import type { PiService } from "./pi-service.js"; import type { PiServiceEvent } from "./types.js"; @@ -46,6 +47,12 @@ export interface CapabilityPanelActions { listSessionReferences?: () => WorkspaceFileItem[]; } +type StatusPickerKind = "model" | "thinking" | "effort" | "budget"; + +function isStatusPickerKind(value: unknown): value is StatusPickerKind { + return value === "model" || value === "thinking" || value === "effort" || value === "budget"; +} + export class PiWebviewPanel { private panel: vscode.WebviewPanel | null = null; private piService: PiService; @@ -736,6 +743,46 @@ export class PiWebviewPanel { } break; + // Reusable status-bar picker: request option JSON for a kind + case "requestPickerOptions": + if (typeof message.requestId === "string" && isStatusPickerKind(message.kind)) { + try { + const items = await this.piService.buildStatusPickerOptions(message.kind); + const meta = STATUS_PICKER_META[message.kind]; + this.postMessage({ + type: "picker-options", + data: { + requestId: message.requestId, + kind: message.kind, + title: meta.title, + placeholder: meta.placeholder, + align: "topRight", + items, + }, + }); + } catch (error: unknown) { + this.postMessage({ + type: "error", + data: { message: `Could not load options: ${error instanceof Error ? error.message : String(error)}` }, + }); + } + } + break; + + // Reusable status-bar picker: apply a selection + case "applyPickerOption": + if (isStatusPickerKind(message.kind) && typeof message.key === "string") { + try { + await this.piService.applyStatusPickerOption(message.kind, message.key); + } catch (error: unknown) { + this.postMessage({ + type: "error", + data: { message: `Could not apply selection: ${error instanceof Error ? error.message : String(error)}` }, + }); + } + } + break; + // Request settings state (#2, #8) case "open-session": if (typeof message.sessionId === "string") { diff --git a/src/webview/components/option-picker.ts b/src/webview/components/option-picker.ts new file mode 100644 index 0000000..7b7914b --- /dev/null +++ b/src/webview/components/option-picker.ts @@ -0,0 +1,192 @@ +import { + fuzzyFilterOptions, + resolvePickerPlacement, + type PickerAlign, + type PickerAnchorRect, + type PickerOptionItem, + type PickerPlacement, + type PickerSize, +} from "../render/option-picker-helpers.js"; + +export interface StatusPickerConfig { + title?: string; + placeholder?: string; + items: readonly PickerOptionItem[]; + anchor: PickerAnchorRect; + align: PickerAlign; +} + +export interface StatusPickerResult { + /** Selected option key, or null when dismissed. */ + key: string | null; +} + +const MAX_WIDTH = 320; +const MAX_HEIGHT = 400; +const VIEWPORT_MARGIN = 8; + +interface PickerDom { + host: HTMLElement; + panel: HTMLElement; + input: HTMLInputElement; + list: HTMLElement; + empty: HTMLElement; +} + +function element(tag: K, className: string): HTMLElementTagNameMap[K] { + const node = document.createElement(tag); + node.className = className; + return node; +} + +let openDom: PickerDom | null = null; +let resolveCurrent: ((result: StatusPickerResult) => void) | null = null; +let currentItems: PickerOptionItem[] = []; +let activeItems: PickerOptionItem[] = []; +let selectedIndex = 0; + +function measure(): PickerSize { + const panel = openDom?.panel; + if (!panel) { return { width: MAX_WIDTH, height: MAX_HEIGHT }; } + return { + width: Math.min(panel.offsetWidth || MAX_WIDTH, MAX_WIDTH), + height: Math.min(panel.offsetHeight || MAX_HEIGHT, MAX_HEIGHT), + }; +} + +function place(config: StatusPickerConfig): void { + const dom = openDom; + const panel = dom?.panel; + if (!dom || !panel) { return; } + const size = measure(); + const viewport = { width: window.innerWidth, height: window.innerHeight, margin: VIEWPORT_MARGIN }; + const position = resolvePickerPlacement(config.anchor, config.align, size, viewport); + panel.style.left = `${position.left}px`; + panel.style.top = `${position.top}px`; +} + +function renderList(query: string): void { + const dom = openDom; + if (!dom) { return; } + const matches = fuzzyFilterOptions(currentItems, query); + activeItems = matches; + dom.list.replaceChildren(); + selectedIndex = 0; + if (matches.length === 0) { + dom.empty.hidden = false; + return; + } + dom.empty.hidden = true; + matches.forEach((item, index) => { + const row = element("button", "option-picker-row"); + row.type = "button"; + row.dataset.index = String(index); + if (item.icon) { + const glyph = element("span", "option-picker-icon"); + glyph.textContent = item.icon; + row.appendChild(glyph); + } + const text = element("span", "option-picker-text"); + const label = element("span", "option-picker-label"); + label.textContent = item.label; + text.appendChild(label); + if (item.description) { + const description = element("span", "option-picker-desc"); + description.textContent = item.description; + text.appendChild(description); + } + row.appendChild(text); + row.addEventListener("click", () => finish(item.key)); + row.addEventListener("pointermove", () => { selectRow(index); }); + dom.list.appendChild(row); + }); + const selected = dom.list.querySelector(".option-picker-row.selected"); + selected?.scrollIntoView({ block: "nearest" }); +} + +function selectRow(index: number): void { + const dom = openDom; + if (!dom) { return; } + const rows = dom.list.querySelectorAll(".option-picker-row"); + if (rows.length === 0) { return; } + selectedIndex = Math.min(Math.max(0, index), rows.length - 1); + rows.forEach((row, i) => row.classList.toggle("selected", i === selectedIndex)); +} + +function moveSelection(delta: number): void { + const dom = openDom; + if (!dom) { return; } + const rows = dom.list.querySelectorAll(".option-picker-row"); + if (rows.length === 0) { return; } + const next = (selectedIndex + delta + rows.length) % rows.length; + selectRow(next); + rows[next]?.scrollIntoView({ block: "nearest" }); +} + +function finish(key: string | null): void { + const dom = openDom; + openDom = null; + if (!dom) { return; } + dom.host.remove(); + const resolve = resolveCurrent; + resolveCurrent = null; + resolve?.({ key }); +} + +/** Open the reusable, searchable status-bar option picker. */ +export function openStatusPicker(config: StatusPickerConfig): Promise { + finish(null); // close any previous picker first + + const host = element("div", "option-picker-root"); + const backdrop = element("div", "option-picker-backdrop"); + const panel = element("div", "option-picker"); + panel.setAttribute("role", "dialog"); + panel.setAttribute("aria-label", config.title ?? "Options"); + + const header = element("div", "option-picker-header"); + if (config.title) { + const title = element("span", "option-picker-title"); + title.textContent = config.title; + header.appendChild(title); + } + const input = element("input", "option-picker-input"); + input.type = "search"; + input.placeholder = config.placeholder ?? "Search…"; + input.setAttribute("aria-label", config.placeholder ?? "Search options"); + const list = element("div", "option-picker-list"); + const empty = element("div", "option-picker-empty"); + empty.textContent = "No matches"; + empty.hidden = true; + + panel.append(header, input, list, empty); + host.append(backdrop, panel); + document.body.appendChild(host); + openDom = { host, panel, input, list, empty }; + currentItems = [...config.items]; + selectedIndex = 0; + + input.addEventListener("input", () => renderList(input.value)); + input.addEventListener("keydown", (event) => { + if (event.key === "ArrowDown") { event.preventDefault(); moveSelection(1); } + if (event.key === "ArrowUp") { event.preventDefault(); moveSelection(-1); } + if (event.key === "Enter") { + event.preventDefault(); + const key = activeItems[selectedIndex]?.key ?? null; + if (key !== null) { finish(key); } + } + if (event.key === "Escape") { event.preventDefault(); finish(null); } + }); + backdrop.addEventListener("click", () => finish(null)); + + renderList(""); + place(config); + panel.style.width = `${MAX_WIDTH}px`; + panel.style.maxWidth = `${Math.max(0, window.innerWidth - VIEWPORT_MARGIN * 2)}px`; + // Reposition once the list has real height (fonts/layout ready). + requestAnimationFrame(() => place(config)); + input.focus(); + + return new Promise((resolve) => { + resolveCurrent = resolve; + }); +} diff --git a/src/webview/handlers/index.ts b/src/webview/handlers/index.ts index 82163f5..5d62266 100644 --- a/src/webview/handlers/index.ts +++ b/src/webview/handlers/index.ts @@ -39,6 +39,53 @@ import { CustomUi } from "../components/custom-ui.js"; import { CONVERSATION_TURNS_EVENT, getConversationJumpTop } from "../components/conversation-minimap.js"; import type { ScrollOwner } from "../render/scroll-lock.js"; import { findWorkspaceFileMention, removeWorkspaceFileMention } from "../file-mention.js"; +import { openStatusPicker } from "../components/option-picker.js"; + +interface PendingPickerRequest { + kind: string; + anchor: { top: number; left: number; right: number; bottom: number; width: number; height: number }; +} + +const pendingPickerRequests: Record = {}; + +function elementAnchorRect(el: HTMLElement): PendingPickerRequest["anchor"] { + const rect = el.getBoundingClientRect(); + return { + top: rect.top, + left: rect.left, + right: rect.right, + bottom: rect.bottom, + width: rect.width, + height: rect.height, + }; +} + +/** Ask the host for JSON options, then open the anchored picker on arrival. */ +function requestStatusPicker(kind: string, anchorEl: HTMLElement): void { + const requestId = `picker-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + pendingPickerRequests[requestId] = { kind, anchor: elementAnchorRect(anchorEl) }; + window.__vscode.postMessage({ type: "requestPickerOptions", requestId, kind }); +} + +function handlePickerOptions(data: any): void { + if (!data || typeof data.requestId !== "string" || !Array.isArray(data.items)) { return; } + const pending = pendingPickerRequests[data.requestId]; + delete pendingPickerRequests[data.requestId]; + if (!pending) { return; } + 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", + }).then((result) => { + if (result.key !== null) { + window.__vscode.postMessage({ type: "applyPickerOption", kind: pending.kind, key: result.key }); + } + }); +} + + import { handleToolStart, handleToolUpdate, handleToolEnd, writeToolRenderer, editToolRenderer, readToolRenderer, @@ -213,6 +260,7 @@ function handleExtensionMessage(msg: any): void { case "user-messages-list": handleUserMessagesList(msg.data); break; case "scoped-models-update": handleScopedModelsUpdate(msg.data); break; case "settings-update": handleSettingsUpdate(msg.data); break; + case "picker-options": handlePickerOptions(msg.data); break; case "revealEntry": handleRevealEntry(msg.entryId, msg.toolCallId); break; // Errors @@ -2187,17 +2235,17 @@ export function sendPrompt(modeOverride?: "steer" | "queue"): void { // ── In-webview status bar click handlers ───────────── if (sbModel) { sbModel.addEventListener("click", function () { - window.__vscode.postMessage({ type: "pickModel" }); + requestStatusPicker("model", sbModel); }); } if (sbThinking) { sbThinking.addEventListener("click", function () { - window.__vscode.postMessage({ type: "pickThinkingLevel" }); + requestStatusPicker("thinking", sbThinking); }); } if (sbEffort) { sbEffort.addEventListener("click", function () { - window.__vscode.postMessage({ type: "pickEffort" }); + requestStatusPicker("effort", sbEffort); }); } if (sbCapabilities) { @@ -2207,7 +2255,7 @@ export function sendPrompt(modeOverride?: "steer" | "queue"): void { } if (sbUsage) { sbUsage.addEventListener("click", function () { - window.__vscode.postMessage({ type: "pickContextBudget" }); + requestStatusPicker("budget", sbUsage); }); } let sbSettings = document.getElementById("pi-sb-settings"); diff --git a/src/webview/render/option-picker-helpers.ts b/src/webview/render/option-picker-helpers.ts new file mode 100644 index 0000000..d3de117 --- /dev/null +++ b/src/webview/render/option-picker-helpers.ts @@ -0,0 +1,162 @@ +/** + * Pure helpers for the reusable status-bar option picker: fuzzy filtering, + * viewport-clamped four-corner anchoring, and static option builders. Kept + * free of DOM so the behavior is directly unit-testable. + */ + +export interface PickerOptionItem { + /** Stable value handed back to the host when selected. */ + key: string; + label: string; + description?: string; + /** Optional single-glyph prefix such as "●", "★", "☰". */ + icon?: string; + selected?: boolean; +} + +export interface PickerAnchorRect { + top: number; + left: number; + right: number; + bottom: number; + width: number; + height: number; +} + +export type PickerAlign = "topLeft" | "topRight" | "bottomLeft" | "bottomRight"; + +export interface PickerPlacement { + left: number; + top: number; +} + +export interface PickerSize { + width: number; + height: number; +} + +export interface PickerViewport { + width: number; + height: number; + margin?: number; +} + +export interface StatusPickerKindMeta { + title: string; + placeholder: string; +} + +export const STATUS_PICKER_META: Record = { + model: { title: "Model", placeholder: "Search models (★ = default)" }, + thinking: { title: "Thinking level", placeholder: "Search thinking levels (★ = default)" }, + effort: { title: "Effort", placeholder: "Search effort levels" }, + budget: { title: "Context budget", placeholder: "Search context budgets" }, +}; + +/** Case-insensitive token filter over label + description. */ +export function fuzzyFilterOptions( + items: readonly PickerOptionItem[], + query: string, +): PickerOptionItem[] { + const trimmed = query.trim().toLowerCase(); + if (!trimmed) { return [...items]; } + return items.filter((item) => { + const haystack = `${item.label} ${item.description ?? ""}`.toLowerCase(); + return trimmed.split(/\s+/).every((token) => haystack.includes(token)); + }); +} + +/** + * Compute the picker's top-left corner for the requested alignment against an + * anchor rect, clamped inside the viewport with the given margin. + */ +export function resolvePickerPlacement( + anchor: PickerAnchorRect, + align: PickerAlign, + size: PickerSize, + viewport: PickerViewport, +): PickerPlacement { + const margin = viewport.margin ?? 8; + const maxLeft = Math.max(margin, viewport.width - size.width - margin); + const maxTop = Math.max(margin, viewport.height - size.height - margin); + + let x = anchor.left; + let y = anchor.bottom + 4; + if (align === "topLeft") { y = anchor.top - size.height - 4; } + if (align === "topRight") { + x = anchor.right - size.width; + y = anchor.top - size.height - 4; + } + if (align === "bottomRight") { x = anchor.right - size.width; } + + return { + left: Math.min(maxLeft, Math.max(margin, x)), + top: Math.min(maxTop, Math.max(margin, y)), + }; +} + +const THINKING_LEVELS: ReadonlyArray<{ key: string; description: string }> = [ + { key: "off", description: "No thinking" }, + { key: "minimal", description: "Minimal thinking" }, + { key: "low", description: "Brief thinking" }, + { key: "medium", description: "Balanced thinking" }, + { key: "high", description: "Extended thinking" }, + { key: "xhigh", description: "Maximum thinking" }, +]; + +export function buildThinkingOptions( + current: string, + defaultLevel: string | undefined, +): PickerOptionItem[] { + return THINKING_LEVELS.map((level) => ({ + key: level.key, + label: level.key === defaultLevel ? `${level.key} ★` : level.key, + description: level.description, + icon: level.key === current ? "●" : undefined, + selected: level.key === current, + })); +} + +const EFFORT_LEVELS: ReadonlyArray<{ key: string; description: string }> = [ + { key: "auto", description: "Let the model decide" }, + { key: "none", description: "No effort" }, + { key: "low", description: "Low effort" }, + { key: "medium", description: "Medium effort" }, + { key: "high", description: "High effort" }, +]; + +export function buildEffortOptions(current: string): PickerOptionItem[] { + return EFFORT_LEVELS.map((level) => ({ + key: level.key, + label: level.key, + description: level.description, + icon: level.key === current ? "●" : undefined, + selected: level.key === current, + })); +} + +const BUDGET_PRESETS: ReadonlyArray<{ key: string; label: string; description: string }> = [ + { key: "0", label: "Model default", description: "Use the model's built-in context window" }, + { key: "100000", label: "100K tokens", description: "Compact at ~0.1M" }, + { key: "200000", label: "200K tokens", description: "Compact at ~0.2M" }, + { key: "500000", label: "500K tokens", description: "Compact at ~0.5M" }, + { key: "1000000", label: "1M tokens", description: "Compact at ~1M" }, +]; + +export function buildBudgetOptions(currentTokens: number): PickerOptionItem[] { + return BUDGET_PRESETS.map((preset) => ({ + key: preset.key, + label: preset.label, + description: preset.description, + icon: String(currentTokens) === preset.key ? "●" : undefined, + selected: String(currentTokens) === preset.key, + })); +} + +/** Compact budget label used by meta/titles, e.g. "200K". */ +export function formatBudgetLabel(tokens: number): string { + if (tokens === 0) { return "model default"; } + if (tokens < 1000) { return String(tokens); } + if (tokens < 1000000) { return `${(tokens / 1000).toFixed(0)}K`; } + return `${(tokens / 1000000).toFixed(1)}M`; +} From 0abbd8be228de972b543f5d4275e11adeca78b54 Mon Sep 17 00:00:00 2001 From: auchan Date: Sun, 6 Sep 2026 20:17:19 +0800 Subject: [PATCH 2/7] fix: cap picker panel height and resolve rebase conflict --- media/style.css | 4 +++- src/test/option-picker-helpers.test.ts | 21 +++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/media/style.css b/media/style.css index b3bfb4c..30da00a 100644 --- a/media/style.css +++ b/media/style.css @@ -3251,6 +3251,7 @@ html.pi-viewport-recovering #pi-web-app { position: fixed; display: flex; flex-direction: column; + max-height: min(60vh, 400px); overflow: hidden; border: 1px solid var(--pi-line); border-radius: 6px; @@ -3291,7 +3292,8 @@ html.pi-viewport-recovering #pi-web-app { } .option-picker-list { - max-height: min(60vh, 340px); + flex: 1 1 auto; + min-height: 0; overflow-y: auto; padding: 4px; } diff --git a/src/test/option-picker-helpers.test.ts b/src/test/option-picker-helpers.test.ts index f4fd927..a9f41f8 100644 --- a/src/test/option-picker-helpers.test.ts +++ b/src/test/option-picker-helpers.test.ts @@ -54,6 +54,27 @@ suite("Option picker helpers", () => { assert.ok(corner.top >= 8 && corner.top + size.height <= 900 - 8); }); + test("caps the panel so measured height matches the placement height", () => { + const styles = readFileSync( + new URL("../../media/style.css", import.meta.url), + "utf8", + ).replace(/\r\n/g, "\n"); + const panelBlock = styles.match(/\.option-picker \{\n[\s\S]*?\n\}/)?.[0] ?? ""; + assert.match(panelBlock, /max-height: min\(60vh, 400px\)/); + assert.match( + styles, + /\.option-picker-list \{\n flex: 1 1 auto;\n min-height: 0;\n overflow-y: auto;/, + ); + // Placement is computed against the capped height even when content is long. + const tall = resolvePickerPlacement( + { top: 860, left: 500, right: 820, bottom: 899, width: 320, height: 39 }, + "bottomLeft", + { width: 320, height: 400 }, + { width: 1000, height: 900, margin: 8 }, + ); + assert.ok(tall.top >= 8 && tall.top + 400 <= 900 - 8); + }); + test("builds thinking options with current and default markers", () => { const options = buildThinkingOptions("low", "off"); const low = options.find((option) => option.key === "low"); From d71d4cc4dc9026d8c85916fdd8f17492e7b49bc0 Mon Sep 17 00:00:00 2001 From: auchan Date: Sun, 6 Sep 2026 20:19:17 +0800 Subject: [PATCH 3/7] fix: allow more than 500 picker options --- src/shared/protocol.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index c5c4514..582d5a1 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -395,7 +395,7 @@ const ExtensionToWebviewSchema = z.discriminatedUnion("type", [ description: z.string().optional(), icon: z.string().optional(), selected: z.boolean().optional(), - })).max(500), + })).max(5000), }), }), From 131f67b3f94ac5eb43a39b6ca65a84e06c4f91a0 Mon Sep 17 00:00:00 2001 From: auchan Date: Sun, 6 Sep 2026 20:31:44 +0800 Subject: [PATCH 4/7] feat: opaque above-bar picker and unified save-as-default prompt --- media/style.css | 4 +- src/pi-service.ts | 47 +++++++++++++++++---- src/shared/protocol.ts | 18 ++++++++ src/test/option-picker-helpers.test.ts | 2 +- src/webview-panel.ts | 6 +++ src/webview/handlers/index.ts | 30 +++++++++++++ src/webview/render/option-picker-helpers.ts | 6 +-- 7 files changed, 99 insertions(+), 14 deletions(-) diff --git a/media/style.css b/media/style.css index 30da00a..3d7f7f7 100644 --- a/media/style.css +++ b/media/style.css @@ -3249,14 +3249,14 @@ html.pi-viewport-recovering #pi-web-app { .option-picker { position: fixed; + z-index: 300; display: flex; flex-direction: column; max-height: min(60vh, 400px); overflow: hidden; border: 1px solid var(--pi-line); border-radius: 6px; - background: var(--vscode-editorWidget-background, var(--pi-surface)); - background: color-mix(in srgb, var(--pi-surface) 97%, transparent); + background: var(--vscode-editorWidget-background, var(--pi-surface-raised)); box-shadow: 0 8px 24px rgb(0 0 0 / 32%); color: var(--pi-text); } diff --git a/src/pi-service.ts b/src/pi-service.ts index 0286467..7ed5ad3 100644 --- a/src/pi-service.ts +++ b/src/pi-service.ts @@ -2812,10 +2812,7 @@ export class PiService { if (kind === "thinking") { await this.setThinkingLevel(key); if (key !== this.getDefaultThinking()) { - const save = await vscode.window.showQuickPick( - [{ label: "\u2605 Save as default", description: "Use this thinking level for future sessions" }], - { placeHolder: `Use "${key}" thinking as the default?` }, - ); + const save = await this.askSaveAsDefault(`Use "${key}" thinking as the default?`); if (save) { this.saveDefaultThinking(); } } return; @@ -2829,14 +2826,48 @@ export class PiService { const defModel = this.getDefaultModel(); const isDefault = Boolean(defModel && provider === defModel.provider && modelId === defModel.id); if (!isDefault) { - const save = await vscode.window.showQuickPick( - [{ label: "\u2605 Save as default", description: "Use this model for future sessions" }], - { placeHolder: "Save this model as default?" }, - ); + const save = await this.askSaveAsDefault("Save this model as default?"); if (save) { this.saveDefaultModel(); } } } + /** 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 582d5a1..58e966f 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -398,6 +398,19 @@ const ExtensionToWebviewSchema = z.discriminatedUnion("type", [ })).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(), + })), + }), + }), // Scroll to entry z.object({ type: z.literal("revealEntry"), entryId: z.string(), toolCallId: z.string().optional() }), @@ -572,6 +585,11 @@ const WebviewToExtensionSchema = z.discriminatedUnion("type", [ 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), + }), 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() }), z.object({ diff --git a/src/test/option-picker-helpers.test.ts b/src/test/option-picker-helpers.test.ts index a9f41f8..abb0d88 100644 --- a/src/test/option-picker-helpers.test.ts +++ b/src/test/option-picker-helpers.test.ts @@ -41,7 +41,7 @@ suite("Option picker helpers", () => { const topLeft = resolvePickerPlacement(anchor, "topLeft", size, viewport); assert.strictEqual(topLeft.left, 600); - assert.strictEqual(topLeft.top, 396); + assert.strictEqual(topLeft.top, 392); // A huge anchor near the bottom-right corner keeps both axes on screen. const corner = resolvePickerPlacement( diff --git a/src/webview-panel.ts b/src/webview-panel.ts index c04fdaa..9739b15 100644 --- a/src/webview-panel.ts +++ b/src/webview-panel.ts @@ -783,6 +783,12 @@ 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") { diff --git a/src/webview/handlers/index.ts b/src/webview/handlers/index.ts index 5d62266..d22dbab 100644 --- a/src/webview/handlers/index.ts +++ b/src/webview/handlers/index.ts @@ -47,6 +47,7 @@ interface PendingPickerRequest { } const pendingPickerRequests: Record = {}; +let lastPickerAnchor: PendingPickerRequest["anchor"] | null = null; function elementAnchorRect(el: HTMLElement): PendingPickerRequest["anchor"] { const rect = el.getBoundingClientRect(); @@ -72,6 +73,7 @@ function handlePickerOptions(data: any): void { const pending = pendingPickerRequests[data.requestId]; delete pendingPickerRequests[data.requestId]; if (!pending) { return; } + lastPickerAnchor = pending.anchor; void openStatusPicker({ title: typeof data.title === "string" ? data.title : undefined, placeholder: typeof data.placeholder === "string" ? data.placeholder : undefined, @@ -85,6 +87,33 @@ 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, @@ -261,6 +290,7 @@ 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 d3de117..530acb9 100644 --- a/src/webview/render/option-picker-helpers.ts +++ b/src/webview/render/option-picker-helpers.ts @@ -81,11 +81,11 @@ export function resolvePickerPlacement( const maxTop = Math.max(margin, viewport.height - size.height - margin); let x = anchor.left; - let y = anchor.bottom + 4; - if (align === "topLeft") { y = anchor.top - size.height - 4; } + let y = anchor.bottom + 8; + if (align === "topLeft") { y = anchor.top - size.height - 8; } if (align === "topRight") { x = anchor.right - size.width; - y = anchor.top - size.height - 4; + y = anchor.top - size.height - 8; } if (align === "bottomRight") { x = anchor.right - size.width; } From 290ff385acadcaea7dc6ebd3e5bb408439e9328b Mon Sep 17 00:00:00 2001 From: auchan Date: Sun, 6 Sep 2026 20:37:24 +0800 Subject: [PATCH 5/7] feat: remember recently chosen picker options for model --- src/test/option-picker-helpers.test.ts | 13 +++++++++++++ src/webview/components/option-picker.ts | 16 +++++++++++++++- src/webview/handlers/index.ts | 18 ++++++++++++++++++ src/webview/render/option-picker-helpers.ts | 12 ++++++++++++ 4 files changed, 58 insertions(+), 1 deletion(-) diff --git a/src/test/option-picker-helpers.test.ts b/src/test/option-picker-helpers.test.ts index abb0d88..4395e2c 100644 --- a/src/test/option-picker-helpers.test.ts +++ b/src/test/option-picker-helpers.test.ts @@ -5,6 +5,7 @@ import { buildEffortOptions, buildThinkingOptions, fuzzyFilterOptions, + orderItemsByRecent, resolvePickerPlacement, STATUS_PICKER_META, type PickerOptionItem, @@ -30,6 +31,18 @@ suite("Option picker helpers", () => { assert.strictEqual(fuzzyFilterOptions(items, "zzz").length, 0); }); + test("orders recently selected keys first", () => { + assert.deepStrictEqual( + orderItemsByRecent(items, ["deepseek", "claude"]).map((item) => item.key), + ["deepseek", "claude", "gpt"], + ); + // Missing recent keys are ignored. + assert.deepStrictEqual( + orderItemsByRecent(items, ["gpt", "nope"]).map((item) => item.key), + ["gpt", "claude", "deepseek"], + ); + }); + test("clamps four-corner placement inside the viewport", () => { const anchor = { top: 800, left: 600, right: 1000, bottom: 830, width: 400, height: 30 }; const size = { width: 320, height: 400 }; diff --git a/src/webview/components/option-picker.ts b/src/webview/components/option-picker.ts index 7b7914b..84550fa 100644 --- a/src/webview/components/option-picker.ts +++ b/src/webview/components/option-picker.ts @@ -1,5 +1,6 @@ import { fuzzyFilterOptions, + orderItemsByRecent, resolvePickerPlacement, type PickerAlign, type PickerAnchorRect, @@ -14,6 +15,13 @@ export interface StatusPickerConfig { items: readonly PickerOptionItem[]; anchor: PickerAnchorRect; align: PickerAlign; + /** Optional recency memory: recent keys surface first and are updated on pick. */ + memory?: StatusPickerMemory; +} + +export interface StatusPickerMemory { + list: () => readonly string[]; + record: (key: string) => void; } export interface StatusPickerResult { @@ -41,6 +49,7 @@ function element(tag: K, className: strin let openDom: PickerDom | null = null; let resolveCurrent: ((result: StatusPickerResult) => void) | null = null; +let activeMemory: StatusPickerMemory | null = null; let currentItems: PickerOptionItem[] = []; let activeItems: PickerOptionItem[] = []; let selectedIndex = 0; @@ -128,8 +137,10 @@ function finish(key: string | null): void { openDom = null; if (!dom) { return; } dom.host.remove(); + if (key !== null) { activeMemory?.record(key); } const resolve = resolveCurrent; resolveCurrent = null; + activeMemory = null; resolve?.({ key }); } @@ -162,7 +173,10 @@ export function openStatusPicker(config: StatusPickerConfig): Promise renderList(input.value)); diff --git a/src/webview/handlers/index.ts b/src/webview/handlers/index.ts index d22dbab..32d2c58 100644 --- a/src/webview/handlers/index.ts +++ b/src/webview/handlers/index.ts @@ -49,6 +49,21 @@ interface PendingPickerRequest { const pendingPickerRequests: Record = {}; 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(); return { @@ -80,6 +95,9 @@ function handlePickerOptions(data: any): void { items: data.items, anchor: pending.anchor, align: data.align === "topLeft" || data.align === "bottomLeft" || data.align === "bottomRight" ? data.align : "topRight", + memory: pending.kind === "model" + ? { list: getModelRecentKeys, record: recordModelRecentKey } + : undefined, }).then((result) => { if (result.key !== null) { window.__vscode.postMessage({ type: "applyPickerOption", kind: pending.kind, key: result.key }); diff --git a/src/webview/render/option-picker-helpers.ts b/src/webview/render/option-picker-helpers.ts index 530acb9..5790360 100644 --- a/src/webview/render/option-picker-helpers.ts +++ b/src/webview/render/option-picker-helpers.ts @@ -95,6 +95,18 @@ export function resolvePickerPlacement( }; } +/** Keep keys from `recent` first (in that order), then the rest unchanged. */ +export function orderItemsByRecent( + items: readonly PickerOptionItem[], + recent: readonly string[], +): PickerOptionItem[] { + const recentSet = new Set(recent); + const recentItems = recent + .map((key) => items.find((item) => item.key === key)) + .filter((item): item is PickerOptionItem => Boolean(item)); + return [...recentItems, ...items.filter((item) => !recentSet.has(item.key))]; +} + const THINKING_LEVELS: ReadonlyArray<{ key: string; description: string }> = [ { key: "off", description: "No thinking" }, { key: "minimal", description: "Minimal thinking" }, From 4625939816763d81bf8e2ccd46de3976b2935e71 Mon Sep 17 00:00:00 2001 From: auchan Date: Sun, 6 Sep 2026 20:39:27 +0800 Subject: [PATCH 6/7] fix: keep effective thinking level and list max --- src/pi-service.ts | 5 ++++- src/webview/render/option-picker-helpers.ts | 9 +++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/pi-service.ts b/src/pi-service.ts index 7ed5ad3..5ef5170 100644 --- a/src/pi-service.ts +++ b/src/pi-service.ts @@ -2521,8 +2521,11 @@ export class PiService { piWarn(`setThinkingLevel("${level}") ignored: session not initialized`); return; } + // The SDK clamps to the active model's available levels (e.g. xhigh may + // become max) and emits thinking_level_changed with the effective level, + // which keeps this._thinkingLevel accurate. Do not overwrite it with the + // requested value afterwards or the status bar will show a wrong level. this.session.setThinkingLevel(level); - this._thinkingLevel = level; this.reportStatus(); // session.setThinkingLevel() delegates persistence to the SDK SessionManager. } diff --git a/src/webview/render/option-picker-helpers.ts b/src/webview/render/option-picker-helpers.ts index 5790360..837dc1e 100644 --- a/src/webview/render/option-picker-helpers.ts +++ b/src/webview/render/option-picker-helpers.ts @@ -110,10 +110,11 @@ export function orderItemsByRecent( const THINKING_LEVELS: ReadonlyArray<{ key: string; description: string }> = [ { key: "off", description: "No thinking" }, { key: "minimal", description: "Minimal thinking" }, - { key: "low", description: "Brief thinking" }, - { key: "medium", description: "Balanced thinking" }, - { key: "high", description: "Extended thinking" }, - { key: "xhigh", description: "Maximum thinking" }, + { key: "low", description: "Light thinking" }, + { key: "medium", description: "Moderate thinking" }, + { key: "high", description: "Deep thinking" }, + { key: "xhigh", description: "Extra-high reasoning" }, + { key: "max", description: "Maximum reasoning" }, ]; export function buildThinkingOptions( From 58e3aeb028a8224687fecb087ef199ba3dd05a5c Mon Sep 17 00:00:00 2001 From: auchan Date: Sun, 6 Sep 2026 20:47:15 +0800 Subject: [PATCH 7/7] feat: list only model-supported thinking levels --- src/pi-service.ts | 20 +++++++++++++++++++- src/test/option-picker-helpers.test.ts | 11 +++++++++++ src/webview/render/option-picker-helpers.ts | 6 +++++- 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/pi-service.ts b/src/pi-service.ts index 5ef5170..9877ebc 100644 --- a/src/pi-service.ts +++ b/src/pi-service.ts @@ -2748,7 +2748,11 @@ export class PiService { Array<{ key: string; label: string; description?: string; icon?: string; selected?: boolean }> > { if (kind === "thinking") { - return buildThinkingOptions(this.thinkingLevel, this.getDefaultThinking()); + return buildThinkingOptions( + this.thinkingLevel, + this.getDefaultThinking(), + this.getSessionThinkingLevels() ?? undefined, + ); } if (kind === "effort") { return buildEffortOptions(this.effort || "auto"); @@ -2834,6 +2838,20 @@ export class PiService { } } + /** Thinking levels the current session/model actually supports, or null when + * the capability cannot be queried (callers then fall back to the full list). */ + private getSessionThinkingLevels(): string[] | null { + try { + const session = this.session as { getAvailableThinkingLevels?: () => unknown } | null; + const levels = session?.getAvailableThinkingLevels?.(); + return Array.isArray(levels) && levels.length > 0 + ? levels.filter((level): level is string => typeof level === "string") + : null; + } catch { + return null; + } + } + /** 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()}`; diff --git a/src/test/option-picker-helpers.test.ts b/src/test/option-picker-helpers.test.ts index 4395e2c..25a8629 100644 --- a/src/test/option-picker-helpers.test.ts +++ b/src/test/option-picker-helpers.test.ts @@ -95,6 +95,17 @@ suite("Option picker helpers", () => { assert.strictEqual(low?.selected, true); assert.strictEqual(low?.icon, "●"); assert.ok(off?.label.includes("★"), "default level is starred"); + assert.ok(options.some((option) => option.key === "xhigh")); + }); + + test("restricts thinking options to the model-supported levels", () => { + const options = buildThinkingOptions("medium", "off", ["off", "medium", "high", "max"]); + assert.deepStrictEqual( + options.map((option) => option.key), + ["off", "medium", "high", "max"], + ); + assert.ok(options.every((option) => option.key !== "xhigh")); + assert.strictEqual(options.find((option) => option.key === "medium")?.selected, true); }); test("builds effort and budget options with the current value marked", () => { diff --git a/src/webview/render/option-picker-helpers.ts b/src/webview/render/option-picker-helpers.ts index 837dc1e..eaee7d0 100644 --- a/src/webview/render/option-picker-helpers.ts +++ b/src/webview/render/option-picker-helpers.ts @@ -120,8 +120,12 @@ const THINKING_LEVELS: ReadonlyArray<{ key: string; description: string }> = [ export function buildThinkingOptions( current: string, defaultLevel: string | undefined, + available?: readonly string[], ): PickerOptionItem[] { - return THINKING_LEVELS.map((level) => ({ + const levels = available && available.length > 0 + ? THINKING_LEVELS.filter((level) => available.includes(level.key)) + : THINKING_LEVELS; + return levels.map((level) => ({ key: level.key, label: level.key === defaultLevel ? `${level.key} ★` : level.key, description: level.description,