diff --git a/README.md b/README.md index fc13ed56..8d53683b 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/src/app-server/client.ts b/src/app-server/client.ts index 277bbffd..7dcf840d 100644 --- a/src/app-server/client.ts +++ b/src/app-server/client.ts @@ -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 { + return this.request("thread/fork", { + threadId, + cwd, + excludeTurns: true, + persistExtendedHistory: false, + }); + } + listThreads(cwd: string, archived = false): Promise { return this.request("thread/list", { cwd, diff --git a/src/composer/suggestions.ts b/src/composer/suggestions.ts index 1522397a..18411ff6 100644 --- a/src/composer/suggestions.ts +++ b/src/composer/suggestions.ts @@ -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; diff --git a/src/main.ts b/src/main.ts index 172d46d6..ba46aa78 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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 { + 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) { diff --git a/src/panel/slash-commands.ts b/src/panel/slash-commands.ts index 9b1c9f8b..e406c757 100644 --- a/src/panel/slash-commands.ts +++ b/src/panel/slash-commands.ts @@ -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; + resumeThread: (threadId: string) => Promise; + forkThread: (threadId: string) => Promise; compactThread: (threadId: string) => Promise; toggleFastMode: () => void; toggleCollaborationMode: () => void; @@ -44,6 +53,35 @@ export async function executeSlashCommand( args: string, context: SlashCommandExecutionContext, ): Promise { + 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}` }; +} diff --git a/tests/app-server-client.test.ts b/tests/app-server-client.test.ts index 1f4f37c7..639fcc79 100644 --- a/tests/app-server-client.test.ts +++ b/tests/app-server-client.test.ts @@ -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 }).params?.perCwdExtraUserRoots).toBeUndefined(); - transport.emitLine({ id: 4, result: { data: [] } }); + expect((transport.sent[5] as { params?: Record }).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; }); diff --git a/tests/composer-suggestions.test.ts b/tests/composer-suggestions.test.ts index 19e526a5..b0ec6c93 100644 --- a/tests/composer-suggestions.test.ts +++ b/tests/composer-suggestions.test.ts @@ -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 { + 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, [ diff --git a/tests/slash-commands.test.ts b/tests/slash-commands.test.ts index 660f7377..bd7c1cfe 100644 --- a/tests/slash-commands.test.ts +++ b/tests/slash-commands.test.ts @@ -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 { 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 = {}): SlashCo }; } +function thread(overrides: Partial = {}): 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();