Skip to content

Commit c3dbed6

Browse files
committed
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.
1 parent 463b176 commit c3dbed6

8 files changed

Lines changed: 88 additions & 7 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pythoughts/pythinker-code": patch
3+
---
4+
5+
Brighten the periwinkle accent in the VS Code extension's dark theme so inline code in chat is easier to read.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pythoughts/pythinker-code": patch
3+
---
4+
5+
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."

apps/vscode/src/handlers/chat.handler.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { ApprovalResponse, ContentPart } from "../../shared/legacy-sdk";
66
import { getUserMessage } from "../../shared/errors";
77
import type { ErrorPhase } from "../../shared/types";
88
import { VSCodeSettings } from "../config/vscode-settings";
9+
import { defaultPermissionMode } from "../runtime/permission-mode";
910
import { normalizeEffort } from "../runtime/pythinker-runtime";
1011
import type { SessionRuntime } from "../runtime/session-runtime";
1112
import { isWorkspacePathContained, relativeWorkspacePath } from "../utils/workspace-path";
@@ -177,15 +178,21 @@ const setPlanMode: Handler<{ enabled: boolean }, { ok: boolean; planMode: boolea
177178
/**
178179
* `/yolo` and `/auto` are control commands, not turns: the webview sends them
179180
* here instead of through the chat queue so they still take effect while the
180-
* agent is running — which is exactly when a pending approval blocks it.
181+
* agent is running — which is exactly when a pending approval blocks it. Before
182+
* the first message there is no session yet, so the request is parked on the
183+
* view and applied to the session that view opens next.
181184
*/
182185
const setPermissionMode: Handler<
183186
{ mode: "yolo" | "auto"; request: PermissionCommandRequest },
184187
{ ok: boolean; mode?: PermissionMode; message?: string }
185188
> = async (params, ctx) => {
186-
const runtime = ctx.getSession();
187-
if (runtime === undefined) return { ok: false };
188-
const result = await applyPermissionCommand(runtime, params.mode, params.request);
189+
const target =
190+
ctx.getSession() ??
191+
ctx.runtime.pendingPermissionTarget(
192+
ctx.webviewId,
193+
defaultPermissionMode(VSCodeSettings.yoloMode),
194+
);
195+
const result = await applyPermissionCommand(target, params.mode, params.request);
189196
return { ok: true, mode: result.mode, message: result.message };
190197
};
191198

apps/vscode/src/handlers/slash-command.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
type SkillSummary,
1111
} from "@pythoughts/pythinker-code-sdk";
1212

13+
import type { PermissionModeTarget } from "../runtime/permission-mode";
1314
import type { SessionRuntime } from "../runtime/session-runtime";
1415
import {
1516
buildExportMarkdown,
@@ -167,10 +168,11 @@ export function parsePermissionCommandRequest(args: string): PermissionCommandRe
167168
/**
168169
* Applies a `/yolo` or `/auto` request and reports the resulting mode. Callers
169170
* own how the message is surfaced, so this runs identically whether the command
170-
* came in between turns or mid-turn over the bridge.
171+
* came in between turns, mid-turn over the bridge, or before the view has a
172+
* session at all.
171173
*/
172174
export async function applyPermissionCommand(
173-
runtime: SessionRuntime,
175+
runtime: PermissionModeTarget,
174176
mode: "yolo" | "auto",
175177
request: PermissionCommandRequest,
176178
): Promise<{ mode: PermissionMode; message: string }> {

apps/vscode/src/runtime/permission-mode.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,17 @@ export async function persistPermissionMode(
3939
});
4040
}
4141

