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..da3166e6 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,22 @@ 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; + 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 { 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..bf88a854 100644 --- a/apps/vscode/test/pythinker-runtime.test.ts +++ b/apps/vscode/test/pythinker-runtime.test.ts @@ -480,6 +480,35 @@ 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("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" }); 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);