Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions media/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -3313,6 +3313,39 @@ html.pi-viewport-recovering #pi-web-app {
cursor: pointer;
}

.option-picker-set-default,
.option-picker-clear-default {
margin-left: auto;
padding: 0 2px;
border: 0;
background: transparent;
font-size: 13px;
line-height: 1;
cursor: pointer;
}

.option-picker-set-default {
color: var(--pi-muted);
opacity: 0;
}

.option-picker-row:hover .option-picker-set-default,
.option-picker-row:focus-within .option-picker-set-default {
opacity: 1;
}

.option-picker-clear-default {
color: var(--pi-lavender);
}

.option-picker-set-default:hover,
.option-picker-set-default:focus-visible,
.option-picker-clear-default:hover,
.option-picker-clear-default:focus-visible {
color: var(--pi-lavender);
outline: none;
}

.option-picker-row.selected,
.option-picker-row:hover {
background: var(--vscode-list-hoverBackground, var(--pi-hover));
Expand Down
96 changes: 41 additions & 55 deletions src/pi-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2532,21 +2532,38 @@ export class PiService {

// ── Default model / thinking persistence ──────────────

/** Save a specific model as the default for future sessions. */
setDefaultModel(provider: string, modelId: string): void {
const cfg = vscode.workspace.getConfiguration("pi-on-code");
cfg.update("defaultModelProvider", provider, vscode.ConfigurationTarget.Global);
cfg.update("defaultModelId", modelId, vscode.ConfigurationTarget.Global);
}

/** Set the default thinking level for future sessions. */
setDefaultThinking(level: string): void {
const cfg = vscode.workspace.getConfiguration("pi-on-code");
cfg.update("defaultThinkingLevel", level, vscode.ConfigurationTarget.Global);
}

/** Clear the configured default model (empty values resolve to none). */
clearDefaultModel(): void {
const cfg = vscode.workspace.getConfiguration("pi-on-code");
cfg.update("defaultModelProvider", "", vscode.ConfigurationTarget.Global);
cfg.update("defaultModelId", "", vscode.ConfigurationTarget.Global);
}

/** Save the current model as the default for future sessions. */
saveDefaultModel(): void {
if (!this._model?.provider || !this._model?.id) {
piWarn("saveDefaultModel() called but no model is active — ignoring");
return;
}
const cfg = vscode.workspace.getConfiguration("pi-on-code");
cfg.update("defaultModelProvider", this._model.provider, vscode.ConfigurationTarget.Global);
cfg.update("defaultModelId", this._model.id, vscode.ConfigurationTarget.Global);
this.setDefaultModel(this._model.provider, this._model.id);
}

/** Save the current thinking level as the default for future sessions. */
saveDefaultThinking(): void {
const cfg = vscode.workspace.getConfiguration("pi-on-code");
cfg.update("defaultThinkingLevel", this._thinkingLevel, vscode.ConfigurationTarget.Global);
this.setDefaultThinking(this._thinkingLevel);
}

/** Get the configured default model (if any). */
Expand Down Expand Up @@ -2797,16 +2814,32 @@ export class PiService {
: undefined;
return {
key: `${m.provider}::${m.modelId}`,
label: isDefault ? `${m.label} ★` : m.label,
label: m.label,
description: detail ? `${m.provider} · ${detail}` : m.provider,
icon: m.modelId === currentId ? "●" : undefined,
selected: m.modelId === currentId,
isDefault,
};
});
}

