From 6ac544e952b8e62cb5956f6408a056266d34a285 Mon Sep 17 00:00:00 2001 From: Duang Cheng Date: Wed, 29 Apr 2026 17:08:55 +0800 Subject: [PATCH 1/4] fix(claude): use native bypass permissions Route Claude bypass mode through the Claude Agent SDK permission mode so it matches Claude Code and no longer relies on a Copilot-style autopilot shim. Co-Authored-By: Claude Opus 4.7 --- electron/main/engines/claude/index.ts | 131 +++++++++++------- src/components/PromptInput.tsx | 10 +- src/pages/Chat.tsx | 2 +- .../electron/engines/claude/index.test.ts | 88 +++++++++++- 4 files changed, 177 insertions(+), 54 deletions(-) diff --git a/electron/main/engines/claude/index.ts b/electron/main/engines/claude/index.ts index f59708f..dfc69d3 100644 --- a/electron/main/engines/claude/index.ts +++ b/electron/main/engines/claude/index.ts @@ -22,6 +22,7 @@ import type { CanUseTool, PermissionResult, PermissionUpdate, + PermissionMode, ModelInfo as ClaudeModelInfo, } from "@anthropic-ai/claude-agent-sdk"; @@ -79,7 +80,8 @@ interface V2SessionInfo { createdAt: number; lastUsedAt: number; capturedSessionId?: string; // CC's internal session ID from system init message - permissionMode?: "default" | "plan" | "acceptEdits" | "dontAsk"; + permissionMode?: PermissionMode; + allowDangerouslySkipPermissions?: boolean; } // ============================================================================ @@ -125,10 +127,29 @@ interface PendingQuestion { const DEFAULT_MODES: AgentMode[] = [ { id: "default", label: "Default", description: "Standard behavior, prompts for dangerous operations" }, { id: "acceptEdits", label: "Auto-Accept", description: "Auto-accept file edit operations" }, - { id: "autopilot", label: "Autopilot", description: "Fully autonomous, skip all permission prompts" }, + { id: "bypassPermissions", label: "Bypass Permissions", description: "Bypass all permission checks" }, { id: "plan", label: "Plan", description: "Planning mode, no actual tool execution" }, ]; +const CLAUDE_PERMISSION_MODES = new Set([ + "default", + "acceptEdits", + "bypassPermissions", + "plan", + "dontAsk", + "auto", +]); + +function toClaudePermissionMode(mode: string | undefined): PermissionMode { + return mode && CLAUDE_PERMISSION_MODES.has(mode as PermissionMode) + ? (mode as PermissionMode) + : "default"; +} + +function allowsDangerouslySkipPermissions(mode: PermissionMode): boolean { + return mode === "bypassPermissions"; +} + function getDefaultClaudeReasoningEffort( supportedReasoningEfforts: ReasoningEffort[] | undefined, ): ReasoningEffort | undefined { @@ -211,7 +232,7 @@ export class ClaudeCodeAdapter extends EngineAdapter { private authMessage: string | undefined; private currentModelId: string | null = null; private cachedModels: UnifiedModelInfo[] = []; - private sessionModes = new Map(); + private sessionModes = new Map(); private sessionReasoningEfforts = new Map(); // --- Session directory cache (used instead of external store lookups) --- @@ -777,10 +798,8 @@ export class ClaudeCodeAdapter extends EngineAdapter { }; this.emit("message.updated", { sessionId, message: assistantMessage }); - // Determine permission mode from mode option - // "autopilot" is handled in canUseTool; SDK uses "default" permissionMode - const mode = options?.mode ?? this.sessionModes.get(sessionId) ?? "default"; - const permissionMode = (mode === "autopilot" ? "default" : mode) as "default" | "plan" | "acceptEdits" | "dontAsk"; + const permissionMode = toClaudePermissionMode(options?.mode ?? this.sessionModes.get(sessionId)); + this.sessionModes.set(sessionId, permissionMode); // Apply reasoning effort if it changed (triggers session rebuild via getOrCreateV2Session) if (options?.reasoningEffort !== undefined) { @@ -1209,25 +1228,34 @@ export class ClaudeCodeAdapter extends EngineAdapter { } async setMode(sessionId: string, modeId: string): Promise { - this.sessionModes.set(sessionId, modeId); - claudeLog.info(`[Claude][${sessionId}] Mode set to: ${modeId}`); - - // Map codemux mode → SDK permissionMode - // "autopilot" is handled in canUseTool (auto-approve), SDK stays on "default" - const sdkPermissionMode = modeId === "autopilot" ? "default" : modeId; + const permissionMode = toClaudePermissionMode(modeId); + this.sessionModes.set(sessionId, permissionMode); + claudeLog.info(`[Claude][${sessionId}] Mode set to: ${permissionMode}`); const v2Info = this.v2Sessions.get(sessionId); if (v2Info) { - // Update tracked permissionMode - v2Info.permissionMode = sdkPermissionMode as any; + if (allowsDangerouslySkipPermissions(permissionMode) && !v2Info.allowDangerouslySkipPermissions) { + if (v2Info.capturedSessionId) { + this.sessionCcIds.set(sessionId, v2Info.capturedSessionId); + } + try { + v2Info.session.close(); + } catch { + // Ignore + } + this.v2Sessions.delete(sessionId); + return; + } + + v2Info.permissionMode = permissionMode; // Use the internal Query API to switch permission mode at runtime // (same pattern as cancelMessage's interrupt() call) try { const query = (v2Info.session as any).query; if (query && typeof query.setPermissionMode === "function") { - await query.setPermissionMode(sdkPermissionMode); - claudeLog.info(`[Claude][${sessionId}] Permission mode switched to: ${sdkPermissionMode}`); + await query.setPermissionMode(permissionMode); + claudeLog.info(`[Claude][${sessionId}] Permission mode switched to: ${permissionMode}`); } else { claudeLog.warn(`[Claude][${sessionId}] setPermissionMode not available on query, will apply on next message`); } @@ -1320,10 +1348,7 @@ export class ClaudeCodeAdapter extends EngineAdapter { /** * Create a canUseTool callback bound to a specific codemux session ID. - * This callback is invoked by the SDK before each tool execution. - * - * - Autopilot mode: auto-approve everything - * - Default/acceptEdits: emit UnifiedPermission for user approval + * This callback is invoked by the SDK when Claude Code needs a permission reply. */ private createCanUseTool(sessionId: string): CanUseTool { return async ( @@ -1346,13 +1371,6 @@ export class ClaudeCodeAdapter extends EngineAdapter { return this.handleExitPlanMode(sessionId, input, options); } - // --- Autopilot: auto-approve all tools --- - const currentMode = this.sessionModes.get(sessionId) ?? "default"; - if (currentMode === "autopilot") { - return { behavior: "allow", updatedInput: input }; - } - - // --- Default / acceptEdits: emit permission prompt --- return this.handleToolPermission(sessionId, toolName, input, options); }; } @@ -1913,7 +1931,7 @@ export class ClaudeCodeAdapter extends EngineAdapter { directory: string, opts: { model?: string; - permissionMode?: "default" | "plan" | "acceptEdits" | "dontAsk"; + permissionMode?: PermissionMode; }, ): Promise { const existing = this.v2Sessions.get(sessionId); @@ -1927,29 +1945,44 @@ export class ClaudeCodeAdapter extends EngineAdapter { this.pendingResumeNotice.add(sessionId); // Fall through to create a new session (ccSessionId is preserved by cleanupSession) } else { - // Check if permissionMode changed — switch at runtime without destroying session const requestedMode = opts.permissionMode ?? "default"; - if (existing.permissionMode !== requestedMode) { + if (allowsDangerouslySkipPermissions(requestedMode) && !existing.allowDangerouslySkipPermissions) { claudeLog.info( - `[Claude][${sessionId}] permissionMode changed from ${existing.permissionMode} to ${requestedMode}, switching at runtime`, + `[Claude][${sessionId}] permissionMode changed to ${requestedMode}, recreating session with skip-permissions allowance`, ); - existing.permissionMode = requestedMode; + if (existing.capturedSessionId) { + this.sessionCcIds.set(sessionId, existing.capturedSessionId); + } try { - const query = (existing.session as any).query; - if (query && typeof query.setPermissionMode === "function") { - await query.setPermissionMode(requestedMode); - claudeLog.info(`[Claude][${sessionId}] Permission mode switched to: ${requestedMode}`); - } else { - claudeLog.warn(`[Claude][${sessionId}] setPermissionMode not available on query, will apply on next message`); + existing.session.close(); + } catch { + // Ignore + } + this.v2Sessions.delete(sessionId); + } else { + // Check if permissionMode changed — switch at runtime without destroying session + if (existing.permissionMode !== requestedMode) { + claudeLog.info( + `[Claude][${sessionId}] permissionMode changed from ${existing.permissionMode} to ${requestedMode}, switching at runtime`, + ); + existing.permissionMode = requestedMode; + try { + const query = (existing.session as any).query; + if (query && typeof query.setPermissionMode === "function") { + await query.setPermissionMode(requestedMode); + claudeLog.info(`[Claude][${sessionId}] Permission mode switched to: ${requestedMode}`); + } else { + claudeLog.warn(`[Claude][${sessionId}] setPermissionMode not available on query, will apply on next message`); + } + } catch (err) { + claudeLog.warn(`[Claude][${sessionId}] Failed to set permission mode at runtime:`, err); } - } catch (err) { - claudeLog.warn(`[Claude][${sessionId}] Failed to set permission mode at runtime:`, err); } - } - // Session is ready — update usage timestamp and return - existing.lastUsedAt = Date.now(); - return existing.session; + // Session is ready — update usage timestamp and return + existing.lastUsedAt = Date.now(); + return existing.session; + } } } @@ -1984,10 +2017,13 @@ export class ClaudeCodeAdapter extends EngineAdapter { promptAppend += `\n\nThe user has installed the following additional skills (invokable via the Skill tool): ${this.cachedSkillNames.join(", ")}. When the user's request matches one of these skills, use the Skill tool to invoke it.`; } + const permissionMode = opts.permissionMode ?? "default"; + const allowDangerouslySkipPermissions = allowsDangerouslySkipPermissions(permissionMode); const sdkOptions: any = this.withClaudeExecutablePath({ model: opts.model ?? this.currentModelId ?? "claude-sonnet-4-20250514", env, - permissionMode: opts.permissionMode ?? "default", + permissionMode, + ...(allowDangerouslySkipPermissions ? { allowDangerouslySkipPermissions: true } : {}), canUseTool: this.createCanUseTool(sessionId), systemPrompt: { type: "preset" as const, preset: "claude_code" as const, append: promptAppend }, stderr: this.stderrCallback, @@ -2057,7 +2093,8 @@ export class ClaudeCodeAdapter extends EngineAdapter { createdAt: Date.now(), lastUsedAt: Date.now(), capturedSessionId: ccSessionId, - permissionMode: opts.permissionMode ?? "default", + permissionMode, + allowDangerouslySkipPermissions, }; this.v2Sessions.set(sessionId, info); diff --git a/src/components/PromptInput.tsx b/src/components/PromptInput.tsx index c14913a..2e18f9c 100644 --- a/src/components/PromptInput.tsx +++ b/src/components/PromptInput.tsx @@ -25,7 +25,7 @@ function getModeColor(mode: AgentMode, index: number): string { const label = getModeDisplayName(mode).toLowerCase(); if (label === "default" || label === "interactive" || label === "build") return "bg-indigo-600"; if (label === "plan") return "bg-cyan-600"; - if (label === "autopilot" || label === "auto-accept") return "bg-emerald-600"; + if (label === "autopilot" || label === "bypass permissions" || label === "auto-accept") return "bg-emerald-600"; // Fallback by position const palette = ["bg-indigo-600", "bg-cyan-600", "bg-emerald-600"]; if (index < palette.length) return palette[index]; @@ -47,7 +47,7 @@ function getModeAccentRing(mode: AgentMode, index: number): { border: "border-cyan-200/40 dark:border-cyan-600/30", bgHover: "bg-cyan-600 hover:bg-cyan-700", }; - if (label === "autopilot" || label === "auto-accept") + if (label === "autopilot" || label === "bypass permissions" || label === "auto-accept") return { bg: "bg-emerald-50/60 dark:bg-slate-800/70 backdrop-blur-xl", ring: "focus-within:ring-emerald-500/40", @@ -85,7 +85,7 @@ const MODE_ICONS: Array<() => any> = [ ), - // 2 — zap / autopilot + // 2 — zap / autonomous () => ( @@ -104,7 +104,7 @@ function getModeIcon(mode: AgentMode, index: number) { const label = getModeDisplayName(mode).toLowerCase(); if (label === "default" || label === "interactive" || label === "build") return MODE_ICONS[0](); if (label === "plan") return MODE_ICONS[1](); - if (label === "autopilot") return MODE_ICONS[2](); + if (label === "autopilot" || label === "bypass permissions") return MODE_ICONS[2](); if (label === "auto-accept") return MODE_ICONS[3](); return MODE_ICONS[index % MODE_ICONS.length](); } @@ -463,7 +463,7 @@ export function PromptInput(props: PromptInputProps) { } const label = getModeDisplayName(agent()).toLowerCase(); if (label === "plan") return t().prompt.planPlaceholder; - if (label === "autopilot") return t().prompt.autopilotPlaceholder; + if (label === "autopilot" || label === "bypass permissions") return t().prompt.autopilotPlaceholder; if (label === "build" || label === "interactive" || label === "default") return t().prompt.buildPlaceholder; return t().prompt.placeholder; }); diff --git a/src/pages/Chat.tsx b/src/pages/Chat.tsx index 9d66873..476253e 100644 --- a/src/pages/Chat.tsx +++ b/src/pages/Chat.tsx @@ -2492,7 +2492,7 @@ export default function Chat() { const id = currentAgent().id; if (id === "plan") return "bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-400"; - if (id === "autopilot" || id === "acceptEdits") + if (id === "autopilot" || id === "bypassPermissions" || id === "acceptEdits") return "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400"; return "bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-400"; })() diff --git a/tests/unit/electron/engines/claude/index.test.ts b/tests/unit/electron/engines/claude/index.test.ts index 33fdcb9..33a8738 100644 --- a/tests/unit/electron/engines/claude/index.test.ts +++ b/tests/unit/electron/engines/claude/index.test.ts @@ -408,6 +408,31 @@ describe("ClaudeCodeAdapter", () => { expect(mock.query.setPermissionMode).toHaveBeenCalledWith("acceptEdits"); }); + it("passes bypassPermissions through when the live V2 session allows skipping permissions", async () => { + const mock = makeMockV2Session(); + seedV2Session(adapter, "cs_1", mock); + (adapter as any).v2Sessions.get("cs_1").allowDangerouslySkipPermissions = true; + + await adapter.setMode("cs_1", "bypassPermissions"); + + expect(mock.query.setPermissionMode).toHaveBeenCalledWith("bypassPermissions"); + expect((adapter as any).v2Sessions.get("cs_1").permissionMode).toBe("bypassPermissions"); + }); + + it("recreates the V2 session when entering bypassPermissions without skip allowance", async () => { + const mock = makeMockV2Session(); + seedV2Session(adapter, "cs_1", mock); + (adapter as any).v2Sessions.get("cs_1").capturedSessionId = "cc-prev"; + + await adapter.setMode("cs_1", "bypassPermissions"); + + expect(mock.query.setPermissionMode).not.toHaveBeenCalled(); + expect(mock.close).toHaveBeenCalledTimes(1); + expect((adapter as any).v2Sessions.has("cs_1")).toBe(false); + expect((adapter as any).sessionCcIds.get("cs_1")).toBe("cc-prev"); + expect((adapter as any).sessionModes.get("cs_1")).toBe("bypassPermissions"); + }); + it("updates permissionMode on existing V2SessionInfo", async () => { const mock = makeMockV2Session(); seedV2Session(adapter, "cs_1", mock); @@ -1880,12 +1905,14 @@ describe("ClaudeCodeAdapter", () => { // ========================================================================= describe("getModes()", () => { - it("returns the three default modes", () => { + it("returns Claude Code permission modes without Copilot autopilot", () => { const modes = adapter.getModes(); const ids = modes.map((m) => m.id); expect(ids).toContain("default"); expect(ids).toContain("plan"); expect(ids).toContain("acceptEdits"); + expect(ids).toContain("bypassPermissions"); + expect(ids).not.toContain("autopilot"); }); }); @@ -2853,6 +2880,32 @@ describe("ClaudeCodeAdapter", () => { expect((adapter as any).v2Sessions.get("cs_1").permissionMode).toBe("plan"); }); + it("recreates an existing session before switching to bypassPermissions without skip allowance", async () => { + const oldSession = makeMockV2Session(); + const newSession = makeMockV2Session(); + seedV2Session(adapter, "cs_1", oldSession); + (adapter as any).v2Sessions.get("cs_1").permissionMode = "default"; + (adapter as any).v2Sessions.get("cs_1").capturedSessionId = "cc-prev"; + unstable_v2_resumeSessionMock.mockReturnValue(newSession); + + const session = await (adapter as any).getOrCreateV2Session("cs_1", "/repo", { + permissionMode: "bypassPermissions", + }); + + expect(session).toBe(newSession); + expect(oldSession.query.setPermissionMode).not.toHaveBeenCalled(); + expect(oldSession.close).toHaveBeenCalledTimes(1); + expect(unstable_v2_resumeSessionMock).toHaveBeenCalledWith( + "cc-prev", + expect.objectContaining({ + permissionMode: "bypassPermissions", + allowDangerouslySkipPermissions: true, + }), + ); + expect((adapter as any).v2Sessions.get("cs_1").permissionMode).toBe("bypassPermissions"); + expect((adapter as any).v2Sessions.get("cs_1").allowDangerouslySkipPermissions).toBe(true); + }); + it("handles setPermissionMode failure gracefully", async () => { const mockSession = makeMockV2Session(); mockSession.query.setPermissionMode.mockRejectedValue(new Error("setPermissionMode failed")); @@ -2903,6 +2956,20 @@ describe("ClaudeCodeAdapter", () => { expect(options.pathToClaudeCodeExecutable).not.toContain("cli.js"); }); + it("passes Claude Code native bypass permission options when creating sessions", async () => { + seedSession(adapter, "cs_1"); + unstable_v2_createSessionMock.mockReturnValue(makeMockV2Session()); + + await (adapter as any).getOrCreateV2Session("cs_1", "/repo", { + permissionMode: "bypassPermissions", + }); + + const options = unstable_v2_createSessionMock.mock.calls[0][0]; + expect(options.permissionMode).toBe("bypassPermissions"); + expect(options.allowDangerouslySkipPermissions).toBe(true); + expect((adapter as any).v2Sessions.get("cs_1").allowDangerouslySkipPermissions).toBe(true); + }); + it("recreates session when transport is not ready", async () => { const deadSession = makeMockV2Session(); deadSession.query.transport.isReady.mockReturnValue(false); @@ -2997,6 +3064,25 @@ describe("ClaudeCodeAdapter", () => { ); }); + it("applies bypassPermissions mode option through SDK permissionMode", async () => { + seedSession(adapter, "cs_1"); + const mockV2 = makeMockV2Session([ + { type: "result", subtype: "success" }, + ]); + const getOrCreateSpy = vi.spyOn(adapter as any, "getOrCreateV2Session").mockResolvedValue(mockV2); + + await adapter.sendMessage("cs_1", [{ type: "text", text: "Hello" }], { + mode: "bypassPermissions", + }).catch(() => {}); + + expect(getOrCreateSpy).toHaveBeenCalledWith( + "cs_1", + expect.any(String), + expect.objectContaining({ permissionMode: "bypassPermissions" }), + ); + expect((adapter as any).sessionModes.get("cs_1")).toBe("bypassPermissions"); + }); + it("sends multimodal message when only image provided (no text)", async () => { seedSession(adapter, "cs_1"); const mockV2 = makeMockV2Session([ From 20f1f127232fa1d27c269f01c8fdd68df3b6f27a Mon Sep 17 00:00:00 2001 From: Duang Cheng Date: Thu, 30 Apr 2026 13:04:09 +0800 Subject: [PATCH 2/4] feat: align session-scoped controls Keep Claude bypass mode and session model/mode/effort controls consistent across the chat UI and channel commands. Co-Authored-By: Claude Opus 4.7 --- .../channels/dingtalk/dingtalk-adapter.ts | 5 +- .../channels/feishu/feishu-card-builder.ts | 5 +- electron/main/channels/gateway-ws-client.ts | 13 + .../main/channels/shared/command-parser.ts | 10 + .../main/channels/shared/command-types.ts | 3 +- .../main/channels/shared/help-text-builder.ts | 5 +- .../main/channels/shared/session-commands.ts | 159 +++++++++-- .../weixin-ilink/weixin-ilink-adapter.ts | 2 +- electron/main/engines/claude/index.ts | 11 +- src/components/ChatModelPicker.tsx | 11 +- src/components/PromptInput.tsx | 12 +- src/components/SessionControls.tsx | 251 ++++++++++++++++++ src/pages/Chat.tsx | 111 ++------ .../feishu/feishu-card-builder.test.ts | 3 +- .../channels/shared/command-parser.test.ts | 39 ++- .../channels/shared/session-commands.test.ts | 159 +++++++++-- .../electron/engines/claude/index.test.ts | 17 +- 17 files changed, 639 insertions(+), 177 deletions(-) create mode 100644 src/components/SessionControls.tsx diff --git a/electron/main/channels/dingtalk/dingtalk-adapter.ts b/electron/main/channels/dingtalk/dingtalk-adapter.ts index f0190d8..b90f4fb 100644 --- a/electron/main/channels/dingtalk/dingtalk-adapter.ts +++ b/electron/main/channels/dingtalk/dingtalk-adapter.ts @@ -1033,8 +1033,9 @@ export class DingTalkAdapter extends ChannelAdapter { "发送消息即可开始对话。可用命令:", "`/cancel` — 取消当前正在运行的消息", "`/status` — 查看会话信息", - "`/mode` — 切换模式", - "`/model` — 切换模型", + "`/mode` — 切换当前会话模式", + "`/model` — 切换当前会话模型", + "`/effort` — 切换当前会话推理级别", "`/history` — 查看会话历史记录", "`/help` — 显示可用命令", ].join("\n"); diff --git a/electron/main/channels/feishu/feishu-card-builder.ts b/electron/main/channels/feishu/feishu-card-builder.ts index 3bc32e9..d6aa4e2 100644 --- a/electron/main/channels/feishu/feishu-card-builder.ts +++ b/electron/main/channels/feishu/feishu-card-builder.ts @@ -76,8 +76,9 @@ export function buildGroupWelcomeCard( const commands = [ "/cancel — 取消当前正在运行的消息", "/status — 查看会话信息", - "/mode agent|plan|build — 切换模式", - "/model list / /model model-id — 切换模型", + "/mode list / /mode mode-id — 切换当前会话模式", + "/model list / /model model-id — 切换当前会话模型", + "/effort list / /effort low|medium|high|max — 切换当前会话推理级别", "/history — 查看会话历史记录", "/help — 显示可用命令", ].join("\n"); diff --git a/electron/main/channels/gateway-ws-client.ts b/electron/main/channels/gateway-ws-client.ts index 3472527..cdf322b 100644 --- a/electron/main/channels/gateway-ws-client.ts +++ b/electron/main/channels/gateway-ws-client.ts @@ -16,6 +16,7 @@ import { type EngineType, type EngineInfo, type EngineCapabilities, + type AgentMode, type UnifiedSession, type UnifiedMessage, type ModelListResult, @@ -30,6 +31,8 @@ import { type ProjectSetEngineRequest, type ModelSetRequest, type ModeSetRequest, + type SessionConfigPatch, + type SessionConfigUpdateRequest, } from "../../../src/types/unified"; import { channelLog } from "../services/logger"; @@ -321,8 +324,18 @@ export class GatewayWsClient { return this.request(GatewayRequestType.MODEL_SET, req); } + updateSessionConfig(sessionId: string, config: SessionConfigPatch): Promise { + const req: SessionConfigUpdateRequest = { sessionId, config }; + return this.request(GatewayRequestType.SESSION_CONFIG_UPDATE, req); + } + // --- Mode API --- + async listModes(engineType: EngineType): Promise { + const engines = await this.listEngines(); + return engines.find((engine) => engine.type === engineType)?.capabilities.availableModes ?? []; + } + setMode(req: ModeSetRequest): Promise { return this.request(GatewayRequestType.MODE_SET, req); } diff --git a/electron/main/channels/shared/command-parser.ts b/electron/main/channels/shared/command-parser.ts index a2a6dda..01a1e51 100644 --- a/electron/main/channels/shared/command-parser.ts +++ b/electron/main/channels/shared/command-parser.ts @@ -12,9 +12,15 @@ const COMMAND_PREFIX = "/"; /** Sub-commands recognised for a given top-level command. */ const SUBCOMMANDS: Record> = { + // /mode list — list available modes + // /mode — switch mode (handled as args, not subcommand) + mode: new Set(["list"]), // /model list — list available models // /model — switch model (handled as args, not subcommand) model: new Set(["list"]), + // /effort list — list available reasoning efforts + // /effort — switch reasoning effort (handled as args, not subcommand) + effort: new Set(["list"]), }; /** @@ -22,8 +28,12 @@ const SUBCOMMANDS: Record> = { * * "/help" → { command: "help", args: [], raw: "/help" } * "/help@MyBot" → { command: "help", args: [], raw: "/help@MyBot" } + * "/mode list" → { command: "mode", subcommand: "list", args: [], raw: "..." } + * "/mode plan" → { command: "mode", args: ["plan"], raw: "..." } * "/model list" → { command: "model", subcommand: "list", args: [], raw: "..." } * "/model gpt-4o" → { command: "model", args: ["gpt-4o"], raw: "..." } + * "/effort list" → { command: "effort", subcommand: "list", args: [], raw: "..." } + * "/effort high" → { command: "effort", args: ["high"], raw: "..." } */ export function parseCommand(text: string): ParsedCommand | null { const trimmed = text.trim(); diff --git a/electron/main/channels/shared/command-types.ts b/electron/main/channels/shared/command-types.ts index 249e497..f215ad7 100644 --- a/electron/main/channels/shared/command-types.ts +++ b/electron/main/channels/shared/command-types.ts @@ -21,7 +21,7 @@ export interface ParsedCommand { export interface CommandCapabilities { /** Class A: project/session navigation (only meaningful in P2P). */ navigation: boolean; - /** Class B: current-session ops (cancel/status/mode/model/history). */ + /** Class B: current-session ops (cancel/status/mode/model/effort/history). */ sessionOps: boolean; /** Class C: help/start are always true; included for symmetry. */ general: boolean; @@ -52,6 +52,7 @@ export const KNOWN_COMMANDS = [ "status", "mode", "model", + "effort", "history", // Class C — general "help", diff --git a/electron/main/channels/shared/help-text-builder.ts b/electron/main/channels/shared/help-text-builder.ts index 598a564..0e18075 100644 --- a/electron/main/channels/shared/help-text-builder.ts +++ b/electron/main/channels/shared/help-text-builder.ts @@ -30,8 +30,9 @@ export function buildHelpText( if (capabilities.sessionOps) { lines.push("`/status` · 查看会话信息"); lines.push("`/cancel` · 取消运行中的消息"); - lines.push("`/mode agent|plan|build` · 切换模式"); - lines.push("`/model list` / `/model model-id` · 切换模型"); + lines.push("`/mode list` / `/mode mode-id` · 切换当前会话模式"); + lines.push("`/model list` / `/model model-id` · 切换当前会话模型"); + lines.push("`/effort list` / `/effort low|medium|high|max` · 切换当前会话推理级别"); lines.push("`/history` · 查看历史"); } diff --git a/electron/main/channels/shared/session-commands.ts b/electron/main/channels/shared/session-commands.ts index 1a123eb..44a0bc3 100644 --- a/electron/main/channels/shared/session-commands.ts +++ b/electron/main/channels/shared/session-commands.ts @@ -8,7 +8,12 @@ // ============================================================================ import type { GatewayWsClient } from "../gateway-ws-client"; -import type { EngineType } from "../../../../src/types/unified"; +import { + isReasoningEffort, + type EngineType, + type ReasoningEffort, + type UnifiedModelInfo, +} from "../../../../src/types/unified"; import type { ParsedCommand } from "./command-types"; import { buildHistoryEntries } from "./list-builders"; @@ -16,6 +21,36 @@ function escapeMarkdownInline(value: string): string { return value.replace(/[\\*`]/g, "\\$&"); } +const effortLabels: Record = { + low: "低", + medium: "中", + high: "高", + max: "最大", +}; + +function getModelEfforts(models: UnifiedModelInfo[], modelId: string | undefined): ReasoningEffort[] { + if (!modelId) return []; + return models.find((model) => model.modelId === modelId)?.capabilities?.supportedReasoningEfforts ?? []; +} + +function getModelDefaultEffort(models: UnifiedModelInfo[], modelId: string | undefined): ReasoningEffort | undefined { + if (!modelId) return undefined; + return models.find((model) => model.modelId === modelId)?.capabilities?.defaultReasoningEffort; +} + +function resolveEffortForModelChange( + models: UnifiedModelInfo[], + modelId: string, + currentEffort: ReasoningEffort | undefined, +): ReasoningEffort | null | undefined { + const model = models.find((item) => item.modelId === modelId); + if (!model) return undefined; + const supportedEfforts = model.capabilities?.supportedReasoningEfforts ?? []; + if (supportedEfforts.length === 0) return null; + if (currentEffort && supportedEfforts.includes(currentEffort)) return currentEffort; + return model.capabilities?.defaultReasoningEffort ?? null; +} + /** * The minimal context a session-ops command needs. Channels build this from * their own state (P2P temp session, group binding, etc.) and pass it in. @@ -55,6 +90,7 @@ export async function handleSessionOpsCommand( case "status": case "mode": case "model": + case "effort": case "history": break; default: @@ -85,23 +121,46 @@ export async function handleSessionOpsCommand( } case "mode": { - if (!command.args || command.args.length === 0) { - await args.sendText([ - "**📋 模式列表**", - "", - "- `agent` · 默认 Agent 模式", - "- `plan` · 规划模式", - "- `build` · 构建模式", - "", - "使用 `/mode agent`、`/mode plan` 或 `/mode build` 切换模式。", - ].join("\n")); + const modes = await args.gatewayClient.listModes(ctx.engineType); + if (modes.length === 0) { + await args.sendText("📋 当前引擎不支持模式切换。"); + return true; + } + + const isList = + subcommand === "list" || + (!subcommand && (!command.args || command.args.length === 0)); + if (isList) { + const session = await args.gatewayClient.getSession(ctx.conversationId); + const currentModeId = session.mode ?? modes[0]?.id; + const lines = ["**📋 模式列表**", ""]; + for (const mode of modes) { + const current = mode.id === currentModeId ? "(当前会话)" : ""; + const modeId = escapeMarkdownInline(mode.id); + const label = escapeMarkdownInline(mode.label || mode.id); + if (mode.description) { + lines.push(`- ${label} · \`${modeId}\`${current} — ${escapeMarkdownInline(mode.description)}`); + } else { + lines.push(`- ${label} · \`${modeId}\`${current}`); + } + } + lines.push(""); + lines.push("使用 `/mode mode-id` 切换当前会话模式。"); + await args.sendText(lines.join("\n")); + return true; + } + + const modeId = command.args?.[0]; + if (!modeId || !modes.some((mode) => mode.id === modeId)) { + await args.sendText(`📋 当前引擎支持的模式:${modes.map((mode) => `\`${escapeMarkdownInline(mode.id)}\``).join("、")}`); return true; } + await args.gatewayClient.setMode({ sessionId: ctx.conversationId, - modeId: command.args[0], + modeId, }); - await args.sendText(`📋 模式已切换为:${command.args[0]}`); + await args.sendText(`📋 当前会话模式已切换为:${modeId}`); return true; } @@ -110,10 +169,16 @@ export async function handleSessionOpsCommand( subcommand === "list" || (!subcommand && (!command.args || command.args.length === 0)); if (isList) { - const result = await args.gatewayClient.listModels(ctx.engineType); + const [result, session] = await Promise.all([ + args.gatewayClient.listModels(ctx.engineType), + args.gatewayClient.getSession(ctx.conversationId), + ]); + const currentModelId = session.modelId ?? result.currentModelId; + let currentMarked = false; const lines = ["**📋 模型列表**", ""]; for (const m of result.models) { - const current = m.modelId === result.currentModelId ? "(当前)" : ""; + const current = m.modelId === currentModelId ? "(当前会话)" : ""; + if (current) currentMarked = true; const modelId = escapeMarkdownInline(m.modelId); if (m.name && m.name !== m.modelId) { lines.push(`- ${escapeMarkdownInline(m.name)} · \`${modelId}\`${current}`); @@ -121,16 +186,68 @@ export async function handleSessionOpsCommand( lines.push(`- \`${modelId}\`${current}`); } } + if (currentModelId && !currentMarked) { + lines.push(`- \`${escapeMarkdownInline(currentModelId)}\`(当前会话)`); + } lines.push(""); - lines.push("使用 `/model model-id` 切换模型。"); + lines.push("使用 `/model model-id` 切换当前会话模型。"); await args.sendText(lines.join("\n")); } else if (command.args && command.args.length > 0) { - await args.gatewayClient.setModel({ - sessionId: ctx.conversationId, - modelId: command.args[0], - }); - await args.sendText(`📋 模型已切换为:${command.args[0]}`); + const modelId = command.args[0]; + const [result, session] = await Promise.all([ + args.gatewayClient.listModels(ctx.engineType), + args.gatewayClient.getSession(ctx.conversationId), + ]); + const nextEffort = resolveEffortForModelChange(result.models, modelId, session.reasoningEffort); + const config = nextEffort === undefined + ? { modelId } + : { modelId, reasoningEffort: nextEffort }; + await args.gatewayClient.updateSessionConfig(ctx.conversationId, config); + await args.sendText(`📋 当前会话模型已切换为:${modelId}`); + } + return true; + } + + case "effort": { + const [result, session] = await Promise.all([ + args.gatewayClient.listModels(ctx.engineType), + args.gatewayClient.getSession(ctx.conversationId), + ]); + const currentModelId = session.modelId ?? result.currentModelId; + const supportedEfforts = getModelEfforts(result.models, currentModelId); + if (supportedEfforts.length === 0) { + await args.sendText("📋 当前模型不支持推理级别设置。"); + return true; + } + + const currentEffort = session.reasoningEffort && supportedEfforts.includes(session.reasoningEffort) + ? session.reasoningEffort + : getModelDefaultEffort(result.models, currentModelId); + const isList = + subcommand === "list" || + (!subcommand && (!command.args || command.args.length === 0)); + + if (isList) { + const lines = ["**📋 推理级别**", ""]; + if (currentModelId) lines.push(`当前模型:\`${escapeMarkdownInline(currentModelId)}\``); + for (const effort of supportedEfforts) { + const current = effort === currentEffort ? "(当前会话)" : ""; + lines.push(`- \`${effort}\` · ${effortLabels[effort]}${current}`); + } + lines.push(""); + lines.push("使用 `/effort low|medium|high|max` 切换当前会话推理级别。"); + await args.sendText(lines.join("\n")); + return true; } + + const nextEffort = command.args?.[0]; + if (!isReasoningEffort(nextEffort) || !supportedEfforts.includes(nextEffort)) { + await args.sendText(`📋 当前模型支持的推理级别:${supportedEfforts.map((effort) => `\`${effort}\``).join("、")}`); + return true; + } + + await args.gatewayClient.updateSessionConfig(ctx.conversationId, { reasoningEffort: nextEffort }); + await args.sendText(`📋 当前会话推理级别已切换为:${effortLabels[nextEffort]}(${nextEffort})`); return true; } diff --git a/electron/main/channels/weixin-ilink/weixin-ilink-adapter.ts b/electron/main/channels/weixin-ilink/weixin-ilink-adapter.ts index c8f22eb..a0730b5 100644 --- a/electron/main/channels/weixin-ilink/weixin-ilink-adapter.ts +++ b/electron/main/channels/weixin-ilink/weixin-ilink-adapter.ts @@ -545,7 +545,7 @@ export class WeixinIlinkAdapter extends ChannelAdapter { ): Promise { if (!command || !this.transport) return; - // Try shared Class-B session-ops first (cancel/status/mode/model/history) + // Try shared Class-B session-ops first (cancel/status/mode/model/effort/history) if (this.gatewayClient) { const handled = await handleSessionOpsCommand(command, { sendText: (text) => this.transport!.sendMarkdown(chatId, text), diff --git a/electron/main/engines/claude/index.ts b/electron/main/engines/claude/index.ts index dfc69d3..6689003 100644 --- a/electron/main/engines/claude/index.ts +++ b/electron/main/engines/claude/index.ts @@ -125,23 +125,20 @@ interface PendingQuestion { // ============================================================================ const DEFAULT_MODES: AgentMode[] = [ - { id: "default", label: "Default", description: "Standard behavior, prompts for dangerous operations" }, - { id: "acceptEdits", label: "Auto-Accept", description: "Auto-accept file edit operations" }, { id: "bypassPermissions", label: "Bypass Permissions", description: "Bypass all permission checks" }, + { id: "default", label: "Default", description: "Standard behavior, prompts for dangerous operations" }, { id: "plan", label: "Plan", description: "Planning mode, no actual tool execution" }, ]; const CLAUDE_PERMISSION_MODES = new Set([ "default", - "acceptEdits", "bypassPermissions", "plan", - "dontAsk", - "auto", ]); function toClaudePermissionMode(mode: string | undefined): PermissionMode { - return mode && CLAUDE_PERMISSION_MODES.has(mode as PermissionMode) + if (!mode) return "bypassPermissions"; + return CLAUDE_PERMISSION_MODES.has(mode as PermissionMode) ? (mode as PermissionMode) : "default"; } @@ -2017,7 +2014,7 @@ export class ClaudeCodeAdapter extends EngineAdapter { promptAppend += `\n\nThe user has installed the following additional skills (invokable via the Skill tool): ${this.cachedSkillNames.join(", ")}. When the user's request matches one of these skills, use the Skill tool to invoke it.`; } - const permissionMode = opts.permissionMode ?? "default"; + const permissionMode = opts.permissionMode ?? toClaudePermissionMode(undefined); const allowDangerouslySkipPermissions = allowsDangerouslySkipPermissions(permissionMode); const sdkOptions: any = this.withClaudeExecutablePath({ model: opts.model ?? this.currentModelId ?? "claude-sonnet-4-20250514", diff --git a/src/components/ChatModelPicker.tsx b/src/components/ChatModelPicker.tsx index b1f22e9..7742834 100644 --- a/src/components/ChatModelPicker.tsx +++ b/src/components/ChatModelPicker.tsx @@ -19,6 +19,7 @@ interface ChatModelPickerProps { selectedModelId: string | null; customModelInput: boolean; disabled?: boolean; + fullWidth?: boolean; placeholder: string; ariaLabel: string; onChange: (modelId: string) => void; @@ -158,14 +159,18 @@ export function ChatModelPicker(props: ChatModelPickerProps) { const dropdownId = "chat-model-picker-dropdown"; + const pickerWidthClass = () => props.fullWidth + ? "w-full max-w-none" + : "w-[220px] max-w-[45vw]"; + return ( -
+
openDropdown(e.currentTarget)} onInput={(e) => { const value = e.currentTarget.value; diff --git a/src/components/PromptInput.tsx b/src/components/PromptInput.tsx index 2e18f9c..f6d05b1 100644 --- a/src/components/PromptInput.tsx +++ b/src/components/PromptInput.tsx @@ -25,7 +25,7 @@ function getModeColor(mode: AgentMode, index: number): string { const label = getModeDisplayName(mode).toLowerCase(); if (label === "default" || label === "interactive" || label === "build") return "bg-indigo-600"; if (label === "plan") return "bg-cyan-600"; - if (label === "autopilot" || label === "bypass permissions" || label === "auto-accept") return "bg-emerald-600"; + if (label === "autopilot" || label === "bypass permissions") return "bg-emerald-600"; // Fallback by position const palette = ["bg-indigo-600", "bg-cyan-600", "bg-emerald-600"]; if (index < palette.length) return palette[index]; @@ -47,7 +47,7 @@ function getModeAccentRing(mode: AgentMode, index: number): { border: "border-cyan-200/40 dark:border-cyan-600/30", bgHover: "bg-cyan-600 hover:bg-cyan-700", }; - if (label === "autopilot" || label === "bypass permissions" || label === "auto-accept") + if (label === "autopilot" || label === "bypass permissions") return { bg: "bg-emerald-50/60 dark:bg-slate-800/70 backdrop-blur-xl", ring: "focus-within:ring-emerald-500/40", @@ -91,13 +91,6 @@ const MODE_ICONS: Array<() => any> = [ ), - // 3 — check-circle / auto-accept - () => ( - - - - - ), ]; function getModeIcon(mode: AgentMode, index: number) { @@ -105,7 +98,6 @@ function getModeIcon(mode: AgentMode, index: number) { if (label === "default" || label === "interactive" || label === "build") return MODE_ICONS[0](); if (label === "plan") return MODE_ICONS[1](); if (label === "autopilot" || label === "bypass permissions") return MODE_ICONS[2](); - if (label === "auto-accept") return MODE_ICONS[3](); return MODE_ICONS[index % MODE_ICONS.length](); } diff --git a/src/components/SessionControls.tsx b/src/components/SessionControls.tsx new file mode 100644 index 0000000..7544fa0 --- /dev/null +++ b/src/components/SessionControls.tsx @@ -0,0 +1,251 @@ +import { createEffect, createMemo, createSignal, For, onCleanup, Show, type JSX } from "solid-js"; +import { Portal } from "solid-js/web"; +import { ChatModelPicker } from "./ChatModelPicker"; +import { useI18n } from "../lib/i18n"; +import type { CodexServiceTier, ReasoningEffort, UnifiedModelInfo } from "../types/unified"; + +interface SessionControlsProps { + models: UnifiedModelInfo[]; + selectedModelId: string | null; + customModelInput: boolean; + modelDisabled?: boolean; + modelPlaceholder: string; + modelAriaLabel: string; + supportedEfforts: ReasoningEffort[]; + selectedEffort: ReasoningEffort | null; + fastModeSupported: boolean; + serviceTier: CodexServiceTier | null; + scopeHint: string; + onModelChange: (modelId: string) => void; + onReasoningEffortChange: (effort: ReasoningEffort) => void; + onFastModeToggle: (nextActive: boolean) => void; +} + +interface PopoverPosition { + left: number; + top: number; + width: number; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} + +function SettingRow(props: { label: string; description?: string; children: JSX.Element }) { + return ( +
+
+
+ {props.label} +
+ +
+ {props.description} +
+
+
+ {props.children} +
+ ); +} + +export function SessionControls(props: SessionControlsProps) { + const { t } = useI18n(); + const [open, setOpen] = createSignal(false); + const [position, setPosition] = createSignal(null); + let triggerRef: HTMLButtonElement | undefined; + let panelRef: HTMLDivElement | undefined; + + const selectedModel = createMemo(() => + props.models.find((model) => model.modelId === props.selectedModelId), + ); + + const modelLabel = createMemo(() => + selectedModel()?.name || props.selectedModelId || props.modelPlaceholder, + ); + + const effortLabels: Record string> = { + low: () => t().prompt.reasoningEffortLow, + medium: () => t().prompt.reasoningEffortMedium, + high: () => t().prompt.reasoningEffortHigh, + max: () => t().prompt.reasoningEffortMax, + }; + + const effortLabel = createMemo(() => { + const effort = props.selectedEffort; + return effort ? effortLabels[effort]?.() ?? effort : null; + }); + + const fastActive = createMemo(() => props.serviceTier === "fast"); + + const summary = createMemo(() => { + const parts = [modelLabel()]; + const effort = effortLabel(); + if (effort && props.supportedEfforts.length > 0) parts.push(effort); + if (props.fastModeSupported && fastActive()) parts.push(t().engine.fastMode); + return parts.join(" · "); + }); + + const updatePosition = () => { + if (!triggerRef) return; + const rect = triggerRef.getBoundingClientRect(); + const viewportWidth = window.innerWidth || 380; + const width = Math.min(380, Math.max(280, viewportWidth - 24)); + const maxLeft = Math.max(12, viewportWidth - width - 12); + setPosition({ + left: clamp(rect.left, 12, maxLeft), + top: rect.top - 8, + width, + }); + }; + + const toggleOpen = () => { + if (open()) { + setOpen(false); + setPosition(null); + return; + } + updatePosition(); + setOpen(true); + }; + + createEffect(() => { + if (!open()) return; + + const handlePointerDown = (event: MouseEvent) => { + const target = event.target; + if (!(target instanceof Element)) return; + if (triggerRef?.contains(target) || panelRef?.contains(target)) return; + if (target.closest("#chat-model-picker-dropdown")) return; + setOpen(false); + setPosition(null); + }; + + const handleResize = () => updatePosition(); + + document.addEventListener("mousedown", handlePointerDown); + window.addEventListener("resize", handleResize); + onCleanup(() => { + document.removeEventListener("mousedown", handlePointerDown); + window.removeEventListener("resize", handleResize); + }); + }); + + return ( +
+ + + + + + + +
+ ); +} diff --git a/src/pages/Chat.tsx b/src/pages/Chat.tsx index 476253e..5dc589c 100644 --- a/src/pages/Chat.tsx +++ b/src/pages/Chat.tsx @@ -11,7 +11,6 @@ import { Suspense, untrack, } from "solid-js"; -import { Portal } from "solid-js/web"; import { Auth } from "../lib/auth"; import { useNavigate } from "@solidjs/router"; import { gateway } from "../lib/gateway-api"; @@ -35,7 +34,7 @@ import { } from "../stores/message"; import { MessageList } from "../components/MessageList"; import { PromptInput } from "../components/PromptInput"; -import { ChatModelPicker } from "../components/ChatModelPicker"; +import { SessionControls } from "../components/SessionControls"; import { SessionSidebar } from "../components/SessionSidebar"; import { HideProjectModal } from "../components/HideProjectModal"; import { AddProjectModal } from "../components/AddProjectModal"; @@ -490,12 +489,6 @@ export default function Chat() { const handleSessionModelChange = (modelId: string) => handleSessionConfigChange({ modelId }); - const [sessionScopeTooltipPos, setSessionScopeTooltipPos] = createSignal<{ left: number; top: number } | null>(null); - const showSessionScopeTooltip = (target: HTMLElement) => { - const r = target.getBoundingClientRect(); - setSessionScopeTooltipPos({ left: r.left + r.width / 2, top: r.top }); - }; - const handleSessionReasoningEffortChange = (effort: ReasoningEffort) => handleSessionConfigChange({ reasoningEffort: effort }); @@ -2492,7 +2485,7 @@ export default function Chat() { const id = currentAgent().id; if (id === "plan") return "bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-400"; - if (id === "autopilot" || id === "bypassPermissions" || id === "acceptEdits") + if (id === "autopilot" || id === "bypassPermissions") return "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400"; return "bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-400"; })() @@ -2884,90 +2877,22 @@ export default function Chat() { onImagesChange={(images) => updateCurrentDraft({ images })} toolbarContent={ - - - - - - - - {/* Model selector */} - - {/* Reasoning effort dropdown */} - 0}> - · - - - {/* Fast mode toggle */} - - · - - - + } /> diff --git a/tests/unit/electron/channels/feishu/feishu-card-builder.test.ts b/tests/unit/electron/channels/feishu/feishu-card-builder.test.ts index 8aafdb9..b37697b 100644 --- a/tests/unit/electron/channels/feishu/feishu-card-builder.test.ts +++ b/tests/unit/electron/channels/feishu/feishu-card-builder.test.ts @@ -56,8 +56,9 @@ describe('buildGroupWelcomeCard', () => { expect(content).toContain(`**引擎:** ${engineType}`); expect(content).toContain(`**会话:** ${sessionId}`); expect(content).toContain('/cancel'); - expect(content).toContain('/mode agent|plan|build'); + expect(content).toContain('/mode list / /mode mode-id'); expect(content).toContain('/model list / /model model-id'); + expect(content).toContain('/effort list / /effort low|medium|high|max'); expect(content).toContain('/help'); }); }); diff --git a/tests/unit/electron/channels/shared/command-parser.test.ts b/tests/unit/electron/channels/shared/command-parser.test.ts index 04fb9e1..b1d9a66 100644 --- a/tests/unit/electron/channels/shared/command-parser.test.ts +++ b/tests/unit/electron/channels/shared/command-parser.test.ts @@ -42,10 +42,26 @@ describe("shared command parser", () => { }); it("captures positional args", () => { + expect(parseCommand("/echo hello world")).toMatchObject({ + command: "echo", + args: ["hello", "world"], + }); + }); + + it("recognises /mode list as a subcommand", () => { + expect(parseCommand("/mode list")).toMatchObject({ + command: "mode", + subcommand: "list", + args: [], + }); + }); + + it("treats /mode as args, not subcommand", () => { expect(parseCommand("/mode plan")).toMatchObject({ command: "mode", args: ["plan"], }); + expect(parseCommand("/mode plan")?.subcommand).toBeUndefined(); }); it("recognises /model list as a subcommand", () => { @@ -61,7 +77,23 @@ describe("shared command parser", () => { command: "model", args: ["gpt-4o"], }); - expect(parseCommand("/model gpt-4o").subcommand).toBeUndefined(); + expect(parseCommand("/model gpt-4o")?.subcommand).toBeUndefined(); + }); + + it("recognises /effort list as a subcommand", () => { + expect(parseCommand("/effort list")).toMatchObject({ + command: "effort", + subcommand: "list", + args: [], + }); + }); + + it("treats /effort as args, not subcommand", () => { + expect(parseCommand("/effort high")).toMatchObject({ + command: "effort", + args: ["high"], + }); + expect(parseCommand("/effort high")?.subcommand).toBeUndefined(); }); it("trims whitespace", () => { @@ -78,9 +110,12 @@ describe("shared help-text builder", () => { expect(text).toContain("/switch"); expect(text).toContain("/cancel"); expect(text).toContain("/status"); - expect(text).toContain("/mode agent|plan|build"); + expect(text).toContain("/mode list"); + expect(text).toContain("/mode mode-id"); expect(text).toContain("/model list"); expect(text).toContain("/model model-id"); + expect(text).toContain("/effort list"); + expect(text).toContain("/effort low|medium|high|max"); expect(text).not.toContain(""); expect(text).toContain("/history"); diff --git a/tests/unit/electron/channels/shared/session-commands.test.ts b/tests/unit/electron/channels/shared/session-commands.test.ts index 4729710..6d7cb01 100644 --- a/tests/unit/electron/channels/shared/session-commands.test.ts +++ b/tests/unit/electron/channels/shared/session-commands.test.ts @@ -16,8 +16,11 @@ interface Harness { sendText: ReturnType; gatewayClient: { cancelMessage: ReturnType; + listModes: ReturnType; setMode: ReturnType; setModel: ReturnType; + updateSessionConfig: ReturnType; + getSession: ReturnType; listModels: ReturnType; listMessages: ReturnType; }; @@ -29,12 +32,33 @@ function makeHarness(context: SessionContext | null = defaultContext()): Harness sendText: vi.fn(async () => undefined), gatewayClient: { cancelMessage: vi.fn(async () => undefined), + listModes: vi.fn(async () => [ + { id: "bypassPermissions", label: "Bypass Permissions" }, + { id: "default", label: "Default" }, + { id: "plan", label: "Plan" }, + ]), setMode: vi.fn(async () => undefined), setModel: vi.fn(async () => undefined), + updateSessionConfig: vi.fn(async () => undefined), + getSession: vi.fn(async () => ({ id: "conv-1", engineType: "claude", directory: "/repo", mode: "plan", modelId: "m2" })), listModels: vi.fn(async () => ({ models: [ - { modelId: "m1", name: "Model One" }, - { modelId: "m2", name: "Model Two" }, + { + modelId: "m1", + name: "Model One", + capabilities: { + supportedReasoningEfforts: ["low", "medium"], + defaultReasoningEffort: "medium", + }, + }, + { + modelId: "m2", + name: "Model Two", + capabilities: { + supportedReasoningEfforts: ["low", "medium", "high"], + defaultReasoningEffort: "high", + }, + }, ], currentModelId: "m1", })), @@ -102,36 +126,49 @@ describe("handleSessionOpsCommand", () => { expect(h.sendText.mock.calls[0][0]).toContain("引擎:codex"); }); - it("/mode without args shows available modes", async () => { + it("/mode with no args lists modes and marks the current session mode", async () => { expect(await invoke(h, cmd("mode"))).toBe(true); + expect(h.gatewayClient.listModes).toHaveBeenCalledWith("claude"); + expect(h.gatewayClient.getSession).toHaveBeenCalledWith("conv-1"); const out = h.sendText.mock.calls[0][0]; - expect(out).toContain("模式列表"); - expect(out).toContain("`agent`"); - expect(out).toContain("`plan`"); - expect(out).toContain("`build`"); - expect(out).toContain("/mode agent"); - expect(out).not.toContain(" { + expect(await invoke(h, cmd("mode", { subcommand: "list" }))).toBe(true); + expect(h.gatewayClient.listModes).toHaveBeenCalledWith("claude"); }); - it("/mode plan calls setMode and confirms", async () => { - expect(await invoke(h, cmd("mode", { args: ["plan"] }))).toBe(true); + it("/mode calls setMode for the current session", async () => { + expect(await invoke(h, cmd("mode", { args: ["bypassPermissions"] }))).toBe(true); expect(h.gatewayClient.setMode).toHaveBeenCalledWith({ sessionId: "conv-1", - modeId: "plan", + modeId: "bypassPermissions", }); - expect(h.sendText.mock.calls[0][0]).toContain("plan"); + expect(h.sendText.mock.calls[0][0]).toContain("bypassPermissions"); + }); + + it("/mode rejects modes not exposed by the current engine", async () => { + expect(await invoke(h, cmd("mode", { args: ["autopilot"] }))).toBe(true); + expect(h.gatewayClient.setMode).not.toHaveBeenCalled(); + expect(h.sendText.mock.calls[0][0]).toContain("当前引擎支持的模式"); }); - it("/model with no args lists models", async () => { + it("/model with no args lists models and marks the current session model", async () => { expect(await invoke(h, cmd("model"))).toBe(true); expect(h.gatewayClient.listModels).toHaveBeenCalledWith("claude"); + expect(h.gatewayClient.getSession).toHaveBeenCalledWith("conv-1"); const out = h.sendText.mock.calls[0][0]; expect(out).toContain("Model One"); expect(out).toContain("`m1`"); - expect(out).toContain("(当前)"); expect(out).toContain("Model Two"); - expect(out).toContain("`m2`"); + expect(out).toContain("`m2`(当前会话)"); + expect(out).not.toContain("`m1`(当前会话)"); expect(out).toContain("/model model-id"); }); @@ -140,13 +177,89 @@ describe("handleSessionOpsCommand", () => { expect(h.gatewayClient.listModels).toHaveBeenCalled(); }); - it("/model calls setModel", async () => { - expect(await invoke(h, cmd("model", { args: ["gpt-4o"] }))).toBe(true); - expect(h.gatewayClient.setModel).toHaveBeenCalledWith({ - sessionId: "conv-1", - modelId: "gpt-4o", + it("/model updates the current session and recalibrates unsupported effort", async () => { + h.gatewayClient.getSession.mockResolvedValueOnce({ + id: "conv-1", + engineType: "claude", + directory: "/repo", + modelId: "m2", + reasoningEffort: "high", + }); + + expect(await invoke(h, cmd("model", { args: ["m1"] }))).toBe(true); + expect(h.gatewayClient.setModel).not.toHaveBeenCalled(); + expect(h.gatewayClient.updateSessionConfig).toHaveBeenCalledWith("conv-1", { + modelId: "m1", + reasoningEffort: "medium", + }); + expect(h.sendText.mock.calls[0][0]).toContain("m1"); + }); + + it("/model preserves compatible current session effort", async () => { + h.gatewayClient.getSession.mockResolvedValueOnce({ + id: "conv-1", + engineType: "claude", + directory: "/repo", + modelId: "m2", + reasoningEffort: "medium", + }); + + expect(await invoke(h, cmd("model", { args: ["m1"] }))).toBe(true); + expect(h.gatewayClient.updateSessionConfig).toHaveBeenCalledWith("conv-1", { + modelId: "m1", + reasoningEffort: "medium", + }); + }); + + it("/model clears effort when the target model has no effort support", async () => { + h.gatewayClient.getSession.mockResolvedValueOnce({ + id: "conv-1", + engineType: "claude", + directory: "/repo", + modelId: "m2", + reasoningEffort: "high", + }); + h.gatewayClient.listModels.mockResolvedValueOnce({ + models: [{ modelId: "m3", name: "Model Three" }], + currentModelId: "m1", + }); + + expect(await invoke(h, cmd("model", { args: ["m3"] }))).toBe(true); + expect(h.gatewayClient.updateSessionConfig).toHaveBeenCalledWith("conv-1", { + modelId: "m3", + reasoningEffort: null, + }); + }); + + it("/effort with no args lists efforts for the current session model", async () => { + expect(await invoke(h, cmd("effort"))).toBe(true); + expect(h.gatewayClient.listModels).toHaveBeenCalledWith("claude"); + expect(h.gatewayClient.getSession).toHaveBeenCalledWith("conv-1"); + const out = h.sendText.mock.calls[0][0]; + expect(out).toContain("当前模型:`m2`"); + expect(out).toContain("`low` · 低"); + expect(out).toContain("`medium` · 中"); + expect(out).toContain("`high` · 高(当前会话)"); + expect(out).toContain("/effort low|medium|high|max"); + }); + + it("/effort list (subcommand) lists efforts", async () => { + expect(await invoke(h, cmd("effort", { subcommand: "list" }))).toBe(true); + expect(h.gatewayClient.listModels).toHaveBeenCalled(); + }); + + it("/effort updates the current session config", async () => { + expect(await invoke(h, cmd("effort", { args: ["medium"] }))).toBe(true); + expect(h.gatewayClient.updateSessionConfig).toHaveBeenCalledWith("conv-1", { + reasoningEffort: "medium", }); - expect(h.sendText.mock.calls[0][0]).toContain("gpt-4o"); + expect(h.sendText.mock.calls[0][0]).toContain("medium"); + }); + + it("/effort rejects unsupported levels for the current model", async () => { + expect(await invoke(h, cmd("effort", { args: ["max"] }))).toBe(true); + expect(h.gatewayClient.updateSessionConfig).not.toHaveBeenCalled(); + expect(h.sendText.mock.calls[0][0]).toContain("当前模型支持的推理级别"); }); it("/history with no messages sends empty notice", async () => { diff --git a/tests/unit/electron/engines/claude/index.test.ts b/tests/unit/electron/engines/claude/index.test.ts index 33a8738..271bcaa 100644 --- a/tests/unit/electron/engines/claude/index.test.ts +++ b/tests/unit/electron/engines/claude/index.test.ts @@ -404,8 +404,8 @@ describe("ClaudeCodeAdapter", () => { it("calls setPermissionMode on live V2 session query", async () => { const mock = makeMockV2Session(); seedV2Session(adapter, "cs_1", mock); - await adapter.setMode("cs_1", "acceptEdits"); - expect(mock.query.setPermissionMode).toHaveBeenCalledWith("acceptEdits"); + await adapter.setMode("cs_1", "plan"); + expect(mock.query.setPermissionMode).toHaveBeenCalledWith("plan"); }); it("passes bypassPermissions through when the live V2 session allows skipping permissions", async () => { @@ -1905,13 +1905,10 @@ describe("ClaudeCodeAdapter", () => { // ========================================================================= describe("getModes()", () => { - it("returns Claude Code permission modes without Copilot autopilot", () => { + it("returns Claude modes that mirror Copilot autopilot without exposing Copilot-only modes", () => { const modes = adapter.getModes(); const ids = modes.map((m) => m.id); - expect(ids).toContain("default"); - expect(ids).toContain("plan"); - expect(ids).toContain("acceptEdits"); - expect(ids).toContain("bypassPermissions"); + expect(ids).toEqual(["bypassPermissions", "default", "plan"]); expect(ids).not.toContain("autopilot"); }); }); @@ -2934,14 +2931,16 @@ describe("ClaudeCodeAdapter", () => { ); }); - it("creates new session via createSession when no ccSessionId", async () => { + it("creates new sessions with bypassPermissions by default", async () => { seedSession(adapter, "cs_1"); unstable_v2_createSessionMock.mockReturnValue(makeMockV2Session()); await (adapter as any).getOrCreateV2Session("cs_1", "/repo", {}); - expect(unstable_v2_createSessionMock).toHaveBeenCalled(); + const options = unstable_v2_createSessionMock.mock.calls[0][0]; + expect(options.permissionMode).toBe("bypassPermissions"); + expect(options.allowDangerouslySkipPermissions).toBe(true); }); it("passes the native Claude executable path to created sessions", async () => { From 06979f5b22d921ac32127ff948f85c574279fe63 Mon Sep 17 00:00:00 2001 From: Duang Cheng Date: Thu, 30 Apr 2026 13:06:29 +0800 Subject: [PATCH 3/4] fix: address PR review feedback Reuse the Claude session cleanup helper when rebuilding sessions for bypass permissions. Co-Authored-By: Claude Opus 4.7 --- electron/main/engines/claude/index.ts | 20 ++------------------ 1 file changed, 2 insertions(+), 18 deletions(-) diff --git a/electron/main/engines/claude/index.ts b/electron/main/engines/claude/index.ts index 6689003..7376bed 100644 --- a/electron/main/engines/claude/index.ts +++ b/electron/main/engines/claude/index.ts @@ -1232,15 +1232,7 @@ export class ClaudeCodeAdapter extends EngineAdapter { const v2Info = this.v2Sessions.get(sessionId); if (v2Info) { if (allowsDangerouslySkipPermissions(permissionMode) && !v2Info.allowDangerouslySkipPermissions) { - if (v2Info.capturedSessionId) { - this.sessionCcIds.set(sessionId, v2Info.capturedSessionId); - } - try { - v2Info.session.close(); - } catch { - // Ignore - } - this.v2Sessions.delete(sessionId); + this.cleanupSession(sessionId, "permission mode changed to bypass permissions"); return; } @@ -1947,15 +1939,7 @@ export class ClaudeCodeAdapter extends EngineAdapter { claudeLog.info( `[Claude][${sessionId}] permissionMode changed to ${requestedMode}, recreating session with skip-permissions allowance`, ); - if (existing.capturedSessionId) { - this.sessionCcIds.set(sessionId, existing.capturedSessionId); - } - try { - existing.session.close(); - } catch { - // Ignore - } - this.v2Sessions.delete(sessionId); + this.cleanupSession(sessionId, "permission mode changed to bypass permissions"); } else { // Check if permissionMode changed — switch at runtime without destroying session if (existing.permissionMode !== requestedMode) { From 8475e39e3d249f0e7c6d6378f4c38048796999f2 Mon Sep 17 00:00:00 2001 From: Duang Cheng Date: Thu, 30 Apr 2026 13:33:05 +0800 Subject: [PATCH 4/4] fix: address PR review feedback Keep Claude session reuse defaults aligned with new-session defaults and close the session controls popover on Escape from any focused control. Co-Authored-By: Claude Opus 4.7 --- electron/main/engines/claude/index.ts | 2 +- src/components/SessionControls.tsx | 14 ++++++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/electron/main/engines/claude/index.ts b/electron/main/engines/claude/index.ts index 7376bed..a4f531e 100644 --- a/electron/main/engines/claude/index.ts +++ b/electron/main/engines/claude/index.ts @@ -1934,7 +1934,7 @@ export class ClaudeCodeAdapter extends EngineAdapter { this.pendingResumeNotice.add(sessionId); // Fall through to create a new session (ccSessionId is preserved by cleanupSession) } else { - const requestedMode = opts.permissionMode ?? "default"; + const requestedMode = opts.permissionMode ?? toClaudePermissionMode(undefined); if (allowsDangerouslySkipPermissions(requestedMode) && !existing.allowDangerouslySkipPermissions) { claudeLog.info( `[Claude][${sessionId}] permissionMode changed to ${requestedMode}, recreating session with skip-permissions allowance`, diff --git a/src/components/SessionControls.tsx b/src/components/SessionControls.tsx index 7544fa0..b97c55e 100644 --- a/src/components/SessionControls.tsx +++ b/src/components/SessionControls.tsx @@ -112,21 +112,31 @@ export function SessionControls(props: SessionControlsProps) { createEffect(() => { if (!open()) return; + const closePopover = () => { + setOpen(false); + setPosition(null); + }; + const handlePointerDown = (event: MouseEvent) => { const target = event.target; if (!(target instanceof Element)) return; if (triggerRef?.contains(target) || panelRef?.contains(target)) return; if (target.closest("#chat-model-picker-dropdown")) return; - setOpen(false); - setPosition(null); + closePopover(); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") closePopover(); }; const handleResize = () => updatePosition(); document.addEventListener("mousedown", handlePointerDown); + document.addEventListener("keydown", handleKeyDown); window.addEventListener("resize", handleResize); onCleanup(() => { document.removeEventListener("mousedown", handlePointerDown); + document.removeEventListener("keydown", handleKeyDown); window.removeEventListener("resize", handleResize); }); });