Extract slash command controller

This commit is contained in:
murashit 2026-05-29 06:14:54 +09:00
parent 4044dd1caf
commit e86eb40a2f
3 changed files with 246 additions and 64 deletions

View 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;
}
}
}

View file

@ -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;

View 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.");
});
});