diff --git a/eslint.config.js b/eslint.config.js index 2936948..4aa0b16 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -48,15 +48,9 @@ export default [ "@typescript-eslint/no-explicit-any": "warn", // Permits `as never` to bridge SDK gaps for unstable elicitation schema. "@typescript-eslint/no-non-null-assertion": "off", - // Member sort within an import spec; statement ordering is enforced by - // review + the import-order convention in CONTRIBUTING.md. - "sort-imports": [ - "warn", - { - ignoreCase: true, - memberSyntaxSortOrder: ["none", "all", "multiple", "single"], - }, - ], + // Import ordering/member sorting is left to review + prettier; the + // rule produces noisy warnings without catching real bugs. + "sort-imports": "off", }, }, ]; diff --git a/src/handlers/server-requests.ts b/src/handlers/server-requests.ts index 2dc9733..b646c8e 100644 --- a/src/handlers/server-requests.ts +++ b/src/handlers/server-requests.ts @@ -37,6 +37,7 @@ import { splitAskUserQuestions, zcodePermissionToAcp, } from "../interaction/adapter.js"; +import { buildConfigOptions, buildModes } from "../config/options.js"; import { log, warn } from "../utils.js"; import type { PendingTurn, ZcodeAcpServer } from "../server.js"; import { sendSessionUpdate } from "./io.js"; @@ -47,6 +48,44 @@ interface DedupEntry { result?: ZcodeInteractionResponse; } +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +/** + * Re-read the authoritative session mode and push a `config_option_update` + * with the mode item's currentValue set to it. + * + * Used for ExitPlanMode reconciliation: Zed's `config_state()` drops + * `session_modes` to None whenever `session/new` returns configOptions (which + * the bridge always sends), so subsequent `current_mode_update` notifications + * are silently ignored — only `config_option_update` drives the dropdown. + * + * Always emits (no dedup): when the user manually switched to plan via the + * dropdown (session/set_mode), `lastMode` can lag the real mode and a dedup + * check would wrongly suppress the post-exit update. + */ +async function emitModeViaConfigOption( + server: ZcodeAcpServer, + cx: acp.AgentContext, + acpSid: string, + zcodeSid: string, +): Promise { + try { + const modes = await buildModes(server, zcodeSid); + server.lastMode.set(acpSid, modes.currentModeId); + const options = await buildConfigOptions(server, zcodeSid); + const modeOpt = options.find((o) => o.id === "mode"); + if (modeOpt) modeOpt.currentValue = modes.currentModeId; + await sendSessionUpdate(cx, acpSid, { + sessionUpdate: "config_option_update", + configOptions: options, + }); + } catch (e) { + warn(`emitModeViaConfigOption failed: ${e instanceof Error ? e.message : String(e)}`); + } +} + /** Per-server reannounce dedup state (lazy-initialised). */ export function getPendingInteractions(server: ZcodeAcpServer): Map { const existing = (server as unknown as { _pendingInteractions?: Map }) @@ -174,6 +213,22 @@ async function handleOne( // Reply to the first zcode id + all reannounced ones, and cache for late reannounces. sendInteractionReply(backend, pending, dedupKey, zcodeReqId, zcodeResp); + + // ExitPlanMode approval switches the session mode (plan → build/etc.), but + // the backend applies it asynchronously — an immediate session/read still + // sees the pre-exit mode. Probe once after a delay to read the real post-exit + // mode, then push it via config_option_update: Zed's config_state() drops + // session_modes to None when session/new returns configOptions, so + // current_mode_update is silently ignored — only config_option_update drives + // the mode dropdown's selection. + if (epm && (zcodeResp as { action?: string }).action === "accept") { + const zcodeSid = server.resolveSid(acpSid); + if (zcodeSid) { + void sleep(1000).then(() => + emitModeViaConfigOption(server, cx, acpSid, zcodeSid).catch(() => {}), + ); + } + } } /** Single requestPermission (tool auth / ExitPlanMode). */ diff --git a/src/handlers/session.ts b/src/handlers/session.ts index fb8baad..0b86ccb 100644 --- a/src/handlers/session.ts +++ b/src/handlers/session.ts @@ -868,7 +868,7 @@ async function runEventTurn( // Fire edit-diff and plan-sync in parallel — they hit independent // backend methods (session/messages vs session/read) so there's no // ordering dependency between them. - await Promise.all([ + const sideTasks: Promise[] = [ dispatchEditDiff( server, cx, @@ -882,7 +882,14 @@ async function runEventTurn( // editor doesn't lag behind — without this, TODO changes only surface at // turn completion, which can be delayed by the model's remaining output. dispatchPlanIfChanged(server, cx, acpSid, turn.zcodeSid, differ, chunkMsgId), - ]); + ]; + // EnterPlanMode switches the session mode mid-turn without a + // session/setMode notification; reconcile immediately so the editor's + // mode indicator flips without waiting for turn completion. + if (translator.toolNames.get(payload.toolCallId) === "EnterPlanMode") { + sideTasks.push(emitModeIfChanged(server, cx, acpSid, turn.zcodeSid)); + } + await Promise.all(sideTasks); } } @@ -1020,7 +1027,7 @@ async function buildSnapshot(server: ZcodeAcpServer, zcodeSid: string): Promise< * emit no notification of their own. Best-effort: failures are logged and * swallowed so they never break the turn-completion path. */ -async function emitModeIfChanged( +export async function emitModeIfChanged( server: ZcodeAcpServer, cx: acp.AgentContext, acpSid: string,