42+
/**
43+
* What `/yolo` and `/auto` act on. A live session is the usual target, but the
44+
* command also arrives before the view has one, so the pending target below
45+
* satisfies the same shape.
46+
*/
47+
export interface PermissionModeTarget {
48+
readonly permissionMode: PermissionMode;
49+
setPermissionMode(mode: PermissionMode): Promise<void>;
50+
togglePermissionMode(mode: Exclude<PermissionMode, "manual">): Promise<PermissionMode>;
51+
}
52+
4253
/** The `pythinker.yoloMode` setting seeds new sessions; it never overrides a stored mode. */
4354
export function defaultPermissionMode(yoloModeSetting: boolean): PermissionMode {
4455
return yoloModeSetting ? "yolo" : "manual";

apps/vscode/src/runtime/pythinker-runtime.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
permissionModeMetadata,
1313
persistPermissionMode,
1414
readPermissionMode,
15+
type PermissionModeTarget,
1516
} from "./permission-mode";
1617
import { SessionRuntime } from "./session-runtime";
1718
import { areSameFsPath } from "../utils/fs-path";
@@ -47,6 +48,7 @@ export class PythinkerRuntime {
4748
private readonly log: PythinkerRuntimeOptions["log"];
4849
private readonly sessions = new Map<string, SessionRuntime>();
4950
private readonly sessionByView = new Map<string, string>();
51+
private readonly pendingPermissionByView = new Map<string, PermissionMode>();
5052
private closed = false;
5153

5254
constructor(options: PythinkerRuntimeOptions) {
@@ -74,6 +76,29 @@ export class PythinkerRuntime {
7476
return this.sessions.get(id);
7577
}
7678

79+
/**
80+
* `/yolo` and `/auto` are usable before the view has opened a session — the
81+
* first message is what creates one. The request is held per view and applied
82+
* to the session that view opens next, so the command is never just lost.
83+
*/
84+
pendingPermissionTarget(webviewId: string, fallback: PermissionMode): PermissionModeTarget {
85+
const pending = this.pendingPermissionByView;
86+
const target: PermissionModeTarget = {
87+
get permissionMode(): PermissionMode {
88+
return pending.get(webviewId) ?? fallback;
89+
},
90+
async setPermissionMode(mode: PermissionMode): Promise<void> {
91+
pending.set(webviewId, mode);
92+
},
93+
async togglePermissionMode(mode: Exclude<PermissionMode, "manual">): Promise<PermissionMode> {
94+
const next = target.permissionMode === mode ? "manual" : mode;
95+
await target.setPermissionMode(next);
96+
return next;
97+
},
98+
};
99+
return target;
100+
}
101+
77102
async openSession(options: OpenSessionOptions): Promise<SessionRuntime> {
78103
this.ensureOpen();
79104
const current = this.getSessionForView(options.webviewId);
@@ -119,6 +144,7 @@ export class PythinkerRuntime {
119144
}
120145
}
121146

147+
await this.applyPendingPermissionMode(options.webviewId, runtime);
122148
runtime.subscribe(options.webviewId);
123149
this.sessionByView.set(options.webviewId, runtime.id);
124150
await runtime.announceStatus(options.webviewId);
@@ -149,6 +175,7 @@ export class PythinkerRuntime {
149175
throw error;
150176
}
151177
}
178+
await this.applyPendingPermissionMode(webviewId, runtime);
152179
runtime.subscribe(webviewId);
153180
this.sessionByView.set(webviewId, runtime.id);
154181
await runtime.announceStatus(webviewId);
@@ -205,6 +232,17 @@ export class PythinkerRuntime {
205232
await this.harness.close();
206233
}
207234

235+
/** Hands a `/yolo` or `/auto` issued before this view had a session to the session it just got. */
236+
private async applyPendingPermissionMode(
237+
webviewId: string,
238+
runtime: SessionRuntime,
239+
): Promise<void> {
240+
const pending = this.pendingPermissionByView.get(webviewId);
241+
if (pending === undefined) return;
242+
this.pendingPermissionByView.delete(webviewId);
243+
await runtime.setPermissionMode(pending);
244+
}
245+
208246
private wrapSession(session: Session, permissionMode: PermissionMode): SessionRuntime {
209247
const runtime = new SessionRuntime({
210248
session,

apps/vscode/test/pythinker-runtime.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,19 @@ describe("Pythinker runtime (owns shared SDK sessions for Webviews)", () => {
480480
await expect(opened.session.getStatus()).resolves.toMatchObject({ permission: "yolo" });
481481
});
482482

483+
it("applies a permission mode requested before the view had a session", async () => {
484+
const { runtime } = createRuntime();
485+
const target = runtime.pendingPermissionTarget("view-1", "manual");
486+
487+
expect(await target.togglePermissionMode("yolo")).toBe("yolo");
488+
const opened = await runtime.openSession(openOptions({ webviewId: "view-1" }));
489+
490+
expect(opened.permissionMode).toBe("yolo");
491+
await expect(opened.session.getStatus()).resolves.toMatchObject({ permission: "yolo" });
492+
// Consumed once: the next session opened by that view starts from its own mode.
493+
expect(runtime.pendingPermissionTarget("view-1", "manual").permissionMode).toBe("manual");
494+
});
495+
483496
it("persists a mode change so the next attach restores it", async () => {
484497
const { runtime, sdk } = createRuntime();
485498
const session = sdk.addSession("saved-1", "/workspace", { permission: "manual" });

apps/vscode/webview-ui/src/styles/index.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@
5151
--accent-foreground: oklch(0.985 0 0);
5252
--destructive: oklch(0.704 0.191 22.216);
5353
/* Muted periwinkle accent (CLI primary), dark variant. */
54-
--brand: #aab3e8;
54+
--brand: #b3b9f4;
5555
/* CLI darkColors.success — the "on" colour for toggles. */
5656
--success: #4ec87e;
5757
--success-foreground: oklch(0.141 0.005 285.823);

0 commit comments

Comments
 (0)