Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 3 additions & 9 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
},
];
55 changes: 55 additions & 0 deletions src/handlers/server-requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -47,6 +48,44 @@ interface DedupEntry {
result?: ZcodeInteractionResponse;
}

function sleep(ms: number): Promise<void> {
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<void> {
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<string, DedupEntry> {
const existing = (server as unknown as { _pendingInteractions?: Map<string, DedupEntry> })
Expand Down Expand Up @@ -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). */
Expand Down
13 changes: 10 additions & 3 deletions src/handlers/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>[] = [
dispatchEditDiff(
server,
cx,
Expand All @@ -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);
}
}

Expand Down Expand Up @@ -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,
Expand Down
Loading