mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Add rename slash command
This commit is contained in:
parent
2f304d59b8
commit
816fff5499
11 changed files with 177 additions and 6 deletions
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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 <thread> <name>",
|
||||
argsKind: "threadAndName",
|
||||
surface: "panelAction",
|
||||
detail: "Rename the selected Codex thread.",
|
||||
},
|
||||
{
|
||||
command: "/auto-review",
|
||||
usage: "/auto-review",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ export interface SlashCommandThreadPort {
|
|||
forkThread: (threadId: string) => Promise<void>;
|
||||
rollbackThread: (threadId: string) => Promise<void>;
|
||||
archiveThread: (threadId: string) => Promise<void>;
|
||||
renameThread: (threadId: string, name: string) => Promise<void>;
|
||||
}
|
||||
|
||||
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(),
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ export interface SlashCommandExecutionContext {
|
|||
rollbackThread: (threadId: string) => Promise<void>;
|
||||
compactThread: (threadId: string) => Promise<void>;
|
||||
archiveThread: (threadId: string, saveMarkdown?: boolean) => Promise<void>;
|
||||
renameThread: (threadId: string, name: string) => Promise<void>;
|
||||
toggleFastMode: () => void | Promise<void>;
|
||||
toggleCollaborationMode: () => void | Promise<void>;
|
||||
toggleAutoReview: () => void | Promise<void>;
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -127,6 +127,28 @@ export class ThreadRenameController {
|
|||
}
|
||||
}
|
||||
|
||||
async rename(threadId: string, value: string): Promise<void> {
|
||||
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<void> {
|
||||
if (this.renameState.kind !== "editing" || this.renameState.threadId !== threadId) return;
|
||||
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ function context(overrides: Partial<SlashCommandExecutionContext> = {}): 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 <thread> <name>");
|
||||
});
|
||||
|
||||
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 <thread> <name>");
|
||||
});
|
||||
|
||||
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 <thread> - 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 <thread>", value: "Archive the selected Codex thread." },
|
||||
{ key: "/rename <thread> <name>", 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 <thread> <name> - Rename the selected Codex thread.",
|
||||
);
|
||||
});
|
||||
|
||||
it("documents refer history size", () => {
|
||||
expect(slashCommandHelpLines().find((line) => line.startsWith("/refer"))).toBe(
|
||||
"/refer <thread> <message> - Send a message with recent turns from another non-archived thread.",
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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<string | null>();
|
||||
const { controller } = controllerFixture({
|
||||
|
|
|
|||
Loading…
Reference in a new issue