diff --git a/media/style.css b/media/style.css index 4cd191d..3d7f7f7 100644 --- a/media/style.css +++ b/media/style.css @@ -3233,3 +3233,125 @@ 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; + 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-raised)); + 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 { + flex: 1 1 auto; + min-height: 0; + 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..9877ebc 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"; @@ -2516,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. } @@ -2731,6 +2739,156 @@ 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(), + this.getSessionThinkingLevels() ?? undefined, + ); + } + 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 this.askSaveAsDefault(`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 this.askSaveAsDefault("Save this model as default?"); + if (save) { this.saveDefaultModel(); } + } + } + + /** 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()}`; + 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 4038cc2..58e966f 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -380,6 +380,38 @@ 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(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() }), @@ -543,6 +575,21 @@ 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("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 new file mode 100644 index 0000000..25a8629 --- /dev/null +++ b/src/test/option-picker-helpers.test.ts @@ -0,0 +1,148 @@ +import * as assert from "node:assert"; +import { readFileSync } from "node:fs"; +import { + buildBudgetOptions, + buildEffortOptions, + buildThinkingOptions, + fuzzyFilterOptions, + orderItemsByRecent, + 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("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 }; + 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, 392); + + // 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("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"); + 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.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", () => { + 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..9739b15 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,52 @@ 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; + + 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/components/option-picker.ts b/src/webview/components/option-picker.ts new file mode 100644 index 0000000..84550fa --- /dev/null +++ b/src/webview/components/option-picker.ts @@ -0,0 +1,206 @@ +import { + fuzzyFilterOptions, + orderItemsByRecent, + 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; + /** 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 { + /** 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 activeMemory: StatusPickerMemory | 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(); + if (key !== null) { activeMemory?.record(key); } + const resolve = resolveCurrent; + resolveCurrent = null; + activeMemory = 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 }; + activeMemory = config.memory ?? null; + currentItems = activeMemory + ? orderItemsByRecent([...config.items], activeMemory.list()) + : [...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..32d2c58 100644 --- a/src/webview/handlers/index.ts +++ b/src/webview/handlers/index.ts @@ -39,6 +39,100 @@ 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 = {}; +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 { + 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; } + lastPickerAnchor = pending.anchor; + 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", + 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 }); + } + }); +} + +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, writeToolRenderer, editToolRenderer, readToolRenderer, @@ -213,6 +307,8 @@ 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 "picker-confirm": handlePickerConfirm(msg.data); break; case "revealEntry": handleRevealEntry(msg.entryId, msg.toolCallId); break; // Errors @@ -2187,17 +2283,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 +2303,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..eaee7d0 --- /dev/null +++ b/src/webview/render/option-picker-helpers.ts @@ -0,0 +1,179 @@ +/** + * 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 + 8; + if (align === "topLeft") { y = anchor.top - size.height - 8; } + if (align === "topRight") { + x = anchor.right - size.width; + y = anchor.top - size.height - 8; + } + 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)), + }; +} + +/** 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" }, + { 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( + current: string, + defaultLevel: string | undefined, + available?: readonly string[], +): PickerOptionItem[] { + 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, + 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`; +}