mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Extract slash command controller
This commit is contained in:
parent
4044dd1caf
commit
e86eb40a2f
3 changed files with 246 additions and 64 deletions
97
src/features/chat/slash-command-controller.ts
Normal file
97
src/features/chat/slash-command-controller.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import type { AppServerClient } from "../../app-server/client";
|
||||
import {
|
||||
referencedThreadInput as buildReferencedThreadInput,
|
||||
referencedThreadTurns,
|
||||
REFERENCED_THREAD_TURN_LIMIT,
|
||||
} from "../../domain/threads/reference";
|
||||
import type { Thread } from "../../generated/app-server/v2/Thread";
|
||||
import { chatTurnBusy, type ChatStateStore } from "./chat-state";
|
||||
import { executeSlashCommand as runSlashCommand, type SlashCommandExecutionResult, type ThreadReferenceInput } from "./slash-commands";
|
||||
import type { SlashCommandName } from "./composer/slash-commands";
|
||||
import type { DisplayDetailSection } from "./display/types";
|
||||
import type { ReasoningEffort } from "../../generated/app-server/ReasoningEffort";
|
||||
import type { UserInput } from "../../generated/app-server/v2/UserInput";
|
||||
|
||||
export interface SlashCommandControllerHost {
|
||||
stateStore: ChatStateStore;
|
||||
currentClient: () => AppServerClient | null;
|
||||
codexInput: (text: string) => UserInput[];
|
||||
startNewThread: () => Promise<void>;
|
||||
resumeThread: (threadId: string) => Promise<void>;
|
||||
forkThread: (threadId: string) => Promise<void>;
|
||||
rollbackThread: (threadId: string) => Promise<void>;
|
||||
archiveThread: (threadId: string) => Promise<void>;
|
||||
toggleFastMode: () => void | Promise<void>;
|
||||
toggleCollaborationMode: () => void | Promise<void>;
|
||||
toggleAutoReview: () => void | Promise<void>;
|
||||
addSystemMessage: (text: string) => void;
|
||||
addStructuredSystemMessage: (text: string, details: DisplayDetailSection[]) => void;
|
||||
setStatus: (status: string) => void;
|
||||
setRequestedModel: (model: string | null) => boolean | undefined | Promise<boolean | undefined>;
|
||||
setRequestedReasoningEffort: (effort: ReasoningEffort | null) => boolean | undefined | Promise<boolean | undefined>;
|
||||
statusSummaryLines: () => string[];
|
||||
connectionDiagnosticDetails: () => DisplayDetailSection[];
|
||||
mcpStatusLines: () => Promise<string[]>;
|
||||
modelStatusLines: () => string[];
|
||||
effortStatusLines: () => string[];
|
||||
}
|
||||
|
||||
export class SlashCommandController {
|
||||
constructor(private readonly host: SlashCommandControllerHost) {}
|
||||
|
||||
async execute(command: SlashCommandName, args: string): Promise<SlashCommandExecutionResult | undefined> {
|
||||
const client = this.host.currentClient();
|
||||
if (!client) return;
|
||||
const state = this.host.stateStore.getState();
|
||||
return runSlashCommand(command, args, {
|
||||
activeThreadId: state.activeThreadId,
|
||||
listedThreads: state.listedThreads,
|
||||
startNewThread: () => this.host.startNewThread(),
|
||||
resumeThread: (threadId) => this.host.resumeThread(threadId),
|
||||
referThread: (thread, message) => this.referencedThreadInput(client, thread, message),
|
||||
forkThread: (threadId) => this.host.forkThread(threadId),
|
||||
rollbackThread: (threadId) => this.host.rollbackThread(threadId),
|
||||
compactThread: async (threadId) => {
|
||||
await client.compactThread(threadId);
|
||||
},
|
||||
archiveThread: (threadId) => this.host.archiveThread(threadId),
|
||||
busy: chatTurnBusy(state),
|
||||
toggleFastMode: () => this.host.toggleFastMode(),
|
||||
toggleCollaborationMode: () => this.host.toggleCollaborationMode(),
|
||||
toggleAutoReview: () => this.host.toggleAutoReview(),
|
||||
addSystemMessage: (text) => {
|
||||
this.host.addSystemMessage(text);
|
||||
},
|
||||
addStructuredSystemMessage: (text, details) => {
|
||||
this.host.addStructuredSystemMessage(text, details);
|
||||
},
|
||||
setStatus: (status) => {
|
||||
this.host.setStatus(status);
|
||||
},
|
||||
setRequestedModel: (model) => this.host.setRequestedModel(model),
|
||||
setRequestedReasoningEffort: (effort) => this.host.setRequestedReasoningEffort(effort),
|
||||
statusSummaryLines: () => this.host.statusSummaryLines(),
|
||||
connectionDiagnosticDetails: () => this.host.connectionDiagnosticDetails(),
|
||||
mcpStatusLines: () => this.host.mcpStatusLines(),
|
||||
modelStatusLines: () => this.host.modelStatusLines(),
|
||||
effortStatusLines: () => this.host.effortStatusLines(),
|
||||
});
|
||||
}
|
||||
|
||||
private async referencedThreadInput(client: AppServerClient, thread: Thread, message: string): Promise<ThreadReferenceInput | null> {
|
||||
try {
|
||||
const response = await client.threadTurnsList(thread.id, null, REFERENCED_THREAD_TURN_LIMIT);
|
||||
const turns = referencedThreadTurns(response.data);
|
||||
if (turns.length === 0) {
|
||||
this.host.addSystemMessage("Referenced thread has no readable conversation turns.");
|
||||
return null;
|
||||
}
|
||||
const reference = buildReferencedThreadInput(thread, turns, message, this.host.codexInput(message));
|
||||
this.host.setStatus(reference.status);
|
||||
return reference;
|
||||
} catch (error) {
|
||||
this.host.addSystemMessage(error instanceof Error ? error.message : String(error));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,6 @@ import { ItemView, Notice, type ViewStateResult, type WorkspaceLeaf } from "obsi
|
|||
|
||||
import type { AppServerClient } from "../../app-server/client";
|
||||
import { ConnectionManager, StaleConnectionError } from "../../app-server/connection-manager";
|
||||
import type { SlashCommandName } from "./composer/slash-commands";
|
||||
import { parseSlashCommand } from "./composer/suggestions";
|
||||
import { VIEW_TYPE_CODEX_PANEL } from "../../constants";
|
||||
import { createSystemItem } from "./display/system";
|
||||
|
|
@ -13,8 +12,6 @@ import type { Thread } from "../../generated/app-server/v2/Thread";
|
|||
import { collaborationModeLabel as formatCollaborationModeLabel } from "../../runtime/collaboration-mode";
|
||||
import { ChatController } from "./chat-controller";
|
||||
import { currentModel, type RuntimeSnapshot } from "../../runtime/state";
|
||||
import { executeSlashCommand as runSlashCommand, type SlashCommandExecutionResult } from "./slash-commands";
|
||||
import type { ThreadReferenceInput } from "./slash-commands";
|
||||
import { ChatAppServerController } from "./chat-app-server-controller";
|
||||
import { ThreadHistoryLoader } from "./thread-history";
|
||||
import { ThreadRenameController } from "./thread-rename";
|
||||
|
|
@ -22,11 +19,6 @@ import { pendingRequestsSignature as requestStateSignature } from "./request-sta
|
|||
import type { CodexPanelSettings } from "../../settings/model";
|
||||
import { ChatComposerController } from "./chat-composer-controller";
|
||||
import { activeTurnId, chatTurnBusy, createChatStateStore, type ChatState, type ChatAction } from "./chat-state";
|
||||
import {
|
||||
referencedThreadInput as buildReferencedThreadInput,
|
||||
referencedThreadTurns,
|
||||
REFERENCED_THREAD_TURN_LIMIT,
|
||||
} from "../../domain/threads/reference";
|
||||
import { renderToolbar, type ToolbarViewModel } from "./ui/toolbar";
|
||||
import { renderChatPanelShell, unmountChatPanelShell } from "./ui/shell";
|
||||
import type { ChatTurnDiffViewState } from "./ui/turn-diff";
|
||||
|
|
@ -72,6 +64,7 @@ import { ToolbarPanelController } from "./toolbar-panel-controller";
|
|||
import { ChatReconnectController } from "./reconnect-controller";
|
||||
import { ChatMessageScrollController } from "./message-scroll-controller";
|
||||
import { TurnSubmissionController } from "./turn-submission-controller";
|
||||
import { SlashCommandController } from "./slash-command-controller";
|
||||
|
||||
export interface CodexChatHost {
|
||||
readonly settings: CodexPanelSettings;
|
||||
|
|
@ -110,6 +103,7 @@ export class CodexChatView extends ItemView {
|
|||
private readonly messageRenderer: ChatMessageRenderer;
|
||||
private readonly messageScroll: ChatMessageScrollController;
|
||||
private readonly turnSubmission: TurnSubmissionController;
|
||||
private readonly slashCommands: SlashCommandController;
|
||||
private shellRenderVersion = 0;
|
||||
private readonly connectionWork = new ChatConnectionWorkTracker();
|
||||
private readonly resumeWork: ChatResumeWorkTracker;
|
||||
|
|
@ -164,6 +158,35 @@ export class CodexChatView extends ItemView {
|
|||
this.addSystemMessage(text);
|
||||
},
|
||||
});
|
||||
this.slashCommands = new SlashCommandController({
|
||||
stateStore: this.chatState,
|
||||
currentClient: () => this.client,
|
||||
codexInput: (text) => this.composerController.codexInput(text),
|
||||
startNewThread: () => this.startNewThread(),
|
||||
resumeThread: (threadId) => this.selectThread(threadId),
|
||||
forkThread: (threadId) => this.threadActions.forkThread(threadId),
|
||||
rollbackThread: (threadId) => this.threadActions.rollbackThread(threadId),
|
||||
archiveThread: (threadId) => this.threadActions.archiveThread(threadId),
|
||||
toggleFastMode: () => this.runtimeSettings.toggleFastMode(),
|
||||
toggleCollaborationMode: () => this.runtimeSettings.toggleCollaborationMode(),
|
||||
toggleAutoReview: () => void this.runtimeSettings.toggleAutoReview(),
|
||||
addSystemMessage: (text) => {
|
||||
this.addSystemMessage(text);
|
||||
},
|
||||
addStructuredSystemMessage: (text, details) => {
|
||||
this.addStructuredSystemMessage(text, details);
|
||||
},
|
||||
setStatus: (status) => {
|
||||
this.setStatus(status);
|
||||
},
|
||||
setRequestedModel: (model) => this.runtimeSettings.setRequestedModel(model),
|
||||
setRequestedReasoningEffort: (effort) => this.runtimeSettings.setRequestedReasoningEffort(effort),
|
||||
statusSummaryLines: () => this.statusSummaryLines(),
|
||||
connectionDiagnosticDetails: () => this.connectionDiagnosticDetails(),
|
||||
mcpStatusLines: () => this.mcpStatusLines(),
|
||||
modelStatusLines: () => this.modelStatusLines(),
|
||||
effortStatusLines: () => this.effortStatusLines(),
|
||||
});
|
||||
this.messageRenderer = new ChatMessageRenderer({
|
||||
app: this.app,
|
||||
owner: this,
|
||||
|
|
@ -772,7 +795,7 @@ export class CodexChatView extends ItemView {
|
|||
const slashCommand = parseSlashCommand(text);
|
||||
if (slashCommand) {
|
||||
this.composerController.setDraft("", { clearSuggestions: true });
|
||||
const result = await this.executeSlashCommand(slashCommand.command, slashCommand.args);
|
||||
const result = await this.slashCommands.execute(slashCommand.command, slashCommand.args);
|
||||
if (result?.sendText) {
|
||||
await this.turnSubmission.sendTurnText(result.sendText, result.sendInput, result.referencedThread);
|
||||
}
|
||||
|
|
@ -811,61 +834,6 @@ export class CodexChatView extends ItemView {
|
|||
await this.sendMessage();
|
||||
}
|
||||
|
||||
private async executeSlashCommand(command: SlashCommandName, args: string): Promise<SlashCommandExecutionResult | undefined> {
|
||||
if (!this.client) return;
|
||||
return runSlashCommand(command, args, {
|
||||
activeThreadId: this.state.activeThreadId,
|
||||
listedThreads: this.state.listedThreads,
|
||||
startNewThread: () => this.startNewThread(),
|
||||
resumeThread: (threadId) => this.selectThread(threadId),
|
||||
referThread: (thread, message) => this.referencedThreadInput(thread, message),
|
||||
forkThread: (threadId) => this.threadActions.forkThread(threadId),
|
||||
rollbackThread: (threadId) => this.threadActions.rollbackThread(threadId),
|
||||
compactThread: async (threadId) => {
|
||||
await this.client?.compactThread(threadId);
|
||||
},
|
||||
archiveThread: (threadId) => this.threadActions.archiveThread(threadId),
|
||||
busy: this.turnBusy,
|
||||
toggleFastMode: () => this.runtimeSettings.toggleFastMode(),
|
||||
toggleCollaborationMode: () => this.runtimeSettings.toggleCollaborationMode(),
|
||||
toggleAutoReview: () => void this.runtimeSettings.toggleAutoReview(),
|
||||
addSystemMessage: (text) => {
|
||||
this.addSystemMessage(text);
|
||||
},
|
||||
addStructuredSystemMessage: (text, details) => {
|
||||
this.addStructuredSystemMessage(text, details);
|
||||
},
|
||||
setStatus: (status) => {
|
||||
this.setStatus(status);
|
||||
},
|
||||
setRequestedModel: (model) => this.runtimeSettings.setRequestedModel(model),
|
||||
setRequestedReasoningEffort: (effort) => this.runtimeSettings.setRequestedReasoningEffort(effort),
|
||||
statusSummaryLines: () => this.statusSummaryLines(),
|
||||
connectionDiagnosticDetails: () => this.connectionDiagnosticDetails(),
|
||||
mcpStatusLines: () => this.mcpStatusLines(),
|
||||
modelStatusLines: () => this.modelStatusLines(),
|
||||
effortStatusLines: () => this.effortStatusLines(),
|
||||
});
|
||||
}
|
||||
|
||||
private async referencedThreadInput(thread: Thread, message: string): Promise<ThreadReferenceInput | null> {
|
||||
if (!this.client) return null;
|
||||
try {
|
||||
const response = await this.client.threadTurnsList(thread.id, null, REFERENCED_THREAD_TURN_LIMIT);
|
||||
const turns = referencedThreadTurns(response.data);
|
||||
if (turns.length === 0) {
|
||||
this.addSystemMessage("Referenced thread has no readable conversation turns.");
|
||||
return null;
|
||||
}
|
||||
const reference = buildReferencedThreadInput(thread, turns, message, this.composerController.codexInput(message));
|
||||
this.setStatus(reference.status);
|
||||
return reference;
|
||||
} catch (error) {
|
||||
this.addSystemMessage(error instanceof Error ? error.message : String(error));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private canImplementPlanItem(item: DisplayItem): boolean {
|
||||
if (item.kind !== "message" || item.role !== "assistant" || item.proposedPlan !== true) return false;
|
||||
if (!this.state.activeThreadId || this.turnBusy || this.state.composerDraft.trim().length > 0) return false;
|
||||
|
|
|
|||
117
tests/features/chat/slash-command-controller.test.ts
Normal file
117
tests/features/chat/slash-command-controller.test.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { AppServerClient } from "../../../src/app-server/client";
|
||||
import { createChatState, createChatStateStore } from "../../../src/features/chat/chat-state";
|
||||
import { SlashCommandController, type SlashCommandControllerHost } from "../../../src/features/chat/slash-command-controller";
|
||||
import type { Thread } from "../../../src/generated/app-server/v2/Thread";
|
||||
import type { UserInput } from "../../../src/generated/app-server/v2/UserInput";
|
||||
|
||||
const textInput = (text: string): UserInput[] => [{ type: "text", text, text_elements: [] }];
|
||||
|
||||
function thread(id: string, name: string | null = null): Thread {
|
||||
return {
|
||||
id,
|
||||
sessionId: id,
|
||||
forkedFromId: null,
|
||||
preview: "",
|
||||
ephemeral: false,
|
||||
modelProvider: "openai",
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
status: { type: "idle" },
|
||||
path: null,
|
||||
cwd: "/vault",
|
||||
cliVersion: "test",
|
||||
source: "appServer",
|
||||
threadSource: null,
|
||||
agentNickname: null,
|
||||
agentRole: null,
|
||||
gitInfo: null,
|
||||
name,
|
||||
turns: [],
|
||||
};
|
||||
}
|
||||
|
||||
function createHost(overrides: Partial<SlashCommandControllerHost> = {}) {
|
||||
const stateStore = createChatStateStore(createChatState());
|
||||
const compactThread = vi.fn().mockResolvedValue({});
|
||||
const threadTurnsList = vi.fn().mockResolvedValue({ data: [] });
|
||||
const client = { compactThread, threadTurnsList } as unknown as AppServerClient;
|
||||
const host: SlashCommandControllerHost = {
|
||||
stateStore,
|
||||
currentClient: () => client,
|
||||
codexInput: vi.fn((text: string) => textInput(text)),
|
||||
startNewThread: vi.fn().mockResolvedValue(undefined),
|
||||
resumeThread: vi.fn().mockResolvedValue(undefined),
|
||||
forkThread: vi.fn().mockResolvedValue(undefined),
|
||||
rollbackThread: vi.fn().mockResolvedValue(undefined),
|
||||
archiveThread: vi.fn().mockResolvedValue(undefined),
|
||||
toggleFastMode: vi.fn(),
|
||||
toggleCollaborationMode: vi.fn(),
|
||||
toggleAutoReview: vi.fn(),
|
||||
addSystemMessage: vi.fn(),
|
||||
addStructuredSystemMessage: vi.fn(),
|
||||
setStatus: vi.fn(),
|
||||
setRequestedModel: vi.fn(),
|
||||
setRequestedReasoningEffort: vi.fn(),
|
||||
statusSummaryLines: () => [],
|
||||
connectionDiagnosticDetails: () => [],
|
||||
mcpStatusLines: vi.fn().mockResolvedValue([]),
|
||||
modelStatusLines: () => [],
|
||||
effortStatusLines: () => [],
|
||||
...overrides,
|
||||
};
|
||||
return { compactThread, host, stateStore, threadTurnsList };
|
||||
}
|
||||
|
||||
describe("SlashCommandController", () => {
|
||||
it("executes slash commands against the current chat state", async () => {
|
||||
const { host } = createHost();
|
||||
const controller = new SlashCommandController(host);
|
||||
|
||||
const result = await controller.execute("new", "hello");
|
||||
|
||||
expect(host.startNewThread).toHaveBeenCalledOnce();
|
||||
expect(result).toEqual({ sendText: "hello" });
|
||||
});
|
||||
|
||||
it("uses the current client for app-server commands", async () => {
|
||||
const { compactThread, host, stateStore } = createHost();
|
||||
stateStore.dispatch({
|
||||
type: "thread/list-applied",
|
||||
threads: [thread("thread", "Thread")],
|
||||
});
|
||||
stateStore.dispatch({
|
||||
type: "thread/resumed",
|
||||
thread: thread("thread", "Thread"),
|
||||
cwd: "/vault",
|
||||
model: null,
|
||||
reasoningEffort: null,
|
||||
serviceTier: null,
|
||||
approvalPolicy: null,
|
||||
approvalsReviewer: null,
|
||||
activePermissionProfile: null,
|
||||
});
|
||||
const controller = new SlashCommandController(host);
|
||||
|
||||
await controller.execute("compact", "");
|
||||
|
||||
expect(compactThread).toHaveBeenCalledWith("thread");
|
||||
expect(host.setStatus).toHaveBeenCalledWith("Compaction requested.");
|
||||
});
|
||||
|
||||
it("reports unreadable referenced threads", async () => {
|
||||
const { host, stateStore, threadTurnsList } = createHost();
|
||||
stateStore.dispatch({
|
||||
type: "thread/list-applied",
|
||||
threads: [thread("other", "Other")],
|
||||
});
|
||||
const controller = new SlashCommandController(host);
|
||||
|
||||
const result = await controller.execute("refer", "Other summarize");
|
||||
|
||||
expect(threadTurnsList).toHaveBeenCalledWith("other", null, 20);
|
||||
expect(result).toBeUndefined();
|
||||
expect(host.addSystemMessage).toHaveBeenCalledWith("Referenced thread has no readable conversation turns.");
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue