Skip to content

Commit 722c011

Browse files
committed
feat(vscode): show the permission mode in the chat
`/yolo` and `/auto` toggle when sent without an argument, and the chat never showed which mode was in effect. Sending `/yolo` to make sure YOLO was on turned it off instead, and nothing on screen said so — the next tool call asked for approval and the command looked broken rather than inverted. The host already fetched the mode for every status announce and dropped it before sending. It now rides along, a mode change announces itself to every attached view, and the composer carries a red badge for YOLO and AUTO in the same danger colour the terminal footer uses. Manual stays unlabelled.
1 parent 87567b7 commit 722c011

8 files changed

Lines changed: 95 additions & 4 deletions

File tree

apps/vscode/shared/legacy-sdk.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,11 +76,15 @@ export interface TokenUsage {
7676
input_cache_creation: number;
7777
}
7878

79+
export type PermissionMode = "manual" | "auto" | "yolo";
80+
7981
export interface StatusUpdate {
8082
context_usage?: number | null;
8183
token_usage?: TokenUsage | null;
8284
message_id?: string | null;
8385
plan_mode?: boolean | null;
86+
/** The live permission mode. Without it the chat cannot show which one is on. */
87+
permission?: PermissionMode | null;
8488
model?: string | null;
8589
thinking_effort?: string | null;
8690
retrying?: {

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,10 @@ export class SessionRuntime {
144144
await persistPermissionMode(this.session, mode);
145145
this.currentPermissionMode = mode;
146146
}
147+
// Tell every attached view at once. `/yolo` is a toggle, so a chat that
148+
// cannot see the mode it landed on is how a user turns YOLO off while
149+
// trying to turn it on.
150+
await Promise.all([...this.webviewIds].map((id) => this.announceStatus(id)));
147151
}
148152

149153
subscribe(webviewId: string): void {
@@ -168,6 +172,7 @@ export class SessionRuntime {
168172
model: status.model,
169173
thinking_effort: status.thinkingLevel,
170174
plan_mode: status.planMode,
175+
permission: status.permission,
171176
},
172177
_sessionId: this.id,
173178
},

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

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -392,7 +392,40 @@ describe("Pythinker runtime (owns shared SDK sessions for Webviews)", () => {
392392
event: Events.StreamEvent,
393393
data: {
394394
type: "StatusUpdate",
395-
payload: { model: "kimi-test", thinking_effort: "max", plan_mode: true },
395+
// The permission mode rides along: the chat badge is the only place the
396+
// user can see which mode a toggle command just landed on.
397+
payload: { model: "kimi-test", thinking_effort: "max", plan_mode: true, permission: "manual" },
398+
_sessionId: "saved-1",
399+
},
400+
webviewId: "view-1",
401+
});
402+
});
403+
404+
it("announces the new permission mode so the chat badge can show it", async () => {
405+
const sdk = createFakeHarness();
406+
const broadcasts: { event: string; data: unknown; webviewId?: string }[] = [];
407+
const runtime = new PythinkerRuntime({
408+
version: "0.6.0",
409+
harness: sdk.harness,
410+
broadcast: (event: string, data: unknown, webviewId?: string) => {
411+
broadcasts.push({ event, data, webviewId });
412+
},
413+
captureBaseline: () => undefined,
414+
log: () => undefined,
415+
});
416+
sdk.addSession("saved-1", "/workspace", { permission: "manual" });
417+
const opened = await runtime.openSession(openOptions({ sessionId: "saved-1" }));
418+
419+
broadcasts.length = 0;
420+
await opened.setPermissionMode("yolo");
421+
422+
// `/yolo` toggles, so a mode the chat cannot see is a command that silently
423+
// does the opposite of what the user meant.
424+
expect(broadcasts).toContainEqual({
425+
event: Events.StreamEvent,
426+
data: {
427+
type: "StatusUpdate",
428+
payload: { model: "kimi-test", thinking_effort: "off", plan_mode: false, permission: "yolo" },
396429
_sessionId: "saved-1",
397430
},
398431
webviewId: "view-1",
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
2+
import type { PermissionMode } from "shared/legacy-sdk";
3+
4+
const LABELS: Partial<Record<PermissionMode, { text: string; hint: string }>> = {
5+
yolo: {
6+
text: "YOLO",
7+
hint: "Tool actions are auto-approved; the agent may still ask questions. Send /yolo off to stop.",
8+
},
9+
auto: {
10+
text: "AUTO",
11+
hint: "Fully autonomous; the agent will not ask questions. Send /auto off to stop.",
12+
},
13+
};
14+
15+
/**
16+
* Shows the permission mode whenever it is not the default.
17+
*
18+
* `/yolo` and `/auto` toggle when sent without an argument, so a chat that
19+
* never showed the mode let the same command mean "on" or "off" depending on
20+
* state nobody could see — sending `/yolo` to be sure it was on turned it off.
21+
* The terminal has always shown this; the red matches its danger row.
22+
*/
23+
export function PermissionModeBadge({ mode }: { mode: PermissionMode }) {
24+
const label = LABELS[mode];
25+
if (label === undefined) return null;
26+
27+
return (
28+
<Tooltip>
29+
<TooltipTrigger asChild>
30+
<span className="inline-flex items-center rounded border border-destructive/40 bg-destructive/10 px-1.5 py-0.5 text-[10px] font-semibold tracking-wide text-destructive select-none">
31+
{label.text}
32+
</span>
33+
</TooltipTrigger>
34+
<TooltipContent>{label.hint}</TooltipContent>
35+
</Tooltip>
36+
);
37+
}

apps/vscode/webview-ui/src/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,5 +24,6 @@ export { InlineError } from "./InlineError";
2424
export { QuestionDialog } from "./QuestionDialog";
2525
export { PlanCard } from "./PlanCard";
2626
export { PlanModeButton } from "./PlanModeButton";
27+
export { PermissionModeBadge } from "./PermissionModeBadge";
2728
export { BrailleSpinner } from "./BrailleSpinner";
2829
export { SilverSpinner } from "./SilverSpinner";

apps/vscode/webview-ui/src/components/inputarea/InputArea.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { BottomToolbar } from "../BottomToolbar";
2020
import { StreamingConfirmDialog } from "../StreamingConfirmDialog";
2121
import { ThinkingButton } from "../ThinkingButton";
2222
import { PlanModeButton } from "../PlanModeButton";
23+
import { PermissionModeBadge } from "../PermissionModeBadge";
2324
import {
2425
getModelById,
2526
getMediaFallbackModel,
@@ -53,7 +54,7 @@ export function InputArea({ onAuthAction }: InputAreaProps) {
5354
const [cursorPos, setCursorPos] = useState(0);
5455
const [previewMedia, setPreviewMedia] = useState<string | null>(null);
5556

56-
const { isStreaming, sendMessage, abort, draftMedia, removeDraftMedia, hasProcessingMedia, getMediaInConversation, pendingInput, planMode, messages } = useChatStore();
57+
const { isStreaming, sendMessage, abort, draftMedia, removeDraftMedia, hasProcessingMedia, getMediaInConversation, pendingInput, planMode, permissionMode, messages } = useChatStore();
5758
const { currentModel, thinkingEffort, updateModel, toggleThinking, selectThinkingEffort, models, extensionConfig, getCurrentThinkingMode } = useSettingsStore();
5859

5960
const isProcessing = hasProcessingMedia();
@@ -483,6 +484,7 @@ export function InputArea({ onAuthAction }: InputAreaProps) {
483484
onSelectEffort={selectThinkingEffort}
484485
/>
485486
<PlanModeButton active={planMode} onToggle={handleTogglePlanMode} />
487+
<PermissionModeBadge mode={permissionMode} />
486488
</div>
487489

488490
<div className="flex items-center gap-2 shrink-0">

apps/vscode/webview-ui/src/stores/chat.store.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { toast } from "@/components/ui/sonner";
77

88
import { useSettingsStore } from "./settings.store";
99
import { processEvent } from "./event-handlers";
10-
import type { StatusUpdate, ContentPart, QuestionRequest, ToolResult } from "shared/legacy-sdk";
10+
import type { StatusUpdate, ContentPart, PermissionMode, QuestionRequest, ToolResult } from "shared/legacy-sdk";
1111
import type { UIStreamEvent } from "shared/types";
1212

1313
const HANDSHAKE_TIMEOUT_MS = 30_000;
@@ -122,6 +122,8 @@ export interface ChatState {
122122
queue: QueuedItem[];
123123
pendingQuestion: QuestionRequest | null;
124124
planMode: boolean;
125+
/** The mode the engine is actually in, as last reported by the host. */
126+
permissionMode: PermissionMode;
125127

126128
sendMessage: (text: string) => void;
127129
retryLastMessage: () => void;
@@ -249,6 +251,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
249251
queue: [],
250252
pendingQuestion: null,
251253
planMode: false,
254+
permissionMode: "manual",
252255

253256
sendMessage: (text) => {
254257
const { draftMedia, isStreaming } = get();
@@ -375,6 +378,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
375378
queue: [],
376379
pendingQuestion: null,
377380
planMode: false,
381+
permissionMode: "manual",
378382
});
379383
useApprovalStore.getState().clearRequests();
380384

@@ -429,6 +433,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
429433
queue: [],
430434
pendingQuestion: null,
431435
planMode: false,
436+
permissionMode: "manual",
432437
});
433438
useApprovalStore.getState().clearRequests();
434439
},

apps/vscode/webview-ui/src/stores/event-handlers.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -637,7 +637,7 @@ const eventHandlers: Record<string, EventHandler> = {
637637
},
638638

639639
StatusUpdate: (draft, payload) => {
640-
const { context_usage, token_usage, plan_mode, model, thinking_effort, retrying } = payload;
640+
const { context_usage, token_usage, plan_mode, permission, model, thinking_effort, retrying } = payload;
641641

642642
if (typeof model === "string" && model.length > 0) {
643643
useSettingsStore.getState().setCurrentModel(model);
@@ -650,6 +650,10 @@ const eventHandlers: Record<string, EventHandler> = {
650650
draft.planMode = plan_mode;
651651
}
652652

653+
if (permission !== undefined && permission !== null) {
654+
draft.permissionMode = permission;
655+
}
656+
653657
if (token_usage) {
654658
addTokenUsage(draft.activeTokenUsage, {
655659
input_other: token_usage.input_other || 0,

0 commit comments

Comments
 (0)