From 127d72b77af3600e1cdba287fc897bf79290e44e Mon Sep 17 00:00:00 2001 From: murashit Date: Fri, 5 Jun 2026 13:41:14 +0900 Subject: [PATCH] Add reconnect slash command --- README.md | 2 +- src/features/chat/chat-view-controller-assembly.ts | 1 + src/features/chat/composer/slash-commands.ts | 7 +++++++ .../controllers/connection/reconnect-controller.ts | 2 +- .../submission/slash-command-controller.ts | 12 +++++++++--- src/features/chat/slash-commands.ts | 6 ++++++ src/features/chat/view.ts | 2 +- tests/features/chat/composer/slash-commands.test.ts | 10 ++++++++++ .../connection/reconnect-controller.test.ts | 4 ++-- .../submission/slash-command-controller.test.ts | 10 ++++++++++ 10 files changed, 48 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index b2c87fb8..97525bd2 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ Use **Codex Panel: Open thread...** to search non-archived threads from a picker Codex Panel supports Codex App Server workflows that fit a persistent panel in Obsidian: -- Manage thread history from the panel or slash commands: clear (`/clear`), resume (`/resume`), rename (`/rename`), auto-name, fork (`/fork`), roll back (`/rollback`), compact (`/compact`), and archive (`/archive`) Codex threads. +- Manage thread history from slash commands or the panel: clear (`/clear`), resume (`/resume`), reconnect (`/reconnect`), rename (`/rename`), auto-name, fork (`/fork`), roll back (`/rollback`), compact (`/compact`), and archive (`/archive`) Codex threads. - Compose with Codex-aware completions for slash commands, enabled skills (`$skill-name`), recent threads, models, and supported reasoning efforts; use `/help` to show available slash commands. - Reference another non-archived thread without switching away from the current one (`/refer`). - Control subsequent turns from slash commands, with the same state visible and lightly adjustable in the composer status row: Plan mode (`/plan`), fast mode (`/fast`), approval auto-review (`/auto-review`), model (`/model`), and reasoning effort (`/reasoning`). diff --git a/src/features/chat/chat-view-controller-assembly.ts b/src/features/chat/chat-view-controller-assembly.ts index 688c329d..1d71ff9d 100644 --- a/src/features/chat/chat-view-controller-assembly.ts +++ b/src/features/chat/chat-view-controller-assembly.ts @@ -209,6 +209,7 @@ export function createChatViewControllerAssembly(host: ChatViewControllerAssembl rollbackThread: (threadId) => threadActions.rollbackThread(threadId), archiveThread: (threadId) => threadActions.archiveThread(threadId), renameThread: (threadId, name) => threadRename.rename(threadId, name), + reconnect: () => reconnectActions.reconnectPanel(), }, runtime: { toggleFastMode: () => runtimeSettings.toggleFastMode(), diff --git a/src/features/chat/composer/slash-commands.ts b/src/features/chat/composer/slash-commands.ts index 8cb2fb0d..7bbc7c61 100644 --- a/src/features/chat/composer/slash-commands.ts +++ b/src/features/chat/composer/slash-commands.ts @@ -31,6 +31,13 @@ export const SLASH_COMMANDS = [ surface: "panelAction", detail: "Resume a recent Codex thread.", }, + { + command: "/reconnect", + usage: "/reconnect", + argsKind: "none", + surface: "panelAction", + detail: "Reconnect to Codex app-server and resume the active thread.", + }, { command: "/refer", usage: "/refer ", diff --git a/src/features/chat/controllers/connection/reconnect-controller.ts b/src/features/chat/controllers/connection/reconnect-controller.ts index 1718311f..e494729d 100644 --- a/src/features/chat/controllers/connection/reconnect-controller.ts +++ b/src/features/chat/controllers/connection/reconnect-controller.ts @@ -19,7 +19,7 @@ export interface ChatReconnectControllerHost { export class ChatReconnectController { constructor(private readonly host: ChatReconnectControllerHost) {} - async reconnectFromToolbar(): Promise { + async reconnectPanel(): Promise { const threadId = this.host.threadState.activeThreadId(); this.host.panelState.closePanels(); this.host.invalidateConnectionWork(); diff --git a/src/features/chat/controllers/submission/slash-command-controller.ts b/src/features/chat/controllers/submission/slash-command-controller.ts index 77f0416b..701dae00 100644 --- a/src/features/chat/controllers/submission/slash-command-controller.ts +++ b/src/features/chat/controllers/submission/slash-command-controller.ts @@ -19,6 +19,7 @@ export interface SlashCommandThreadPort { rollbackThread: (threadId: string) => Promise; archiveThread: (threadId: string) => Promise; renameThread: (threadId: string, name: string) => Promise; + reconnect: () => Promise; } export interface SlashCommandRuntimePort { @@ -53,18 +54,23 @@ export class SlashCommandController { constructor(private readonly host: SlashCommandControllerHost) {} async execute(command: SlashCommandName, args: string): Promise { - const client = this.host.currentClient(); - if (!client) return; const state = this.host.state.snapshot(); + const client = this.host.currentClient(); + if (!client && command !== "reconnect") return; return runSlashCommand(command, args, { activeThreadId: state.activeThreadId, listedThreads: state.listedThreads, startNewThread: () => this.host.threads.startNewThread(), resumeThread: (threadId) => this.host.threads.resumeThread(threadId), - referThread: (thread, message) => this.referencedThreadInput(client, thread, message), + reconnect: () => this.host.threads.reconnect(), + referThread: (thread, message) => { + if (!client) return Promise.resolve(null); + return this.referencedThreadInput(client, thread, message); + }, forkThread: (threadId) => this.host.threads.forkThread(threadId), rollbackThread: (threadId) => this.host.threads.rollbackThread(threadId), compactThread: async (threadId) => { + if (!client) return; await client.compactThread(threadId); }, archiveThread: (threadId) => this.host.threads.archiveThread(threadId), diff --git a/src/features/chat/slash-commands.ts b/src/features/chat/slash-commands.ts index ac0c4e59..fcc8d315 100644 --- a/src/features/chat/slash-commands.ts +++ b/src/features/chat/slash-commands.ts @@ -24,6 +24,7 @@ export interface SlashCommandExecutionContext { compactThread: (threadId: string) => Promise; archiveThread: (threadId: string, saveMarkdown?: boolean) => Promise; renameThread: (threadId: string, name: string) => Promise; + reconnect: () => Promise; toggleFastMode: () => void | Promise; toggleCollaborationMode: () => void | Promise; toggleAutoReview: () => void | Promise; @@ -76,6 +77,11 @@ export async function executeSlashCommand( return; } + if (command === "reconnect") { + await context.reconnect(); + return; + } + if (command === "refer") { const parsed = parseReferArgs(args); if (!parsed) { diff --git a/src/features/chat/view.ts b/src/features/chat/view.ts index 96f05e76..cd58c197 100644 --- a/src/features/chat/view.ts +++ b/src/features/chat/view.ts @@ -590,7 +590,7 @@ export class CodexChatView extends ItemView { toggleStatusPanel: () => { this.toolbarPanels.toggleStatus(); }, - connect: () => void this.reconnectActions.reconnectFromToolbar(), + connect: () => void this.reconnectActions.reconnectPanel(), refreshStatus: () => void this.refreshStatusPanel(), resumeThread: (threadId) => void this.threadSelection.selectThreadFromToolbar(threadId), startArchiveThread: (threadId) => { diff --git a/tests/features/chat/composer/slash-commands.test.ts b/tests/features/chat/composer/slash-commands.test.ts index 3d1e3c6d..1a0e0696 100644 --- a/tests/features/chat/composer/slash-commands.test.ts +++ b/tests/features/chat/composer/slash-commands.test.ts @@ -20,6 +20,7 @@ function context(overrides: Partial = {}): SlashCo compactThread: vi.fn().mockResolvedValue(undefined), archiveThread: vi.fn().mockResolvedValue(undefined), renameThread: vi.fn().mockResolvedValue(undefined), + reconnect: vi.fn().mockResolvedValue(undefined), toggleFastMode: vi.fn(), toggleCollaborationMode: vi.fn(), toggleAutoReview: vi.fn(), @@ -91,6 +92,14 @@ describe("slash commands", () => { expect(ctx.resumeThread).toHaveBeenCalledWith("latest"); }); + it("reconnects the panel for /reconnect", async () => { + const ctx = context(); + + await executeSlashCommand("reconnect", "", ctx); + + expect(ctx.reconnect).toHaveBeenCalledOnce(); + }); + it("resumes a thread by id argument", async () => { const ctx = context({ listedThreads: [thread({ id: "thread-alpha", name: "Alpha" }), thread({ id: "thread-beta", name: "Beta" })], @@ -363,6 +372,7 @@ describe("slash commands", () => { title: "Panel actions", rows: expect.arrayContaining([ { key: "/clear", value: "Clear the current panel and start a fresh Codex thread." }, + { key: "/reconnect", value: "Reconnect to Codex app-server and resume the active thread." }, { key: "/archive ", value: "Archive the selected Codex thread." }, { key: "/rename ", value: "Rename the selected Codex thread." }, ]), diff --git a/tests/features/chat/controllers/connection/reconnect-controller.test.ts b/tests/features/chat/controllers/connection/reconnect-controller.test.ts index 3e0a3fb2..75d51ecb 100644 --- a/tests/features/chat/controllers/connection/reconnect-controller.test.ts +++ b/tests/features/chat/controllers/connection/reconnect-controller.test.ts @@ -49,7 +49,7 @@ describe("ChatReconnectController", () => { const { host, stateStore } = createHost(); const controller = new ChatReconnectController(host); - await controller.reconnectFromToolbar(); + await controller.reconnectPanel(); expect(stateStore.getState().openDetails.size).toBe(0); expect(host.invalidateConnectionWork).toHaveBeenCalledOnce(); @@ -69,7 +69,7 @@ describe("ChatReconnectController", () => { }); const controller = new ChatReconnectController(host); - await controller.reconnectFromToolbar(); + await controller.reconnectPanel(); expect(host.addSystemMessage).toHaveBeenCalledWith("resume failed"); }); diff --git a/tests/features/chat/controllers/submission/slash-command-controller.test.ts b/tests/features/chat/controllers/submission/slash-command-controller.test.ts index 9d159e6c..47fcfd58 100644 --- a/tests/features/chat/controllers/submission/slash-command-controller.test.ts +++ b/tests/features/chat/controllers/submission/slash-command-controller.test.ts @@ -59,6 +59,7 @@ function createHost(overrides: SlashCommandHostOverrides = {}) { rollbackThread: vi.fn().mockResolvedValue(undefined), archiveThread: vi.fn().mockResolvedValue(undefined), renameThread: vi.fn().mockResolvedValue(undefined), + reconnect: vi.fn().mockResolvedValue(undefined), ...threadOverrides, }; const runtime: SlashCommandRuntimePort = { @@ -128,6 +129,15 @@ describe("SlashCommandController", () => { expect(host.status.setStatus).toHaveBeenCalledWith("Compaction requested."); }); + it("runs reconnect even when there is no current app-server client", async () => { + const { host } = createHost({ currentClient: () => null }); + const controller = new SlashCommandController(host); + + await controller.execute("reconnect", ""); + + expect(host.threads.reconnect).toHaveBeenCalledOnce(); + }); + it("reports unreadable referenced threads", async () => { const { host, stateStore, threadTurnsList } = createHost(); stateStore.dispatch({