From c3dbed67dc60136006a0382b7526bc0d22abc326 Mon Sep 17 00:00:00 2001 From: elkaix Date: Wed, 5 Aug 2026 22:59:32 -0400 Subject: [PATCH 1/2] fix(vscode): allow /yolo and /auto before the first message The permission commands take a control path to the extension host, which resolved the view's session and bailed when there was none. A session is created lazily by the first message, so the command failed with "Could not change the permission mode." every time it was used to set the mode up front. The request is now parked on the view and applied to the session that view opens next. Also brightens the dark-theme periwinkle accent used for inline code in chat. --- .changeset/vscode-inline-code-color.md | 5 +++ ...permission-command-before-first-message.md | 5 +++ apps/vscode/src/handlers/chat.handler.ts | 15 ++++++-- apps/vscode/src/handlers/slash-command.ts | 6 ++- apps/vscode/src/runtime/permission-mode.ts | 11 ++++++ apps/vscode/src/runtime/pythinker-runtime.ts | 38 +++++++++++++++++++ apps/vscode/test/pythinker-runtime.test.ts | 13 +++++++ apps/vscode/webview-ui/src/styles/index.css | 2 +- 8 files changed, 88 insertions(+), 7 deletions(-) create mode 100644 .changeset/vscode-inline-code-color.md create mode 100644 .changeset/vscode-permission-command-before-first-message.md diff --git a/.changeset/vscode-inline-code-color.md b/.changeset/vscode-inline-code-color.md new file mode 100644 index 00000000..c3f97911 --- /dev/null +++ b/.changeset/vscode-inline-code-color.md @@ -0,0 +1,5 @@ +--- +"@pythoughts/pythinker-code": patch +--- + +Brighten the periwinkle accent in the VS Code extension's dark theme so inline code in chat is easier to read. diff --git a/.changeset/vscode-permission-command-before-first-message.md b/.changeset/vscode-permission-command-before-first-message.md new file mode 100644 index 00000000..d0a1bdf9 --- /dev/null +++ b/.changeset/vscode-permission-command-before-first-message.md @@ -0,0 +1,5 @@ +--- +"@pythoughts/pythinker-code": patch +--- + +Let `/yolo` and `/auto` be used in the VS Code extension before the first message is sent — the request now applies to the session that chat opens next instead of failing with "Could not change the permission mode." diff --git a/apps/vscode/src/handlers/chat.handler.ts b/apps/vscode/src/handlers/chat.handler.ts index ba7bffa2..a3d416e8 100644 --- a/apps/vscode/src/handlers/chat.handler.ts +++ b/apps/vscode/src/handlers/chat.handler.ts @@ -6,6 +6,7 @@ import type { ApprovalResponse, ContentPart } from "../../shared/legacy-sdk"; import { getUserMessage } from "../../shared/errors"; import type { ErrorPhase } from "../../shared/types"; import { VSCodeSettings } from "../config/vscode-settings"; +import { defaultPermissionMode } from "../runtime/permission-mode"; import { normalizeEffort } from "../runtime/pythinker-runtime"; import type { SessionRuntime } from "../runtime/session-runtime"; import { isWorkspacePathContained, relativeWorkspacePath } from "../utils/workspace-path"; @@ -177,15 +178,21 @@ const setPlanMode: Handler<{ enabled: boolean }, { ok: boolean; planMode: boolea /** * `/yolo` and `/auto` are control commands, not turns: the webview sends them * here instead of through the chat queue so they still take effect while the - * agent is running — which is exactly when a pending approval blocks it. + * agent is running — which is exactly when a pending approval blocks it. Before + * the first message there is no session yet, so the request is parked on the + * view and applied to the session that view opens next. */ const setPermissionMode: Handler< { mode: "yolo" | "auto"; request: PermissionCommandRequest }, { ok: boolean; mode?: PermissionMode; message?: string } > = async (params, ctx) => { - const runtime = ctx.getSession(); - if (runtime === undefined) return { ok: false }; - const result = await applyPermissionCommand(runtime, params.mode, params.request); + const target = + ctx.getSession() ?? + ctx.runtime.pendingPermissionTarget( + ctx.webviewId, + defaultPermissionMode(VSCodeSettings.yoloMode), + ); + const result = await applyPermissionCommand(target, params.mode, params.request); return { ok: true, mode: result.mode, message: result.message }; }; diff --git a/apps/vscode/src/handlers/slash-command.ts b/apps/vscode/src/handlers/slash-command.ts index aad6a89f..807ca784 100644 --- a/apps/vscode/src/handlers/slash-command.ts +++ b/apps/vscode/src/handlers/slash-command.ts @@ -10,6 +10,7 @@ import { type SkillSummary, } from "@pythoughts/pythinker-code-sdk"; +import type { PermissionModeTarget } from "../runtime/permission-mode"; import type { SessionRuntime } from "../runtime/session-runtime"; import { buildExportMarkdown, @@ -167,10 +168,11 @@ export function parsePermissionCommandRequest(args: string): PermissionCommandRe /** * Applies a `/yolo` or `/auto` request and reports the resulting mode. Callers * own how the message is surfaced, so this runs identically whether the command - * came in between turns or mid-turn over the bridge. + * came in between turns, mid-turn over the bridge, or before the view has a + * session at all. */ export async function applyPermissionCommand( - runtime: SessionRuntime, + runtime: PermissionModeTarget, mode: "yolo" | "auto", request: PermissionCommandRequest, ): Promise<{ mode: PermissionMode; message: string }> { diff --git a/apps/vscode/src/runtime/permission-mode.ts b/apps/vscode/src/runtime/permission-mode.ts index 1b142b44..8f0bf8b2 100644 --- a/apps/vscode/src/runtime/permission-mode.ts +++ b/apps/vscode/src/runtime/permission-mode.ts @@ -39,6 +39,17 @@ export async function persistPermissionMode( }); } +/** + * What `/yolo` and `/auto` act on. A live session is the usual target, but the + * command also arrives before the view has one, so the pending target below + * satisfies the same shape. + */ +export interface PermissionModeTarget { + readonly permissionMode: PermissionMode; + setPermissionMode(mode: PermissionMode): Promise; + togglePermissionMode(mode: Exclude): Promise; +} + /** The `pythinker.yoloMode` setting seeds new sessions; it never overrides a stored mode. */ export function defaultPermissionMode(yoloModeSetting: boolean): PermissionMode { return yoloModeSetting ? "yolo" : "manual"; diff --git a/apps/vscode/src/runtime/pythinker-runtime.ts b/apps/vscode/src/runtime/pythinker-runtime.ts index 04340115..d4664f32 100644 --- a/apps/vscode/src/runtime/pythinker-runtime.ts +++ b/apps/vscode/src/runtime/pythinker-runtime.ts @@ -12,6 +12,7 @@ import { permissionModeMetadata, persistPermissionMode, readPermissionMode, + type PermissionModeTarget, } from "./permission-mode"; import { SessionRuntime } from "./session-runtime"; import { areSameFsPath } from "../utils/fs-path"; @@ -47,6 +48,7 @@ export class PythinkerRuntime { private readonly log: PythinkerRuntimeOptions["log"]; private readonly sessions = new Map(); private readonly sessionByView = new Map(); + private readonly pendingPermissionByView = new Map(); private closed = false; constructor(options: PythinkerRuntimeOptions) { @@ -74,6 +76,29 @@ export class PythinkerRuntime { return this.sessions.get(id); } + /** + * `/yolo` and `/auto` are usable before the view has opened a session — the + * first message is what creates one. The request is held per view and applied + * to the session that view opens next, so the command is never just lost. + */ + pendingPermissionTarget(webviewId: string, fallback: PermissionMode): PermissionModeTarget { + const pending = this.pendingPermissionByView; + const target: PermissionModeTarget = { + get permissionMode(): PermissionMode { + return pending.get(webviewId) ?? fallback; + }, + async setPermissionMode(mode: PermissionMode): Promise { + pending.set(webviewId, mode); + }, + async togglePermissionMode(mode: Exclude): Promise { + const next = target.permissionMode === mode ? "manual" : mode; + await target.setPermissionMode(next); + return next; + }, + }; + return target; + } + async openSession(options: OpenSessionOptions): Promise { this.ensureOpen(); const current = this.getSessionForView(options.webviewId); @@ -119,6 +144,7 @@ export class PythinkerRuntime { } } + await this.applyPendingPermissionMode(options.webviewId, runtime); runtime.subscribe(options.webviewId); this.sessionByView.set(options.webviewId, runtime.id); await runtime.announceStatus(options.webviewId); @@ -149,6 +175,7 @@ export class PythinkerRuntime { throw error; } } + await this.applyPendingPermissionMode(webviewId, runtime); runtime.subscribe(webviewId); this.sessionByView.set(webviewId, runtime.id); await runtime.announceStatus(webviewId); @@ -205,6 +232,17 @@ export class PythinkerRuntime { await this.harness.close(); } + /** Hands a `/yolo` or `/auto` issued before this view had a session to the session it just got. */ + private async applyPendingPermissionMode( + webviewId: string, + runtime: SessionRuntime, + ): Promise { + const pending = this.pendingPermissionByView.get(webviewId); + if (pending === undefined) return; + this.pendingPermissionByView.delete(webviewId); + await runtime.setPermissionMode(pending); + } + private wrapSession(session: Session, permissionMode: PermissionMode): SessionRuntime { const runtime = new SessionRuntime({ session, diff --git a/apps/vscode/test/pythinker-runtime.test.ts b/apps/vscode/test/pythinker-runtime.test.ts index 30567e79..66100e6c 100644 --- a/apps/vscode/test/pythinker-runtime.test.ts +++ b/apps/vscode/test/pythinker-runtime.test.ts @@ -480,6 +480,19 @@ describe("Pythinker runtime (owns shared SDK sessions for Webviews)", () => { await expect(opened.session.getStatus()).resolves.toMatchObject({ permission: "yolo" }); }); + it("applies a permission mode requested before the view had a session", async () => { + const { runtime } = createRuntime(); + const target = runtime.pendingPermissionTarget("view-1", "manual"); + + expect(await target.togglePermissionMode("yolo")).toBe("yolo"); + const opened = await runtime.openSession(openOptions({ webviewId: "view-1" })); + + expect(opened.permissionMode).toBe("yolo"); + await expect(opened.session.getStatus()).resolves.toMatchObject({ permission: "yolo" }); + // Consumed once: the next session opened by that view starts from its own mode. + expect(runtime.pendingPermissionTarget("view-1", "manual").permissionMode).toBe("manual"); + }); + it("persists a mode change so the next attach restores it", async () => { const { runtime, sdk } = createRuntime(); const session = sdk.addSession("saved-1", "/workspace", { permission: "manual" }); diff --git a/apps/vscode/webview-ui/src/styles/index.css b/apps/vscode/webview-ui/src/styles/index.css index 9a4a2a96..7f3f34aa 100644 --- a/apps/vscode/webview-ui/src/styles/index.css +++ b/apps/vscode/webview-ui/src/styles/index.css @@ -51,7 +51,7 @@ --accent-foreground: oklch(0.985 0 0); --destructive: oklch(0.704 0.191 22.216); /* Muted periwinkle accent (CLI primary), dark variant. */ - --brand: #aab3e8; + --brand: #b3b9f4; /* CLI darkColors.success — the "on" colour for toggles. */ --success: #4ec87e; --success-foreground: oklch(0.141 0.005 285.823); From 2f28751f0540e3d693dc38dab5903892d462fe35 Mon Sep 17 00:00:00 2001 From: elkaix Date: Wed, 5 Aug 2026 23:13:35 -0400 Subject: [PATCH 2/2] fix(vscode): keep a pending permission mode when applying it fails --- apps/vscode/src/runtime/pythinker-runtime.ts | 7 ++++++- apps/vscode/test/pythinker-runtime.test.ts | 16 ++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/apps/vscode/src/runtime/pythinker-runtime.ts b/apps/vscode/src/runtime/pythinker-runtime.ts index d4664f32..da3166e6 100644 --- a/apps/vscode/src/runtime/pythinker-runtime.ts +++ b/apps/vscode/src/runtime/pythinker-runtime.ts @@ -239,8 +239,13 @@ export class PythinkerRuntime { ): Promise { const pending = this.pendingPermissionByView.get(webviewId); if (pending === undefined) return; - this.pendingPermissionByView.delete(webviewId); await runtime.setPermissionMode(pending); + // Dropped only once it landed, and only if it is still the request in hand: + // a failed apply keeps the command for the next attempt, and a command that + // arrived during the await outranks the one just applied. + if (this.pendingPermissionByView.get(webviewId) === pending) { + this.pendingPermissionByView.delete(webviewId); + } } private wrapSession(session: Session, permissionMode: PermissionMode): SessionRuntime { diff --git a/apps/vscode/test/pythinker-runtime.test.ts b/apps/vscode/test/pythinker-runtime.test.ts index 66100e6c..bf88a854 100644 --- a/apps/vscode/test/pythinker-runtime.test.ts +++ b/apps/vscode/test/pythinker-runtime.test.ts @@ -493,6 +493,22 @@ describe("Pythinker runtime (owns shared SDK sessions for Webviews)", () => { expect(runtime.pendingPermissionTarget("view-1", "manual").permissionMode).toBe("manual"); }); + it("keeps a pending permission mode when applying it to the session fails", async () => { + const { runtime, sdk } = createRuntime(); + const boundary = sdk.addSession("saved-1", "/workspace"); + (boundary.session as { setPermission: (mode: PermissionMode) => Promise }).setPermission = + async () => { + throw new Error("engine offline"); + }; + await runtime.pendingPermissionTarget("view-1", "manual").setPermissionMode("yolo"); + + await expect( + runtime.openSession(openOptions({ webviewId: "view-1", sessionId: "saved-1" })), + ).rejects.toThrow("engine offline"); + + expect(runtime.pendingPermissionTarget("view-1", "manual").permissionMode).toBe("yolo"); + }); + it("persists a mode change so the next attach restores it", async () => { const { runtime, sdk } = createRuntime(); const session = sdk.addSession("saved-1", "/workspace", { permission: "manual" });