diff --git a/src/features/chat/chat-view-controller-assembly.ts b/src/features/chat/chat-view-controller-assembly.ts index 8f91af29..64dae139 100644 --- a/src/features/chat/chat-view-controller-assembly.ts +++ b/src/features/chat/chat-view-controller-assembly.ts @@ -362,6 +362,7 @@ export function createChatViewControllerAssembly(host: ChatViewControllerAssembl resetThreadTurnPresence: host.effects.thread.resetTurnPresence, setStatus: host.effects.status.set, addSystemMessage: host.effects.status.addSystemMessage, + configuredCommand: () => host.plugin.settings.codexPath, refreshLiveState: host.effects.liveState.refresh, render: host.effects.render.now, scheduleRender: host.effects.render.schedule, diff --git a/src/features/chat/controllers/connection/connection-controller.ts b/src/features/chat/controllers/connection/connection-controller.ts index 84816787..ffaea04c 100644 --- a/src/features/chat/controllers/connection/connection-controller.ts +++ b/src/features/chat/controllers/connection/connection-controller.ts @@ -25,6 +25,7 @@ export interface ChatConnectionControllerHost { resetThreadTurnPresence: (hadTurns: boolean) => void; setStatus: (status: string) => void; addSystemMessage: (text: string) => void; + configuredCommand: () => string; refreshLiveState: () => void; render: () => void; scheduleRender: () => void; @@ -125,7 +126,7 @@ export class ChatConnectionController { if (this.host.connectionWork.isStale(connection)) return; if (error instanceof StaleConnectionError) return; this.host.setStatus("Connection failed."); - this.host.addSystemMessage(error instanceof Error ? error.message : String(error)); + this.host.addSystemMessage(connectionErrorMessage(error, this.host.configuredCommand())); this.host.notifyConnectionFailed(); } if (!this.host.connectionWork.isStale(connection)) { @@ -137,3 +138,15 @@ export class ChatConnectionController { this.host.stateStore.dispatch(action); } } + +function connectionErrorMessage(error: unknown, configuredCommand: string): string { + const message = error instanceof Error ? error.message : String(error); + if (!isMissingCommandError(error)) return message; + return `Could not start Codex app-server because the configured command was not found: ${configuredCommand}. Check the Codex command path in settings. (${message})`; +} + +function isMissingCommandError(error: unknown): boolean { + if (!(error instanceof Error)) return false; + const candidate = error as { code?: unknown; syscall?: unknown }; + return candidate.code === "ENOENT" && candidate.syscall === "spawn"; +} diff --git a/src/features/chat/view-model.ts b/src/features/chat/view-model.ts index 02b8b991..94d3bce1 100644 --- a/src/features/chat/view-model.ts +++ b/src/features/chat/view-model.ts @@ -50,6 +50,7 @@ export interface StatusDotStateInput { connected: boolean; turnBusy: boolean; diagnostics: AppServerDiagnostics; + connectionFailed?: boolean; turnStartBlocked?: boolean; } @@ -156,6 +157,7 @@ export function toolbarViewModel(input: ToolbarViewModelInput): ToolbarViewModel connected: input.connected, turnBusy: input.turnBusy, diagnostics: state.appServerDiagnostics, + connectionFailed: state.status === "Connection failed.", }); const model = currentModel(snapshot, config); const effort = currentReasoningEffort(snapshot, config); @@ -197,6 +199,7 @@ export function toolbarViewModel(input: ToolbarViewModelInput): ToolbarViewModel export function statusDotState(input: StatusDotStateInput): ToolbarStatusState { if (input.turnBusy) return "running"; + if (input.connectionFailed) return "blocked"; if (!input.connected) return "offline"; if (input.turnStartBlocked) return "blocked"; return hasDiagnosticIssue(input.diagnostics) ? "degraded" : "ready"; diff --git a/tests/features/chat/controllers/connection/connection-controller.test.ts b/tests/features/chat/controllers/connection/connection-controller.test.ts index ccb667ad..fd9ce432 100644 --- a/tests/features/chat/controllers/connection/connection-controller.test.ts +++ b/tests/features/chat/controllers/connection/connection-controller.test.ts @@ -46,6 +46,7 @@ function createController({ connected = false, client = {} as AppServerClient } resetThreadTurnPresence: vi.fn(), setStatus: vi.fn(), addSystemMessage: vi.fn(), + configuredCommand: () => "codex", refreshLiveState: vi.fn(), render: vi.fn(), scheduleRender: vi.fn(), @@ -117,4 +118,18 @@ describe("ChatConnectionController", () => { runtimePicker: null, }); }); + + it("explains missing configured command failures", async () => { + const { controller, connect, host } = createController(); + const error = Object.assign(new Error("spawn codex ENOENT"), { code: "ENOENT", syscall: "spawn" }); + connect.mockRejectedValueOnce(error); + + await controller.ensureConnected(); + + expect(host.setStatus).toHaveBeenCalledWith("Connection failed."); + expect(host.addSystemMessage).toHaveBeenCalledWith( + "Could not start Codex app-server because the configured command was not found: codex. Check the Codex command path in settings. (spawn codex ENOENT)", + ); + expect(host.notifyConnectionFailed).toHaveBeenCalledOnce(); + }); }); diff --git a/tests/features/chat/view-model.test.ts b/tests/features/chat/view-model.test.ts index 6d70688e..5775fed6 100644 --- a/tests/features/chat/view-model.test.ts +++ b/tests/features/chat/view-model.test.ts @@ -81,6 +81,7 @@ describe("chat view model", () => { expect(statusDotState({ connected: true, turnBusy: true, diagnostics })).toBe("running"); expect(statusDotState({ connected: false, turnBusy: false, diagnostics })).toBe("offline"); + expect(statusDotState({ connected: false, turnBusy: false, diagnostics, connectionFailed: true })).toBe("blocked"); expect(statusDotState({ connected: true, turnBusy: false, diagnostics })).toBe("ready"); expect(statusDotState({ connected: true, turnBusy: false, diagnostics, turnStartBlocked: true })).toBe("blocked"); }); @@ -138,6 +139,27 @@ describe("chat view model", () => { expect(runningModel.statusState).toBe("running"); }); + it("marks connection failure status as blocked in the toolbar model", () => { + const state = createChatState(); + state.status = "Connection failed."; + + const model = toolbarViewModel({ + state, + snapshot: runtimeSnapshotForChatState({ state }), + connected: false, + turnBusy: false, + vaultPath: "/vault", + configuredCommand: "codex", + archiveConfirmThreadId: null, + archiveExportEnabled: true, + modelChoices: [], + effortChoices: [], + renameState: () => null, + }); + + expect(model.statusState).toBe("blocked"); + }); + it("builds slash-command status lines from chat state", () => { const state = createChatState(); state.activeThreadId = "thread-1";