diff --git a/README.md b/README.md index 28a3f9c8..00050c76 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, auto-name, fork (`/fork`), roll back (`/rollback`), compact (`/compact`), and archive (`/archive`) Codex threads. +- 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. - 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 the toolbar or slash commands: 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 98351b6b..d871e334 100644 --- a/src/features/chat/chat-view-controller-assembly.ts +++ b/src/features/chat/chat-view-controller-assembly.ts @@ -208,6 +208,7 @@ export function createChatViewControllerAssembly(host: ChatViewControllerAssembl forkThread: (threadId) => threadActions.forkThread(threadId), rollbackThread: (threadId) => threadActions.rollbackThread(threadId), archiveThread: (threadId) => threadActions.archiveThread(threadId), + renameThread: (threadId, name) => threadRename.rename(threadId, name), }, runtime: { toggleFastMode: () => runtimeSettings.toggleFastMode(), diff --git a/src/features/chat/composer/slash-commands.ts b/src/features/chat/composer/slash-commands.ts index 8ac218e1..8cb2fb0d 100644 --- a/src/features/chat/composer/slash-commands.ts +++ b/src/features/chat/composer/slash-commands.ts @@ -1,4 +1,11 @@ -export type SlashCommandArgsKind = "none" | "optionalThread" | "requiredThread" | "optionalMessage" | "threadAndMessage" | "showOrSet"; +export type SlashCommandArgsKind = + | "none" + | "optionalThread" + | "requiredThread" + | "optionalMessage" + | "threadAndMessage" + | "threadAndName" + | "showOrSet"; export type SlashCommandSurface = "panelAction" | "threadSetting" | "diagnostic" | "composition"; @@ -47,6 +54,13 @@ export const SLASH_COMMANDS = [ surface: "panelAction", detail: "Archive the selected Codex thread.", }, + { + command: "/rename", + usage: "/rename ", + argsKind: "threadAndName", + surface: "panelAction", + detail: "Rename the selected Codex thread.", + }, { command: "/auto-review", usage: "/auto-review", diff --git a/src/features/chat/composer/suggestions.ts b/src/features/chat/composer/suggestions.ts index a47aae93..018cd264 100644 --- a/src/features/chat/composer/suggestions.ts +++ b/src/features/chat/composer/suggestions.ts @@ -249,7 +249,7 @@ export function activeSlashCommandSuggestions(beforeCursor: string): ComposerSug } export function activeThreadCommandSuggestions(beforeCursor: string, threads: readonly Thread[]): ComposerSuggestion[] | null { - const completion = activeCommandArgumentCompletionQuery(beforeCursor, /^\/(?:resume|refer|archive)\s+([^\s\n]{0,120})$/); + const completion = activeCommandArgumentCompletionQuery(beforeCursor, /^\/(?:resume|refer|archive|rename)\s+([^\s\n]{0,120})$/); if (!completion) return null; const { query, start } = completion; diff --git a/src/features/chat/controllers/submission/slash-command-controller.ts b/src/features/chat/controllers/submission/slash-command-controller.ts index 73af6feb..77f0416b 100644 --- a/src/features/chat/controllers/submission/slash-command-controller.ts +++ b/src/features/chat/controllers/submission/slash-command-controller.ts @@ -18,6 +18,7 @@ export interface SlashCommandThreadPort { forkThread: (threadId: string) => Promise; rollbackThread: (threadId: string) => Promise; archiveThread: (threadId: string) => Promise; + renameThread: (threadId: string, name: string) => Promise; } export interface SlashCommandRuntimePort { @@ -67,6 +68,7 @@ export class SlashCommandController { await client.compactThread(threadId); }, archiveThread: (threadId) => this.host.threads.archiveThread(threadId), + renameThread: (threadId, name) => this.host.threads.renameThread(threadId, name), busy: state.busy, toggleFastMode: () => this.host.runtime.toggleFastMode(), toggleCollaborationMode: () => this.host.runtime.toggleCollaborationMode(), diff --git a/src/features/chat/slash-commands.ts b/src/features/chat/slash-commands.ts index 5117d98e..ac0c4e59 100644 --- a/src/features/chat/slash-commands.ts +++ b/src/features/chat/slash-commands.ts @@ -23,6 +23,7 @@ export interface SlashCommandExecutionContext { rollbackThread: (threadId: string) => Promise; compactThread: (threadId: string) => Promise; archiveThread: (threadId: string, saveMarkdown?: boolean) => Promise; + renameThread: (threadId: string, name: string) => Promise; toggleFastMode: () => void | Promise; toggleCollaborationMode: () => void | Promise; toggleAutoReview: () => void | Promise; @@ -147,6 +148,21 @@ export async function executeSlashCommand( return; } + if (command === "rename") { + const parsed = parseThreadAndNameArgs(args); + if (!parsed) { + context.addSystemMessage(usageError(command, "requires a thread and a name")); + return; + } + const thread = resolveThreadArgument(parsed.threadQuery, context.listedThreads); + if (!thread.ok) { + context.addSystemMessage(thread.message); + return; + } + await context.renameThread(thread.thread.id, parsed.text); + return; + } + if (command === "fast") { await context.toggleFastMode(); return; @@ -214,6 +230,7 @@ function validateSlashCommandArguments(command: SlashCommandName, args: string): if (definition.argsKind === "none" && args) return usageError(command, "does not take arguments"); if (definition.argsKind === "requiredThread" && !args) return usageError(command, "requires a thread"); if (definition.argsKind === "threadAndMessage" && !parseReferArgs(args)) return usageError(command, "requires a thread and a message"); + if (definition.argsKind === "threadAndName" && !parseThreadAndNameArgs(args)) return usageError(command, "requires a thread and a name"); return null; } @@ -242,11 +259,23 @@ function lineToRow(line: string): DisplayDetailMetaRow { type ThreadResolution = { ok: true; thread: Thread } | { ok: false; message: string }; function parseReferArgs(args: string): { threadQuery: string; message: string } | null { + const parsed = parseThreadAndTextArgs(args); + return parsed ? { threadQuery: parsed.threadQuery, message: parsed.text } : null; +} + +function parseThreadAndTextArgs(args: string): { threadQuery: string; text: string } | null { const match = /^(\S+)\s+([\s\S]*\S)\s*$/.exec(args); if (!match) return null; const threadQuery = match[1]; - const message = match[2]; - return threadQuery !== undefined && message !== undefined ? { threadQuery, message } : null; + const text = match[2]; + return threadQuery !== undefined && text !== undefined ? { threadQuery, text } : null; +} + +function parseThreadAndNameArgs(args: string): { threadQuery: string; text: string } | null { + const parsed = parseThreadAndTextArgs(args); + if (!parsed) return null; + const text = parsed.text.trim(); + return text ? { threadQuery: parsed.threadQuery, text } : null; } export function resolveThreadArgument(args: string, threads: readonly Thread[]): ThreadResolution { diff --git a/src/features/chat/thread-rename.ts b/src/features/chat/thread-rename.ts index 185cda16..fff9aefc 100644 --- a/src/features/chat/thread-rename.ts +++ b/src/features/chat/thread-rename.ts @@ -127,6 +127,28 @@ export class ThreadRenameController { } } + async rename(threadId: string, value: string): Promise { + const title = value.trim(); + if (!title) return; + + await this.host.ensureConnected(); + const client = this.host.currentClient(); + if (!client) return; + + try { + await client.setThreadName(threadId, title); + this.dispatch({ + type: "thread/list-applied", + threads: this.state.listedThreads.map((thread) => (thread.id === threadId ? { ...thread, name: title } : thread)), + }); + this.host.notifyThreadRenamed(threadId, title); + } catch (error) { + this.host.addSystemMessage(error instanceof Error ? error.message : String(error)); + } finally { + this.host.render(); + } + } + async autoNameDraft(threadId: string): Promise { if (this.renameState.kind !== "editing" || this.renameState.threadId !== threadId) return; diff --git a/tests/features/chat/composer/composer-suggestions.test.ts b/tests/features/chat/composer/composer-suggestions.test.ts index 242a2c5d..7a6357cb 100644 --- a/tests/features/chat/composer/composer-suggestions.test.ts +++ b/tests/features/chat/composer/composer-suggestions.test.ts @@ -135,6 +135,7 @@ describe("composer suggestions", () => { expect(parseSlashCommand("/refer thread-1 続きです")).toEqual({ command: "refer", args: "thread-1 続きです" }); expect(parseSlashCommand("/fork")).toEqual({ command: "fork", args: "" }); expect(parseSlashCommand("/archive thread-1")).toEqual({ command: "archive", args: "thread-1" }); + expect(parseSlashCommand("/rename thread-1 New name")).toEqual({ command: "rename", args: "thread-1 New name" }); expect(parseSlashCommand("/doctor")).toEqual({ command: "doctor", args: "" }); expect(parseSlashCommand("/fast now")).toEqual({ command: "fast", args: "now" }); expect(parseSlashCommand("/plan")).toEqual({ command: "plan", args: "" }); @@ -240,7 +241,7 @@ describe("composer suggestions", () => { expect(activeComposerSuggestions("please\n/reasoning h", notes, [], [], models, "gpt-5.5")).toEqual([]); }); - it("suggests recent threads for /resume, /refer, and /archive arguments", () => { + it("suggests recent threads for thread slash command arguments", () => { const threads = [ thread({ id: "019abcde-0000-7000-8000-000000000001", name: "Codex Panel実装" }), thread({ id: "019abcde-0000-7000-8000-000000000002", name: "別件" }), @@ -276,6 +277,23 @@ describe("composer suggestions", () => { appendSpaceOnInsert: true, }); expect(activeComposerSuggestions("/archive 019abcde-0000-7000-8000-000000000001 ", notes, [], threads)).toEqual([]); + expect(activeComposerSuggestions("/rename codex", notes, [], threads)[0]).toMatchObject({ + display: "Codex Panel実装", + detail: "019abcde", + replacement: "019abcde-0000-7000-8000-000000000001", + appendSpaceOnInsert: true, + }); + expect( + applyComposerSuggestionInsertion( + "/rename codex", + 13, + expectPresent(activeComposerSuggestions("/rename codex", notes, [], threads)[0]), + ), + ).toEqual({ + value: "/rename 019abcde-0000-7000-8000-000000000001 ", + cursor: 45, + }); + expect(activeComposerSuggestions("/rename 019abcde-0000-7000-8000-000000000001 New name", notes, [], threads)).toEqual([]); }); it("does not suggest threads for /fork arguments", () => { diff --git a/tests/features/chat/composer/slash-commands.test.ts b/tests/features/chat/composer/slash-commands.test.ts index 73fd8bad..3d1e3c6d 100644 --- a/tests/features/chat/composer/slash-commands.test.ts +++ b/tests/features/chat/composer/slash-commands.test.ts @@ -19,6 +19,7 @@ function context(overrides: Partial = {}): SlashCo rollbackThread: vi.fn().mockResolvedValue(undefined), compactThread: vi.fn().mockResolvedValue(undefined), archiveThread: vi.fn().mockResolvedValue(undefined), + renameThread: vi.fn().mockResolvedValue(undefined), toggleFastMode: vi.fn(), toggleCollaborationMode: vi.fn(), toggleAutoReview: vi.fn(), @@ -292,6 +293,55 @@ describe("slash commands", () => { expect(ctx.addSystemMessage).toHaveBeenCalledWith("Multiple matching threads: Draft, Draft notes"); }); + it("renames a selected thread by id argument", async () => { + const ctx = context({ + listedThreads: [thread({ id: "thread-alpha", name: "Alpha" }), thread({ id: "thread-beta", name: "Beta" })], + }); + + await executeSlashCommand("rename", "thread-beta New Beta Name", ctx); + + expect(ctx.renameThread).toHaveBeenCalledWith("thread-beta", "New Beta Name"); + }); + + it("trims /rename names before saving", async () => { + const ctx = context({ + listedThreads: [thread({ id: "thread-beta", name: "Beta" })], + }); + + await executeSlashCommand("rename", "thread-beta New Beta Name ", ctx); + + expect(ctx.renameThread).toHaveBeenCalledWith("thread-beta", "New Beta Name"); + }); + + it("rejects /rename without a thread and name", async () => { + const ctx = context(); + + await executeSlashCommand("rename", "thread-1", ctx); + + expect(ctx.renameThread).not.toHaveBeenCalled(); + expect(ctx.addSystemMessage).toHaveBeenCalledWith("/rename requires a thread and a name. Usage: /rename "); + }); + + it("rejects blank /rename names after trimming", async () => { + const ctx = context(); + + await executeSlashCommand("rename", "thread-1 ", ctx); + + expect(ctx.renameThread).not.toHaveBeenCalled(); + expect(ctx.addSystemMessage).toHaveBeenCalledWith("/rename requires a thread and a name. Usage: /rename "); + }); + + it("reports ambiguous rename matches", async () => { + const ctx = context({ + listedThreads: [thread({ id: "thread-alpha", name: "Draft" }), thread({ id: "thread-beta", name: "Draft notes" })], + }); + + await executeSlashCommand("rename", "Draft New name", ctx); + + expect(ctx.renameThread).not.toHaveBeenCalled(); + expect(ctx.addSystemMessage).toHaveBeenCalledWith("Multiple matching threads: Draft, Draft notes"); + }); + it("documents archive", () => { expect(slashCommandHelpLines().find((line) => line.startsWith("/archive"))).toBe( "/archive - Archive the selected Codex thread.", @@ -314,6 +364,7 @@ describe("slash commands", () => { rows: expect.arrayContaining([ { key: "/clear", value: "Clear the current panel and start a fresh Codex thread." }, { key: "/archive ", value: "Archive the selected Codex thread." }, + { key: "/rename ", value: "Rename the selected Codex thread." }, ]), }, { @@ -408,6 +459,12 @@ describe("slash commands", () => { ); }); + it("documents rename", () => { + expect(slashCommandHelpLines().find((line) => line.startsWith("/rename"))).toBe( + "/rename - Rename the selected Codex thread.", + ); + }); + it("documents refer history size", () => { expect(slashCommandHelpLines().find((line) => line.startsWith("/refer"))).toBe( "/refer - Send a message with recent turns from another non-archived thread.", 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 37e26435..9d159e6c 100644 --- a/tests/features/chat/controllers/submission/slash-command-controller.test.ts +++ b/tests/features/chat/controllers/submission/slash-command-controller.test.ts @@ -58,6 +58,7 @@ function createHost(overrides: SlashCommandHostOverrides = {}) { forkThread: vi.fn().mockResolvedValue(undefined), rollbackThread: vi.fn().mockResolvedValue(undefined), archiveThread: vi.fn().mockResolvedValue(undefined), + renameThread: vi.fn().mockResolvedValue(undefined), ...threadOverrides, }; const runtime: SlashCommandRuntimePort = { diff --git a/tests/features/chat/thread-rename.test.ts b/tests/features/chat/thread-rename.test.ts index e748b8e3..9b6a8ffd 100644 --- a/tests/features/chat/thread-rename.test.ts +++ b/tests/features/chat/thread-rename.test.ts @@ -69,6 +69,33 @@ describe("ThreadRenameController", () => { expect(controller.editState("thread")).toBeNull(); }); + it("renames a thread without entering inline edit state", async () => { + const setThreadName = vi.fn().mockResolvedValue({}); + const client = fakeClient({ setThreadName }); + const { controller, notifyThreadRenamed, stateStore } = controllerFixture({ + currentClient: () => client, + }); + + await controller.rename("thread", " Slash command title "); + + expect(setThreadName).toHaveBeenCalledWith("thread", "Slash command title"); + expect(stateStore.getState().listedThreads[0]?.name).toBe("Slash command title"); + expect(notifyThreadRenamed).toHaveBeenCalledWith("thread", "Slash command title"); + expect(controller.editState("thread")).toBeNull(); + }); + + it("ignores empty slash command rename titles", async () => { + const setThreadName = vi.fn().mockResolvedValue({}); + const client = fakeClient({ setThreadName }); + const { controller } = controllerFixture({ + currentClient: () => client, + }); + + await controller.rename("thread", " "); + + expect(setThreadName).not.toHaveBeenCalled(); + }); + it("keeps an edited draft when auto-name generation finishes later", async () => { const generatedTitle = deferred(); const { controller } = controllerFixture({