/** Apply a selection from the reusable webview status picker. */
async applyStatusPickerOption(kind: "model" | "thinking" | "effort" | "budget", key: string): Promise<void> {
/** Apply a selection from the reusable webview status picker. When asDefault
* is set, persist that option as the default without changing the active one. */
async applyStatusPickerOption(kind: "model" | "thinking" | "effort" | "budget", key: string, asDefault?: "set" | "clear"): Promise<void> {
if (asDefault) {
if (kind === "model") {
const separator = key.lastIndexOf("::");
if (separator <= 0 || separator >= key.length - 2) { return; }
if (asDefault === "set") {
this.setDefaultModel(key.slice(0, separator), key.slice(separator + 2));
} else if (asDefault === "clear") {
this.clearDefaultModel();
}
} else if (kind === "thinking" && asDefault === "set") {
this.setDefaultThinking(key);
}
return;
}
if (kind === "effort") {
await this.setEffort(key);
return;
Expand All @@ -2818,10 +2851,6 @@ export class PiService {
}
if (kind === "thinking") {
await this.setThinkingLevel(key);
if (key !== this.getDefaultThinking()) {
const save = await this.askSaveAsDefault(`Use "${key}" thinking as the default?`);
if (save) { this.saveDefaultThinking(); }
}
return;
}
// model
Expand All @@ -2830,12 +2859,6 @@ export class PiService {
const provider = key.slice(0, separator);
const modelId = key.slice(separator + 2);
await this.setModel(provider, modelId);
const defModel = this.getDefaultModel();
const isDefault = Boolean(defModel && provider === defModel.provider && modelId === defModel.id);
if (!isDefault) {
const save = await this.askSaveAsDefault("Save this model as default?");
if (save) { this.saveDefaultModel(); }
}
}

/** Thinking levels the current session/model actually supports, or null when
Expand All @@ -2852,43 +2875,6 @@ export class PiService {
}
}

/** Ask the Webview (via the custom picker) whether to save the selection as default. */
private async askSaveAsDefault(prompt: string): Promise<boolean> {
const requestId = `confirm-${++this.pickerConfirmSequence}-${Date.now()}`;
return new Promise<boolean>((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<string, (save: boolean) => void>();
private pickerConfirmSequence = 0;

emitSettings(): void {
this.emit({
type: "settings-update",
Expand Down
20 changes: 2 additions & 18 deletions src/shared/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -396,19 +396,7 @@ const ExtensionToWebviewSchema = z.discriminatedUnion("type", [
icon: z.string().optional(),
selected: z.boolean().optional(),
})).max(5000),
}),
}),
z.object({
type: z.literal("picker-confirm"),
data: z.object({
requestId: z.string().min(1).max(64),
prompt: z.string(),
align: z.enum(["topLeft", "topRight", "bottomLeft", "bottomRight"]).optional(),
options: z.array(z.object({
key: z.string(),
label: z.string(),
description: z.string().optional(),
})),
recent: z.array(z.string().min(1)).max(64).optional(),
}),
}),

Expand Down Expand Up @@ -584,11 +572,7 @@ const WebviewToExtensionSchema = z.discriminatedUnion("type", [
type: z.literal("applyPickerOption"),
kind: z.enum(["model", "thinking", "effort", "budget"]),
key: z.string().min(1).max(512),
}),
z.object({
type: z.literal("picker-confirm-result"),
requestId: z.string().min(1).max(64),
key: z.string().min(1).max(64),
asDefault: z.enum(["set", "clear"]).optional(),
}),
z.object({ type: z.literal("resolveLocalImage"), path: z.string().min(1).max(4096), requestId: z.string().min(1).max(100) }),
z.object({ type: z.literal("promoteToSteer"), text: z.string() }),
Expand Down
8 changes: 6 additions & 2 deletions src/test/option-picker-helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,8 @@ suite("Option picker helpers", () => {
const off = options.find((option) => option.key === "off");
assert.strictEqual(low?.selected, true);
assert.strictEqual(low?.icon, "●");
assert.ok(off?.label.includes("★"), "default level is starred");
assert.strictEqual(off?.isDefault, true);
assert.ok(!(off?.label.includes("★") ?? false));
assert.ok(options.some((option) => option.key === "xhigh"));
});

Expand Down Expand Up @@ -139,7 +140,10 @@ suite("Option picker helpers", () => {
assert.match(panel, /case "requestPickerOptions"/);
assert.match(panel, /case "applyPickerOption"/);
assert.match(panel, /buildStatusPickerOptions\(message\.kind\)/);
assert.match(panel, /applyStatusPickerOption\(message\.kind, message\.key\)/);
assert.match(panel, /this\.piService\.applyStatusPickerOption\(/);
assert.match(panel, /message\.kind,/);
assert.match(panel, /message\.key,/);
assert.match(panel, /message\.asDefault === "set" \|\| message\.asDefault === "clear"/);

const service = readFileSync(new URL("../../src/pi-service.ts", import.meta.url), "utf8");
assert.match(service, /async buildStatusPickerOptions/);
Expand Down
27 changes: 20 additions & 7 deletions src/webview-panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ function isStatusPickerKind(value: unknown): value is StatusPickerKind {
}

export class PiWebviewPanel {
private static readonly MODEL_RECENT_KEY = "pi.modelRecent";
private panel: vscode.WebviewPanel | null = null;
private piService: PiService;
private disposables: vscode.Disposable[] = [];
Expand Down Expand Up @@ -758,6 +759,7 @@ export class PiWebviewPanel {
placeholder: meta.placeholder,
align: "topRight",
items,
recent: message.kind === "model" ? this.readModelRecent() : undefined,
},
});
} catch (error: unknown) {
Expand All @@ -773,7 +775,14 @@ export class PiWebviewPanel {
case "applyPickerOption":
if (isStatusPickerKind(message.kind) && typeof message.key === "string") {
try {
await this.piService.applyStatusPickerOption(message.kind, message.key);
await this.piService.applyStatusPickerOption(
message.kind,
message.key,
message.asDefault === "set" || message.asDefault === "clear" ? message.asDefault : undefined,
);
if (message.kind === "model" && !message.asDefault) {
this.recordModelRecent(message.key);
}
} catch (error: unknown) {
this.postMessage({
type: "error",
Expand All @@ -783,12 +792,6 @@ export class PiWebviewPanel {
}
break;

case "picker-confirm-result":
if (typeof message.requestId === "string" && typeof message.key === "string") {
this.piService.resolvePickerConfirm(message.requestId, message.key);
}
break;

// Request settings state (#2, #8)
case "open-session":
if (typeof message.sessionId === "string") {
Expand Down Expand Up @@ -840,6 +843,16 @@ export class PiWebviewPanel {
);
}

private readModelRecent(): string[] {
const raw = this.context.workspaceState.get<unknown>(PiWebviewPanel.MODEL_RECENT_KEY);
return Array.isArray(raw) ? raw.filter((key): key is string => typeof key === "string") : [];
}

private recordModelRecent(key: string): void {
const next = [key, ...this.readModelRecent().filter((candidate) => candidate !== key)].slice(0, 8);
void this.context.workspaceState.update(PiWebviewPanel.MODEL_RECENT_KEY, next);
}

private async resolveLocalImage(href: string, requestId: string): Promise<void> {
const bases = [
getWorkspaceCwd(),
Expand Down
Loading
Loading