mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Add thread slash commands
This commit is contained in:
parent
95d2735a81
commit
5797313da0
8 changed files with 331 additions and 17 deletions
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
44
src/main.ts
44
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<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) {
|
||||
|
|
|
|||
|
|
@ -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}` };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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, [
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue