Add thread slash commands

This commit is contained in:
murashit 2026-05-13 15:41:48 +09:00
parent 95d2735a81
commit 5797313da0
8 changed files with 331 additions and 17 deletions

View file

@ -28,8 +28,8 @@ The plugin depends on the experimental `codex app-server` API. Later Codex CLI r
https://github.com/murashit/codex-panel
```
4. Enable `Codex Panel` from Obsidian's Community plugins list.
5. Open the panel from the ribbon or command palette.
1. Enable `Codex Panel` from Obsidian's Community plugins list.
2. Open the panel from the ribbon or command palette.
### Manual
@ -72,6 +72,9 @@ The status dot in the panel opens connection controls, diagnostics, usage limits
## Slash Commands
- `/new`: start a new Codex thread. Add text after the command to start the new thread and send that text immediately.
- `/resume`: resume a recent Codex thread. Add a thread ID or use composer suggestions after `/resume` to choose a thread.
- `/fork`: fork the active Codex thread.
- `/compact`: request context compaction for the active thread.
- `/fast`: toggle fast service tier for subsequent turns.
- `/plan`: toggle Plan mode for subsequent turns. Add text after the command to toggle mode and send that text immediately, for example `/plan OK, implement it`.

View file

@ -10,6 +10,7 @@ import type { HooksListResponse } from "../generated/app-server/v2/HooksListResp
import type { ModelListResponse } from "../generated/app-server/v2/ModelListResponse";
import type { SkillsListResponse } from "../generated/app-server/v2/SkillsListResponse";
import type { ThreadArchiveResponse } from "../generated/app-server/v2/ThreadArchiveResponse";
import type { ThreadForkResponse } from "../generated/app-server/v2/ThreadForkResponse";
import type { ThreadListResponse } from "../generated/app-server/v2/ThreadListResponse";
import type { ThreadResumeResponse } from "../generated/app-server/v2/ThreadResumeResponse";
import type { ThreadSetNameResponse } from "../generated/app-server/v2/ThreadSetNameResponse";
@ -47,6 +48,7 @@ interface ClientResponseByMethod {
"hooks/list": HooksListResponse;
"thread/start": ThreadStartResponse;
"thread/resume": ThreadResumeResponse;
"thread/fork": ThreadForkResponse;
"thread/list": ThreadListResponse;
"thread/archive": ThreadArchiveResponse;
"thread/unarchive": ThreadUnarchiveResponse;
@ -193,6 +195,15 @@ export class AppServerClient {
});
}
forkThread(threadId: string, cwd: string): Promise<ThreadForkResponse> {
return this.request("thread/fork", {
threadId,
cwd,
excludeTurns: true,
persistExtendedHistory: false,
});
}
listThreads(cwd: string, archived = false): Promise<ThreadListResponse> {
return this.request("thread/list", {
cwd,

View file

@ -1,5 +1,8 @@
import type { SkillMetadata } from "../generated/app-server/v2/SkillMetadata";
import type { Thread } from "../generated/app-server/v2/Thread";
import { SLASH_COMMANDS, type SlashCommandName } from "../panel/slash-commands";
import { getThreadTitle } from "../threads";
import { shortThreadId } from "../utils";
export interface ComposerSuggestion {
display: string;
@ -23,9 +26,15 @@ export function parseSlashCommand(text: string): { command: SlashCommandName; ar
return { command, args: match[2]?.trim() ?? "" };
}
export function activeComposerSuggestions(beforeCursor: string, notes: NoteCandidate[], skills: SkillMetadata[]): ComposerSuggestion[] {
export function activeComposerSuggestions(
beforeCursor: string,
notes: NoteCandidate[],
skills: SkillMetadata[],
threads: Thread[] = [],
): ComposerSuggestion[] {
return (
activeWikiLinkSuggestions(beforeCursor, notes) ??
activeThreadResumeSuggestions(beforeCursor, threads) ??
activeSlashCommandSuggestions(beforeCursor) ??
activeSkillSuggestions(beforeCursor, skills) ??
[]
@ -119,6 +128,32 @@ export function activeSlashCommandSuggestions(beforeCursor: string): ComposerSug
}));
}
export function activeThreadResumeSuggestions(beforeCursor: string, threads: Thread[]): ComposerSuggestion[] | null {
const match = beforeCursor.match(/(?:^|\n)\/resume\s+([^\n]{0,120})$/);
if (!match || match.index === undefined) return null;
const query = (match[1] ?? "").trim().toLowerCase();
const start = beforeCursor.length - (match[1]?.length ?? 0);
return threads
.map((thread, index) => {
const title = getThreadTitle(thread);
const id = thread.id.toLowerCase();
const normalizedTitle = title.toLowerCase();
const score = query.length === 0 ? 2 : id.startsWith(query) ? 0 : normalizedTitle.includes(query) ? 1 : id.includes(query) ? 2 : -1;
return { thread, title, score, index };
})
.filter((item) => item.score !== -1)
.sort((a, b) => a.score - b.score || a.index - b.index || a.title.localeCompare(b.title))
.slice(0, 8)
.map(({ thread, title }) => ({
display: title,
detail: shortThreadId(thread.id),
replacement: thread.id,
start,
appendSpaceOnInsert: true,
}));
}
export function activeSkillSuggestions(beforeCursor: string, skills: SkillMetadata[]): ComposerSuggestion[] | null {
const match = beforeCursor.match(/(^|[\s([{])\$([^\s\])}]{0,120})$/);
if (!match || match.index === undefined) return null;

View file

@ -511,6 +511,10 @@ class CodexPanelView extends ItemView {
if (!this.client) return;
return runSlashCommand(command, args, {
activeThreadId: this.state.activeThreadId,
listedThreads: this.state.listedThreads,
startNewThread: () => this.startNewThread(),
resumeThread: (threadId) => this.resumeThread(threadId),
forkThread: (threadId) => this.forkThread(threadId),
compactThread: async (threadId) => {
await this.client?.compactThread(threadId);
},
@ -977,6 +981,39 @@ class CodexPanelView extends ItemView {
}
}
private async forkThread(threadId: string): Promise<void> {
if (this.state.busy) {
this.addSystemMessage("Finish or interrupt the current turn before forking threads.");
return;
}
await this.ensureConnected();
if (!this.client) return;
try {
const response = await this.client.forkThread(threadId, this.plugin.vaultPath);
this.state.activeThreadId = response.thread.id;
this.state.activeThreadCwd = response.cwd ?? response.thread.cwd ?? this.plugin.vaultPath;
this.state.activeTurnId = null;
this.state.activeModel = response.model ?? null;
this.state.activeServiceTier = response.serviceTier ?? null;
this.state.activeThreadCliVersion = response.thread.cliVersion ?? null;
this.state.tokenUsage = null;
this.state.displayItems = [];
this.state.historyCursor = null;
this.threadRename.resetThreadTurnPresence(false);
this.forceMessagesToBottom();
await this.refreshThreads();
await this.history.loadLatest(response.thread.id);
if (this.state.displayItems.length === 0) {
this.state.displayItems.push(this.systemItem(`Forked thread ${response.thread.id}`));
this.forceMessagesToBottom();
}
this.render();
} catch (error) {
this.addSystemMessage(error instanceof Error ? error.message : String(error));
}
}
private forceMessagesToBottom(): void {
this.state.messagesPinnedToBottom = true;
this.forceScrollMessagesToBottomOnNextRender = true;
@ -1202,7 +1239,12 @@ class CodexPanelView extends ItemView {
const cursor = this.composer.selectionStart;
const beforeCursor = this.composer.value.slice(0, cursor);
const suggestions = activeComposerSuggestions(beforeCursor, this.noteCandidates(), this.state.availableSkills);
const suggestions = activeComposerSuggestions(
beforeCursor,
this.noteCandidates(),
this.state.availableSkills,
this.state.listedThreads,
);
this.state.composerSuggestions = suggestions;
if (this.state.composerSuggestSelected >= this.state.composerSuggestions.length) {

View file

@ -1,7 +1,12 @@
import type { ReasoningEffort } from "../generated/app-server/ReasoningEffort";
import type { Thread } from "../generated/app-server/v2/Thread";
import { getThreadTitle } from "../threads";
import { modelOverrideMessage, parseModelOverride, parseReasoningEffortOverride, reasoningEffortOverrideMessage } from "./runtime-settings";
export const SLASH_COMMANDS = [
{ command: "/new", detail: "Start a new Codex thread, optionally sending a message." },
{ command: "/resume", detail: "Resume a recent Codex thread." },
{ command: "/fork", detail: "Fork the active Codex thread." },
{ command: "/compact", detail: "Compact the current conversation context." },
{ command: "/fast", detail: "Toggle fast service tier for subsequent turns." },
{ command: "/plan", detail: "Toggle Plan mode, optionally sending a message." },
@ -22,6 +27,10 @@ export function slashCommandHelpLines(): string[] {
export interface SlashCommandExecutionContext {
activeThreadId: string | null;
listedThreads: Thread[];
startNewThread: () => Promise<void>;
resumeThread: (threadId: string) => Promise<void>;
forkThread: (threadId: string) => Promise<void>;
compactThread: (threadId: string) => Promise<void>;
toggleFastMode: () => void;
toggleCollaborationMode: () => void;
@ -44,6 +53,35 @@ export async function executeSlashCommand(
args: string,
context: SlashCommandExecutionContext,
): Promise<SlashCommandExecutionResult | void> {
if (command === "new") {
await context.startNewThread();
if (args) return { sendText: args };
return;
}
if (command === "resume") {
const thread = resolveThreadArgument(args, context.listedThreads);
if (!thread.ok) {
context.addSystemMessage(thread.message);
return;
}
await context.resumeThread(thread.thread.id);
return;
}
if (command === "fork") {
if (args) {
context.addSystemMessage(`Unsupported slash command arguments: ${args}`);
return;
}
if (!context.activeThreadId) {
context.addSystemMessage("No active thread to fork.");
return;
}
await context.forkThread(context.activeThreadId);
return;
}
if (command === "compact") {
if (!context.activeThreadId) {
context.addSystemMessage("No active thread to compact.");
@ -115,3 +153,24 @@ export async function executeSlashCommand(
context.addSystemMessage(`Unsupported slash command arguments: ${args}`);
}
}
type ThreadResolution = { ok: true; thread: Thread } | { ok: false; message: string };
export function resolveThreadArgument(args: string, threads: Thread[]): ThreadResolution {
const query = args.trim();
if (!query) {
const thread = threads[0];
return thread ? { ok: true, thread } : { ok: false, message: "No recent threads to resume." };
}
const idMatches = threads.filter((thread) => thread.id === query || thread.id.startsWith(query));
if (idMatches.length === 1) return { ok: true, thread: idMatches[0] };
if (idMatches.length > 1) return { ok: false, message: `Multiple matching threads: ${idMatches.map((thread) => thread.id).join(", ")}` };
const titleQuery = query.toLowerCase();
const titleMatches = threads.filter((thread) => getThreadTitle(thread).toLowerCase().includes(titleQuery));
if (titleMatches.length === 1) return { ok: true, thread: titleMatches[0] };
if (titleMatches.length > 1) return { ok: false, message: `Multiple matching threads: ${titleMatches.map(getThreadTitle).join(", ")}` };
return { ok: false, message: `No matching thread: ${query}` };
}

View file

@ -332,32 +332,41 @@ describe("AppServerClient", () => {
transport.emitLine({ id: 3, result: { thread: { id: "thread-1", title: null }, cwd: "/vault" } });
await resuming;
const skills = client.listSkills("/vault");
const forking = client.forkThread("thread-1", "/vault");
expect(transport.sent[4]).toMatchObject({
id: 4,
method: "thread/fork",
params: { threadId: "thread-1", cwd: "/vault", excludeTurns: true, persistExtendedHistory: false },
});
transport.emitLine({ id: 4, result: { thread: { id: "thread-2", title: null }, cwd: "/vault" } });
await forking;
const skills = client.listSkills("/vault");
expect(transport.sent[5]).toMatchObject({
id: 5,
method: "skills/list",
params: { cwds: ["/vault"], forceReload: false },
});
expect((transport.sent[4] as { params?: Record<string, unknown> }).params?.perCwdExtraUserRoots).toBeUndefined();
transport.emitLine({ id: 4, result: { data: [] } });
expect((transport.sent[5] as { params?: Record<string, unknown> }).params?.perCwdExtraUserRoots).toBeUndefined();
transport.emitLine({ id: 5, result: { data: [] } });
await skills;
const turns = client.threadTurnsList("thread-1", "cursor-1", 10);
expect(transport.sent[5]).toMatchObject({
id: 5,
method: "thread/turns/list",
params: { threadId: "thread-1", cursor: "cursor-1", limit: 10, sortDirection: "desc", itemsView: "full" },
});
transport.emitLine({ id: 5, result: { data: [], nextCursor: null } });
await turns;
const firstTurn = client.threadTurnsList("thread-1", null, 1, "asc");
expect(transport.sent[6]).toMatchObject({
id: 6,
method: "thread/turns/list",
params: { threadId: "thread-1", cursor: null, limit: 1, sortDirection: "asc", itemsView: "full" },
params: { threadId: "thread-1", cursor: "cursor-1", limit: 10, sortDirection: "desc", itemsView: "full" },
});
transport.emitLine({ id: 6, result: { data: [], nextCursor: null } });
await turns;
const firstTurn = client.threadTurnsList("thread-1", null, 1, "asc");
expect(transport.sent[7]).toMatchObject({
id: 7,
method: "thread/turns/list",
params: { threadId: "thread-1", cursor: null, limit: 1, sortDirection: "asc", itemsView: "full" },
});
transport.emitLine({ id: 7, result: { data: [], nextCursor: null } });
await firstTurn;
});

View file

@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import type { Thread } from "../src/generated/app-server/v2/Thread";
import {
activeComposerSuggestions,
applyComposerSuggestionInsertion,
@ -9,6 +10,31 @@ import {
parseSlashCommand,
} from "../src/composer/suggestions";
function thread(overrides: Partial<Thread> = {}): Thread {
return {
id: "019abcde-0000-7000-8000-000000000001",
sessionId: "session-1",
forkedFromId: null,
preview: "Preview",
ephemeral: false,
modelProvider: "openai",
createdAt: 1,
updatedAt: 1,
status: "idle",
path: null,
cwd: "/vault",
cliVersion: "0.130.0",
source: "appServer",
threadSource: null,
agentNickname: null,
agentRole: null,
gitInfo: null,
name: null,
turns: [],
...overrides,
} as Thread;
}
describe("composer suggestions", () => {
const notes = [
{ basename: "Alpha", path: "thoughts/Alpha.md", mtime: 10 },
@ -18,6 +44,9 @@ describe("composer suggestions", () => {
it("parses supported slash commands only", () => {
expect(parseSlashCommand("/status")).toEqual({ command: "status", args: "" });
expect(parseSlashCommand("/new")).toEqual({ command: "new", args: "" });
expect(parseSlashCommand("/resume thread-1")).toEqual({ command: "resume", args: "thread-1" });
expect(parseSlashCommand("/fork")).toEqual({ command: "fork", args: "" });
expect(parseSlashCommand("/doctor")).toEqual({ command: "doctor", args: "" });
expect(parseSlashCommand("/fast now")).toEqual({ command: "fast", args: "now" });
expect(parseSlashCommand("/plan")).toEqual({ command: "plan", args: "" });
@ -44,6 +73,37 @@ describe("composer suggestions", () => {
expect(activeComposerSuggestions("/doc", notes, [])[0]?.replacement).toBe("/doctor");
});
it("suggests recent threads for /resume arguments", () => {
const threads = [
thread({ id: "019abcde-0000-7000-8000-000000000001", name: "Codex Panel実装" }),
thread({ id: "019abcde-0000-7000-8000-000000000002", name: "別件" }),
];
const suggestions = activeComposerSuggestions("/resume codex", notes, [], threads);
expect(suggestions[0]).toMatchObject({
display: "Codex Panel実装",
detail: "019abcde",
replacement: "019abcde-0000-7000-8000-000000000001",
appendSpaceOnInsert: true,
});
expect(applyComposerSuggestionInsertion("/resume codex", 13, suggestions[0])).toEqual({
value: "/resume 019abcde-0000-7000-8000-000000000001 ",
cursor: 45,
});
});
it("does not suggest threads for /fork arguments", () => {
const suggestions = activeComposerSuggestions(
"/fork codex",
notes,
[],
[thread({ id: "019abcde-0000-7000-8000-000000000001", name: "Codex Panel実装" })],
);
expect(suggestions).toEqual([]);
});
it("adds a trailing space for slash command and skill insertions only", () => {
const slash = activeComposerSuggestions("/sta", notes, [])[0];
const skill = activeComposerSuggestions("$obs", notes, [

View file

@ -1,10 +1,15 @@
import { describe, expect, it, vi } from "vitest";
import type { Thread } from "../src/generated/app-server/v2/Thread";
import { executeSlashCommand, slashCommandHelpLines, type SlashCommandExecutionContext } from "../src/panel/slash-commands";
function context(overrides: Partial<SlashCommandExecutionContext> = {}): SlashCommandExecutionContext {
return {
activeThreadId: "thread-1",
listedThreads: [thread({ id: "thread-1", name: "Current" })],
startNewThread: vi.fn().mockResolvedValue(undefined),
resumeThread: vi.fn().mockResolvedValue(undefined),
forkThread: vi.fn().mockResolvedValue(undefined),
compactThread: vi.fn().mockResolvedValue(undefined),
toggleFastMode: vi.fn(),
toggleCollaborationMode: vi.fn(),
@ -20,7 +25,97 @@ function context(overrides: Partial<SlashCommandExecutionContext> = {}): SlashCo
};
}
function thread(overrides: Partial<Thread> = {}): Thread {
return {
id: "thread-1",
sessionId: "session-1",
forkedFromId: null,
preview: "Preview",
ephemeral: false,
modelProvider: "openai",
createdAt: 1,
updatedAt: 1,
status: "idle",
path: null,
cwd: "/vault",
cliVersion: "0.130.0",
source: "appServer",
threadSource: null,
agentNickname: null,
agentRole: null,
gitInfo: null,
name: null,
turns: [],
...overrides,
} as Thread;
}
describe("slash commands", () => {
it("starts a new thread for /new", async () => {
const ctx = context();
await executeSlashCommand("new", "", ctx);
expect(ctx.startNewThread).toHaveBeenCalledOnce();
});
it("returns message text after starting a new thread for /new arguments", async () => {
const ctx = context();
const result = await executeSlashCommand("new", "最初の依頼です", ctx);
expect(ctx.startNewThread).toHaveBeenCalledOnce();
expect(result).toEqual({ sendText: "最初の依頼です" });
});
it("resumes the latest listed thread for bare /resume", async () => {
const ctx = context({
listedThreads: [thread({ id: "latest", name: "Latest" }), thread({ id: "older", name: "Older" })],
});
await executeSlashCommand("resume", "", ctx);
expect(ctx.resumeThread).toHaveBeenCalledWith("latest");
});
it("resumes a thread by id argument", async () => {
const ctx = context({
listedThreads: [thread({ id: "thread-alpha", name: "Alpha" }), thread({ id: "thread-beta", name: "Beta" })],
});
await executeSlashCommand("resume", "thread-beta", ctx);
expect(ctx.resumeThread).toHaveBeenCalledWith("thread-beta");
});
it("reports ambiguous resume matches", async () => {
const ctx = context({
listedThreads: [thread({ id: "thread-alpha", name: "Draft" }), thread({ id: "thread-beta", name: "Draft notes" })],
});
await executeSlashCommand("resume", "Draft", ctx);
expect(ctx.resumeThread).not.toHaveBeenCalled();
expect(ctx.addSystemMessage).toHaveBeenCalledWith("Multiple matching threads: Draft, Draft notes");
});
it("forks the active thread for /fork", async () => {
const ctx = context({ activeThreadId: "active-thread" });
await executeSlashCommand("fork", "", ctx);
expect(ctx.forkThread).toHaveBeenCalledWith("active-thread");
});
it("rejects /fork arguments", async () => {
const ctx = context();
await executeSlashCommand("fork", "anything", ctx);
expect(ctx.forkThread).not.toHaveBeenCalled();
expect(ctx.addSystemMessage).toHaveBeenCalledWith("Unsupported slash command arguments: anything");
});
it("toggles Plan mode without sending text for bare /plan", async () => {
const ctx = context();