Functionize chat use case services

This commit is contained in:
murashit 2026-06-08 09:15:36 +09:00
parent 02df0cc4b7
commit 09545939cc
30 changed files with 1363 additions and 1187 deletions

View file

@ -0,0 +1,198 @@
import type { AppServerClient } from "../../../app-server/client";
import {
capabilityProbeError,
capabilityProbeOk,
upsertMcpServerDiagnostic,
type CapabilityProbeMethod,
} from "../../../app-server/compatibility";
import type { McpServerStatus } from "../../../generated/app-server/v2/McpServerStatus";
import type { SharedAppServerMetadata } from "../../../runtime/shared-app-server-state";
import { mcpStatusLines as buildMcpStatusLines } from "../mcp-status";
import { cloneAppServerDiagnostics, type ChatAppServerBaseHost } from "./shared";
interface RefreshCapabilityDiagnosticsOptions {
cachedAppServerMetadata?: boolean;
}
export interface ChatAppServerDiagnosticsActionsHost extends ChatAppServerBaseHost {
publishAppServerMetadata: (metadata: SharedAppServerMetadata) => void;
appServerMetadataSnapshot: () => SharedAppServerMetadata;
}
export interface ChatAppServerDiagnosticsActions {
refreshCapabilityDiagnostics: (options?: RefreshCapabilityDiagnosticsOptions) => Promise<void>;
refreshPublishedCapabilityDiagnostics: (options?: RefreshCapabilityDiagnosticsOptions) => Promise<void>;
mcpStatusLines: () => Promise<string[]>;
recordMcpStartupStatus: (name: string, startupStatus: "starting" | "ready" | "failed" | "cancelled", message: string | null) => void;
}
export function createChatAppServerDiagnosticsActions(host: ChatAppServerDiagnosticsActionsHost): ChatAppServerDiagnosticsActions {
return {
refreshCapabilityDiagnostics: (options) => refreshCapabilityDiagnostics(host, options),
refreshPublishedCapabilityDiagnostics: (options) => refreshPublishedCapabilityDiagnostics(host, options),
mcpStatusLines: () => mcpStatusLines(host),
recordMcpStartupStatus: (name, startupStatus, message) => {
recordMcpStartupStatus(host, name, startupStatus, message);
},
};
}
async function refreshCapabilityDiagnostics(
host: ChatAppServerDiagnosticsActionsHost,
options: RefreshCapabilityDiagnosticsOptions = {},
): Promise<void> {
const client = host.currentClient();
if (!client) return;
const probes: Promise<void>[] = [];
if (!options.cachedAppServerMetadata) {
probes.push(
probeCapability(
host,
"model/list",
() => client.listModels(false),
(response) => `${String(response.data.length)} models`,
),
probeCapability(
host,
"skills/list",
() => client.listSkills(host.vaultPath),
(response) => {
const count = response.data.reduce((total, entry) => total + entry.skills.length, 0);
return `${String(count)} skills`;
},
),
probeCapability(
host,
"account/rateLimits/read",
() => client.readAccountRateLimits(),
(response) => (response.rateLimitsByLimitId ? `${String(Object.keys(response.rateLimitsByLimitId).length)} limits` : "available"),
),
);
}
probes.push(
probeCapability(
host,
"hooks/list",
() => client.listHooks(host.vaultPath),
(response) => {
const count = response.data.reduce((total, entry) => total + entry.hooks.length, 0);
return `${String(count)} hooks`;
},
),
probeCapability(
host,
"mcpServerStatus/list",
() => client.listMcpServerStatus(mcpServerStatusParams(host.stateStore.getState().activeThread.id)),
(response) => {
recordMcpServerStatus(host, response.data);
const issueCount = response.data.filter((server) => server.authStatus === "notLoggedIn").length;
return issueCount > 0
? `${String(response.data.length)} servers, ${String(issueCount)} auth issues`
: `${String(response.data.length)} servers`;
},
),
probeCapability(
host,
"collaborationMode/list",
() => client.listCollaborationModes(),
(response) => `${String(response.data.length)} modes`,
),
probeCapability(
host,
"modelProvider/capabilities/read",
() => client.readModelProviderCapabilities(),
(response) =>
[
response.namespaceTools ? "namespace tools" : null,
response.imageGeneration ? "image generation" : null,
response.webSearch ? "web search" : null,
]
.filter(Boolean)
.join(", ") || "no optional capabilities",
),
);
await Promise.all(probes);
}
async function refreshPublishedCapabilityDiagnostics(
host: ChatAppServerDiagnosticsActionsHost,
options: RefreshCapabilityDiagnosticsOptions = {},
): Promise<void> {
await refreshCapabilityDiagnostics(host, options);
host.publishAppServerMetadata(host.appServerMetadataSnapshot());
}
async function mcpStatusLines(host: ChatAppServerDiagnosticsActionsHost): Promise<string[]> {
const client = host.currentClient();
if (!client) return ["MCP servers", "Codex app-server is not connected."];
try {
const state = host.stateStore.getState();
const response = await client.listMcpServerStatus(mcpServerStatusParams(state.activeThread.id));
return buildMcpStatusLines(response.data, state.connection.appServerDiagnostics.mcpServers);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return ["MCP servers", `Could not load MCP servers: ${message}`];
}
}
function recordMcpStartupStatus(
host: ChatAppServerDiagnosticsActionsHost,
name: string,
startupStatus: "starting" | "ready" | "failed" | "cancelled",
message: string | null,
): void {
host.stateStore.dispatch({
type: "connection/metadata-applied",
appServerDiagnostics: upsertMcpServerDiagnostic(host.stateStore.getState().connection.appServerDiagnostics, {
name,
startupStatus,
authStatus: null,
toolCount: null,
message,
}),
});
}
async function probeCapability<T>(
host: ChatAppServerDiagnosticsActionsHost,
method: CapabilityProbeMethod,
request: () => Promise<T>,
summarize: (response: T) => string | null,
): Promise<void> {
try {
const response = await request();
const diagnostics = cloneAppServerDiagnostics(host.stateStore.getState().connection.appServerDiagnostics);
diagnostics.probes[method] = capabilityProbeOk(method, summarize(response));
host.stateStore.dispatch({ type: "connection/metadata-applied", appServerDiagnostics: diagnostics });
} catch (error) {
const diagnostics = cloneAppServerDiagnostics(host.stateStore.getState().connection.appServerDiagnostics);
diagnostics.probes[method] = capabilityProbeError(method, error);
host.stateStore.dispatch({ type: "connection/metadata-applied", appServerDiagnostics: diagnostics });
}
}
function recordMcpServerStatus(host: ChatAppServerDiagnosticsActionsHost, servers: McpServerStatus[]): void {
let diagnostics = host.stateStore.getState().connection.appServerDiagnostics;
for (const server of servers) {
diagnostics = upsertMcpServerDiagnostic(diagnostics, {
name: server.name,
startupStatus: "unknown",
authStatus: server.authStatus,
toolCount: Object.keys(server.tools).length,
message: null,
});
}
host.stateStore.dispatch({ type: "connection/metadata-applied", appServerDiagnostics: diagnostics });
}
function mcpServerStatusParams(threadId: string | null): Parameters<AppServerClient["listMcpServerStatus"]>[0] {
return {
detail: "toolsAndAuthOnly",
limit: 100,
...(threadId ? { threadId } : {}),
};
}

View file

@ -1,165 +0,0 @@
import type { AppServerClient } from "../../../app-server/client";
import {
capabilityProbeError,
capabilityProbeOk,
upsertMcpServerDiagnostic,
type CapabilityProbeMethod,
} from "../../../app-server/compatibility";
import type { McpServerStatus } from "../../../generated/app-server/v2/McpServerStatus";
import type { SharedAppServerMetadata } from "../../../runtime/shared-app-server-state";
import { mcpStatusLines as buildMcpStatusLines } from "../mcp-status";
import { cloneAppServerDiagnostics, type ChatAppServerBaseHost } from "./shared";
export interface RefreshCapabilityDiagnosticsOptions {
cachedAppServerMetadata?: boolean;
}
export interface ChatAppServerDiagnosticsControllerHost extends ChatAppServerBaseHost {
publishAppServerMetadata: (metadata: SharedAppServerMetadata) => void;
appServerMetadataSnapshot: () => SharedAppServerMetadata;
}
export class ChatAppServerDiagnosticsController {
constructor(private readonly host: ChatAppServerDiagnosticsControllerHost) {}
async refreshCapabilityDiagnostics(options: RefreshCapabilityDiagnosticsOptions = {}): Promise<void> {
const client = this.host.currentClient();
if (!client) return;
const probes: Promise<void>[] = [];
if (!options.cachedAppServerMetadata) {
probes.push(
this.probeCapability(
"model/list",
() => client.listModels(false),
(response) => `${String(response.data.length)} models`,
),
this.probeCapability(
"skills/list",
() => client.listSkills(this.host.vaultPath),
(response) => {
const count = response.data.reduce((total, entry) => total + entry.skills.length, 0);
return `${String(count)} skills`;
},
),
this.probeCapability(
"account/rateLimits/read",
() => client.readAccountRateLimits(),
(response) => (response.rateLimitsByLimitId ? `${String(Object.keys(response.rateLimitsByLimitId).length)} limits` : "available"),
),
);
}
probes.push(
this.probeCapability(
"hooks/list",
() => client.listHooks(this.host.vaultPath),
(response) => {
const count = response.data.reduce((total, entry) => total + entry.hooks.length, 0);
return `${String(count)} hooks`;
},
),
this.probeCapability(
"mcpServerStatus/list",
() => client.listMcpServerStatus(mcpServerStatusParams(this.host.stateStore.getState().activeThread.id)),
(response) => {
this.recordMcpServerStatus(response.data);
const issueCount = response.data.filter((server) => server.authStatus === "notLoggedIn").length;
return issueCount > 0
? `${String(response.data.length)} servers, ${String(issueCount)} auth issues`
: `${String(response.data.length)} servers`;
},
),
this.probeCapability(
"collaborationMode/list",
() => client.listCollaborationModes(),
(response) => `${String(response.data.length)} modes`,
),
this.probeCapability(
"modelProvider/capabilities/read",
() => client.readModelProviderCapabilities(),
(response) =>
[
response.namespaceTools ? "namespace tools" : null,
response.imageGeneration ? "image generation" : null,
response.webSearch ? "web search" : null,
]
.filter(Boolean)
.join(", ") || "no optional capabilities",
),
);
await Promise.all(probes);
}
async refreshPublishedCapabilityDiagnostics(options: RefreshCapabilityDiagnosticsOptions = {}): Promise<void> {
await this.refreshCapabilityDiagnostics(options);
this.host.publishAppServerMetadata(this.host.appServerMetadataSnapshot());
}
async mcpStatusLines(): Promise<string[]> {
const client = this.host.currentClient();
if (!client) return ["MCP servers", "Codex app-server is not connected."];
try {
const state = this.host.stateStore.getState();
const response = await client.listMcpServerStatus(mcpServerStatusParams(state.activeThread.id));
return buildMcpStatusLines(response.data, state.connection.appServerDiagnostics.mcpServers);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return ["MCP servers", `Could not load MCP servers: ${message}`];
}
}
recordMcpStartupStatus(name: string, startupStatus: "starting" | "ready" | "failed" | "cancelled", message: string | null): void {
this.host.stateStore.dispatch({
type: "connection/metadata-applied",
appServerDiagnostics: upsertMcpServerDiagnostic(this.host.stateStore.getState().connection.appServerDiagnostics, {
name,
startupStatus,
authStatus: null,
toolCount: null,
message,
}),
});
}
private async probeCapability<T>(
method: CapabilityProbeMethod,
request: () => Promise<T>,
summarize: (response: T) => string | null,
): Promise<void> {
try {
const response = await request();
const diagnostics = cloneAppServerDiagnostics(this.host.stateStore.getState().connection.appServerDiagnostics);
diagnostics.probes[method] = capabilityProbeOk(method, summarize(response));
this.host.stateStore.dispatch({ type: "connection/metadata-applied", appServerDiagnostics: diagnostics });
} catch (error) {
const diagnostics = cloneAppServerDiagnostics(this.host.stateStore.getState().connection.appServerDiagnostics);
diagnostics.probes[method] = capabilityProbeError(method, error);
this.host.stateStore.dispatch({ type: "connection/metadata-applied", appServerDiagnostics: diagnostics });
}
}
private recordMcpServerStatus(servers: McpServerStatus[]): void {
let diagnostics = this.host.stateStore.getState().connection.appServerDiagnostics;
for (const server of servers) {
diagnostics = upsertMcpServerDiagnostic(diagnostics, {
name: server.name,
startupStatus: "unknown",
authStatus: server.authStatus,
toolCount: Object.keys(server.tools).length,
message: null,
});
}
this.host.stateStore.dispatch({ type: "connection/metadata-applied", appServerDiagnostics: diagnostics });
}
}
function mcpServerStatusParams(threadId: string | null): Parameters<AppServerClient["listMcpServerStatus"]>[0] {
return {
detail: "toolsAndAuthOnly",
limit: 100,
...(threadId ? { threadId } : {}),
};
}

View file

@ -0,0 +1,215 @@
import { type AppServerDiagnostics, capabilityProbeError, capabilityProbeOk } from "../../../app-server/compatibility";
import type { Model } from "../../../generated/app-server/v2/Model";
import type { RateLimitSnapshot } from "../../../generated/app-server/v2/RateLimitSnapshot";
import type { SkillMetadata } from "../../../generated/app-server/v2/SkillMetadata";
import type { SharedAppServerMetadata } from "../../../runtime/shared-app-server-state";
import { cloneAppServerDiagnostics, type ChatAppServerBaseHost } from "./shared";
export interface ChatAppServerMetadataActionsHost extends ChatAppServerBaseHost {
publishAppServerMetadata: (metadata: SharedAppServerMetadata) => void;
}
export interface ChatAppServerMetadataActions {
appServerMetadataSnapshot: () => SharedAppServerMetadata;
applyAppServerMetadata: (metadata: SharedAppServerMetadata) => void;
loadAppServerMetadata: () => Promise<SharedAppServerMetadata | null>;
refreshAppServerMetadata: () => Promise<SharedAppServerMetadata | null>;
refreshPublishedAppServerMetadata: () => Promise<SharedAppServerMetadata | null>;
publishAppServerMetadataSnapshot: () => void;
refreshModels: () => Promise<void>;
loadModels: () => Promise<{ data: Model[]; probe: AppServerDiagnostics["probes"]["model/list"] }>;
refreshSkills: (forceReload?: boolean) => Promise<void>;
refreshPublishedSkills: (forceReload?: boolean) => Promise<void>;
loadSkills: (forceReload?: boolean) => Promise<{ data: SkillMetadata[]; probe: AppServerDiagnostics["probes"]["skills/list"] }>;
refreshRateLimits: () => Promise<void>;
refreshPublishedRateLimits: () => Promise<void>;
loadRateLimit: () => Promise<{ data: RateLimitSnapshot | null; probe: AppServerDiagnostics["probes"]["account/rateLimits/read"] }>;
}
export function createChatAppServerMetadataActions(host: ChatAppServerMetadataActionsHost): ChatAppServerMetadataActions {
return {
appServerMetadataSnapshot: () => appServerMetadataSnapshot(host),
applyAppServerMetadata: (metadata) => {
applyAppServerMetadata(host, metadata);
},
loadAppServerMetadata: () => loadAppServerMetadata(host),
refreshAppServerMetadata: () => refreshAppServerMetadata(host),
refreshPublishedAppServerMetadata: () => refreshPublishedAppServerMetadata(host),
publishAppServerMetadataSnapshot: () => {
publishAppServerMetadataSnapshot(host);
},
refreshModels: () => refreshModels(host),
loadModels: () => loadModels(host),
refreshSkills: (forceReload) => refreshSkills(host, forceReload),
refreshPublishedSkills: (forceReload) => refreshPublishedSkills(host, forceReload),
loadSkills: (forceReload) => loadSkills(host, forceReload),
refreshRateLimits: () => refreshRateLimits(host),
refreshPublishedRateLimits: () => refreshPublishedRateLimits(host),
loadRateLimit: () => loadRateLimit(host),
};
}
function appServerMetadataSnapshot(host: ChatAppServerMetadataActionsHost): SharedAppServerMetadata {
const state = host.stateStore.getState();
return {
effectiveConfig: state.connection.effectiveConfig,
availableModels: state.connection.availableModels,
availableSkills: state.connection.availableSkills,
rateLimit: state.connection.rateLimit,
appServerDiagnostics: state.connection.appServerDiagnostics,
};
}
function applyAppServerMetadata(host: ChatAppServerMetadataActionsHost, metadata: SharedAppServerMetadata): void {
host.stateStore.dispatch({
type: "connection/metadata-applied",
effectiveConfig: metadata.effectiveConfig,
availableModels: metadata.availableModels,
availableSkills: metadata.availableSkills,
rateLimit: metadata.rateLimit,
appServerDiagnostics: metadata.appServerDiagnostics,
});
}
async function loadAppServerMetadata(host: ChatAppServerMetadataActionsHost): Promise<SharedAppServerMetadata | null> {
const client = host.currentClient();
if (!client) return null;
const effectiveConfig = await client.readEffectiveConfig(host.vaultPath);
const [models, skills, rateLimit] = await Promise.all([loadModels(host), loadSkills(host), loadRateLimit(host)]);
const diagnostics = cloneAppServerDiagnostics(host.stateStore.getState().connection.appServerDiagnostics);
diagnostics.probes["model/list"] = models.probe;
diagnostics.probes["skills/list"] = skills.probe;
diagnostics.probes["account/rateLimits/read"] = rateLimit.probe;
return {
effectiveConfig,
availableModels: models.data,
availableSkills: skills.data,
rateLimit: rateLimit.data,
appServerDiagnostics: diagnostics,
};
}
async function refreshAppServerMetadata(host: ChatAppServerMetadataActionsHost): Promise<SharedAppServerMetadata | null> {
const metadata = await loadAppServerMetadata(host);
if (metadata) applyAppServerMetadata(host, metadata);
return metadata;
}
async function refreshPublishedAppServerMetadata(host: ChatAppServerMetadataActionsHost): Promise<SharedAppServerMetadata | null> {
const metadata = await refreshAppServerMetadata(host);
if (metadata) host.publishAppServerMetadata(metadata);
return metadata;
}
function publishAppServerMetadataSnapshot(host: ChatAppServerMetadataActionsHost): void {
host.publishAppServerMetadata(appServerMetadataSnapshot(host));
}
async function refreshModels(host: ChatAppServerMetadataActionsHost): Promise<void> {
const models = await loadModels(host);
const diagnostics = cloneAppServerDiagnostics(host.stateStore.getState().connection.appServerDiagnostics);
diagnostics.probes["model/list"] = models.probe;
host.stateStore.dispatch({
type: "connection/metadata-applied",
availableModels: models.data,
appServerDiagnostics: diagnostics,
});
}
async function loadModels(
host: ChatAppServerMetadataActionsHost,
): Promise<{ data: Model[]; probe: AppServerDiagnostics["probes"]["model/list"] }> {
const client = host.currentClient();
if (!client) return { data: [], probe: capabilityProbeError("model/list", new Error("Codex app-server is not connected.")) };
try {
const response = await client.listModels(false);
return { data: response.data, probe: capabilityProbeOk("model/list", `${String(response.data.length)} models`) };
} catch (error) {
return { data: [], probe: capabilityProbeError("model/list", error) };
}
}
async function refreshSkills(host: ChatAppServerMetadataActionsHost, forceReload = false): Promise<void> {
const skills = await loadSkills(host, forceReload);
const diagnostics = cloneAppServerDiagnostics(host.stateStore.getState().connection.appServerDiagnostics);
diagnostics.probes["skills/list"] = skills.probe;
host.stateStore.dispatch({
type: "connection/metadata-applied",
availableSkills: skills.data,
appServerDiagnostics: diagnostics,
});
}
async function refreshPublishedSkills(host: ChatAppServerMetadataActionsHost, forceReload = false): Promise<void> {
await refreshSkills(host, forceReload);
publishAppServerMetadataSnapshot(host);
}
async function loadSkills(
host: ChatAppServerMetadataActionsHost,
forceReload = false,
): Promise<{ data: SkillMetadata[]; probe: AppServerDiagnostics["probes"]["skills/list"] }> {
const client = host.currentClient();
if (!client) return { data: [], probe: capabilityProbeError("skills/list", new Error("Codex app-server is not connected.")) };
try {
const response = await client.listSkills(host.vaultPath, forceReload);
const data = response.data.flatMap((entry) => entry.skills).filter((skill) => skill.enabled);
const count = response.data.reduce((total, entry) => total + entry.skills.length, 0);
return { data, probe: capabilityProbeOk("skills/list", `${String(count)} skills`) };
} catch (error) {
return { data: [], probe: capabilityProbeError("skills/list", error) };
}
}
async function refreshRateLimits(host: ChatAppServerMetadataActionsHost): Promise<void> {
const rateLimit = await loadRateLimit(host);
const diagnostics = cloneAppServerDiagnostics(host.stateStore.getState().connection.appServerDiagnostics);
diagnostics.probes["account/rateLimits/read"] = rateLimit.probe;
host.stateStore.dispatch({
type: "connection/metadata-applied",
rateLimit: rateLimit.data,
appServerDiagnostics: diagnostics,
});
}
async function refreshPublishedRateLimits(host: ChatAppServerMetadataActionsHost): Promise<void> {
const rateLimit = await loadRateLimit(host);
const diagnostics = cloneAppServerDiagnostics(host.stateStore.getState().connection.appServerDiagnostics);
diagnostics.probes["account/rateLimits/read"] = rateLimit.probe;
if (rateLimit.probe.status === "ok") {
host.stateStore.dispatch({
type: "connection/metadata-applied",
rateLimit: rateLimit.data,
appServerDiagnostics: diagnostics,
});
publishAppServerMetadataSnapshot(host);
return;
}
host.stateStore.dispatch({ type: "connection/metadata-applied", appServerDiagnostics: diagnostics });
}
async function loadRateLimit(
host: ChatAppServerMetadataActionsHost,
): Promise<{ data: RateLimitSnapshot | null; probe: AppServerDiagnostics["probes"]["account/rateLimits/read"] }> {
const client = host.currentClient();
if (!client) {
return {
data: null,
probe: capabilityProbeError("account/rateLimits/read", new Error("Codex app-server is not connected.")),
};
}
try {
const response = await client.readAccountRateLimits();
const rateLimitsByLimitId = response.rateLimitsByLimitId;
const codexRateLimit = rateLimitsByLimitId && Object.hasOwn(rateLimitsByLimitId, "codex") ? rateLimitsByLimitId["codex"] : undefined;
return {
data: codexRateLimit ?? response.rateLimits,
probe: capabilityProbeOk(
"account/rateLimits/read",
response.rateLimitsByLimitId ? `${String(Object.keys(response.rateLimitsByLimitId).length)} limits` : "available",
),
};
} catch (error) {
return { data: null, probe: capabilityProbeError("account/rateLimits/read", error) };
}
}

View file

@ -1,172 +0,0 @@
import { type AppServerDiagnostics, capabilityProbeError, capabilityProbeOk } from "../../../app-server/compatibility";
import type { Model } from "../../../generated/app-server/v2/Model";
import type { RateLimitSnapshot } from "../../../generated/app-server/v2/RateLimitSnapshot";
import type { SkillMetadata } from "../../../generated/app-server/v2/SkillMetadata";
import type { SharedAppServerMetadata } from "../../../runtime/shared-app-server-state";
import { cloneAppServerDiagnostics, type ChatAppServerBaseHost } from "./shared";
export interface ChatAppServerMetadataControllerHost extends ChatAppServerBaseHost {
publishAppServerMetadata: (metadata: SharedAppServerMetadata) => void;
}
export class ChatAppServerMetadataController {
constructor(private readonly host: ChatAppServerMetadataControllerHost) {}
appServerMetadataSnapshot(): SharedAppServerMetadata {
const state = this.host.stateStore.getState();
return {
effectiveConfig: state.connection.effectiveConfig,
availableModels: state.connection.availableModels,
availableSkills: state.connection.availableSkills,
rateLimit: state.connection.rateLimit,
appServerDiagnostics: state.connection.appServerDiagnostics,
};
}
applyAppServerMetadata(metadata: SharedAppServerMetadata): void {
this.host.stateStore.dispatch({
type: "connection/metadata-applied",
effectiveConfig: metadata.effectiveConfig,
availableModels: metadata.availableModels,
availableSkills: metadata.availableSkills,
rateLimit: metadata.rateLimit,
appServerDiagnostics: metadata.appServerDiagnostics,
});
}
async loadAppServerMetadata(): Promise<SharedAppServerMetadata | null> {
const client = this.host.currentClient();
if (!client) return null;
const effectiveConfig = await client.readEffectiveConfig(this.host.vaultPath);
const [models, skills, rateLimit] = await Promise.all([this.loadModels(), this.loadSkills(), this.loadRateLimit()]);
const diagnostics = cloneAppServerDiagnostics(this.host.stateStore.getState().connection.appServerDiagnostics);
diagnostics.probes["model/list"] = models.probe;
diagnostics.probes["skills/list"] = skills.probe;
diagnostics.probes["account/rateLimits/read"] = rateLimit.probe;
return {
effectiveConfig,
availableModels: models.data,
availableSkills: skills.data,
rateLimit: rateLimit.data,
appServerDiagnostics: diagnostics,
};
}
async refreshAppServerMetadata(): Promise<SharedAppServerMetadata | null> {
const metadata = await this.loadAppServerMetadata();
if (metadata) this.applyAppServerMetadata(metadata);
return metadata;
}
async refreshPublishedAppServerMetadata(): Promise<SharedAppServerMetadata | null> {
const metadata = await this.refreshAppServerMetadata();
if (metadata) this.host.publishAppServerMetadata(metadata);
return metadata;
}
publishAppServerMetadataSnapshot(): void {
this.host.publishAppServerMetadata(this.appServerMetadataSnapshot());
}
async refreshModels(): Promise<void> {
const models = await this.loadModels();
const diagnostics = cloneAppServerDiagnostics(this.host.stateStore.getState().connection.appServerDiagnostics);
diagnostics.probes["model/list"] = models.probe;
this.host.stateStore.dispatch({
type: "connection/metadata-applied",
availableModels: models.data,
appServerDiagnostics: diagnostics,
});
}
async loadModels(): Promise<{ data: Model[]; probe: AppServerDiagnostics["probes"]["model/list"] }> {
const client = this.host.currentClient();
if (!client) return { data: [], probe: capabilityProbeError("model/list", new Error("Codex app-server is not connected.")) };
try {
const response = await client.listModels(false);
return { data: response.data, probe: capabilityProbeOk("model/list", `${String(response.data.length)} models`) };
} catch (error) {
return { data: [], probe: capabilityProbeError("model/list", error) };
}
}
async refreshSkills(forceReload = false): Promise<void> {
const skills = await this.loadSkills(forceReload);
const diagnostics = cloneAppServerDiagnostics(this.host.stateStore.getState().connection.appServerDiagnostics);
diagnostics.probes["skills/list"] = skills.probe;
this.host.stateStore.dispatch({
type: "connection/metadata-applied",
availableSkills: skills.data,
appServerDiagnostics: diagnostics,
});
}
async refreshPublishedSkills(forceReload = false): Promise<void> {
await this.refreshSkills(forceReload);
this.publishAppServerMetadataSnapshot();
}
async loadSkills(forceReload = false): Promise<{ data: SkillMetadata[]; probe: AppServerDiagnostics["probes"]["skills/list"] }> {
const client = this.host.currentClient();
if (!client) return { data: [], probe: capabilityProbeError("skills/list", new Error("Codex app-server is not connected.")) };
try {
const response = await client.listSkills(this.host.vaultPath, forceReload);
const data = response.data.flatMap((entry) => entry.skills).filter((skill) => skill.enabled);
const count = response.data.reduce((total, entry) => total + entry.skills.length, 0);
return { data, probe: capabilityProbeOk("skills/list", `${String(count)} skills`) };
} catch (error) {
return { data: [], probe: capabilityProbeError("skills/list", error) };
}
}
async refreshRateLimits(): Promise<void> {
const rateLimit = await this.loadRateLimit();
const diagnostics = cloneAppServerDiagnostics(this.host.stateStore.getState().connection.appServerDiagnostics);
diagnostics.probes["account/rateLimits/read"] = rateLimit.probe;
this.host.stateStore.dispatch({
type: "connection/metadata-applied",
rateLimit: rateLimit.data,
appServerDiagnostics: diagnostics,
});
}
async refreshPublishedRateLimits(): Promise<void> {
const rateLimit = await this.loadRateLimit();
const diagnostics = cloneAppServerDiagnostics(this.host.stateStore.getState().connection.appServerDiagnostics);
diagnostics.probes["account/rateLimits/read"] = rateLimit.probe;
if (rateLimit.probe.status === "ok") {
this.host.stateStore.dispatch({
type: "connection/metadata-applied",
rateLimit: rateLimit.data,
appServerDiagnostics: diagnostics,
});
this.publishAppServerMetadataSnapshot();
return;
}
this.host.stateStore.dispatch({ type: "connection/metadata-applied", appServerDiagnostics: diagnostics });
}
async loadRateLimit(): Promise<{ data: RateLimitSnapshot | null; probe: AppServerDiagnostics["probes"]["account/rateLimits/read"] }> {
const client = this.host.currentClient();
if (!client) {
return {
data: null,
probe: capabilityProbeError("account/rateLimits/read", new Error("Codex app-server is not connected.")),
};
}
try {
const response = await client.readAccountRateLimits();
const rateLimitsByLimitId = response.rateLimitsByLimitId;
const codexRateLimit = rateLimitsByLimitId && Object.hasOwn(rateLimitsByLimitId, "codex") ? rateLimitsByLimitId["codex"] : undefined;
return {
data: codexRateLimit ?? response.rateLimits,
probe: capabilityProbeOk(
"account/rateLimits/read",
response.rateLimitsByLimitId ? `${String(Object.keys(response.rateLimitsByLimitId).length)} limits` : "available",
),
};
} catch (error) {
return { data: null, probe: capabilityProbeError("account/rateLimits/read", error) };
}
}
}

View file

@ -0,0 +1,62 @@
import type { AppServerClient } from "../../../app-server/client";
import { upsertThread } from "../../../domain/threads/model";
import type { Thread } from "../../../generated/app-server/v2/Thread";
import { requestedOrConfiguredServiceTier, type RuntimeSnapshot } from "../../../runtime/state";
import { resumedThreadAction } from "../thread-resume";
import type { ChatAppServerBaseHost } from "./shared";
export interface ChatAppServerThreadActionsHost extends ChatAppServerBaseHost {
runtimeSnapshot: () => RuntimeSnapshot;
forceMessagesToBottom: () => void;
publishThreadList: (threads: readonly Thread[]) => void;
syncThreadGoal: (threadId: string) => void;
}
export interface ChatAppServerThreadActions {
applyThreadList: (threads: readonly Thread[]) => void;
loadThreadList: () => Promise<readonly Thread[]>;
startThread: (preview?: string, options?: { syncGoal?: boolean }) => Promise<Awaited<ReturnType<AppServerClient["startThread"]>> | null>;
}
export function createChatAppServerThreadActions(host: ChatAppServerThreadActionsHost): ChatAppServerThreadActions {
return {
applyThreadList: (threads) => {
applyThreadList(host, threads);
},
loadThreadList: () => loadThreadList(host),
startThread: (preview, options) => startThread(host, preview, options),
};
}
function applyThreadList(host: ChatAppServerThreadActionsHost, threads: readonly Thread[]): void {
host.stateStore.dispatch({ type: "thread-list/applied", threads, threadsLoaded: true });
}
async function loadThreadList(host: ChatAppServerThreadActionsHost): Promise<readonly Thread[]> {
const client = host.currentClient();
if (!client) throw new Error("Codex app-server is not connected.");
const response = await client.listThreads(host.vaultPath);
return response.data;
}
async function startThread(
host: ChatAppServerThreadActionsHost,
preview?: string,
options: { syncGoal?: boolean } = {},
): Promise<Awaited<ReturnType<AppServerClient["startThread"]>> | null> {
const client = host.currentClient();
if (!client) return null;
const serviceTier = requestedOrConfiguredServiceTier(host.runtimeSnapshot());
const response = await client.startThread(host.vaultPath, serviceTier);
const state = host.stateStore.getState();
const fallbackPreview = preview?.trim();
const thread =
response.thread.preview.trim().length > 0 || !fallbackPreview ? response.thread : { ...response.thread, preview: fallbackPreview };
const listedThreads = upsertThread(state.threadList.listedThreads, thread);
const resumedResponse = thread === response.thread ? response : { ...response, thread };
host.stateStore.dispatch(resumedThreadAction({ response: resumedResponse, listedThreads, forceMessagesToBottom: true }));
host.publishThreadList(listedThreads);
host.forceMessagesToBottom();
if (options.syncGoal ?? true) host.syncThreadGoal(response.thread.id);
return response;
}

View file

@ -1,49 +0,0 @@
import type { AppServerClient } from "../../../app-server/client";
import { upsertThread } from "../../../domain/threads/model";
import type { Thread } from "../../../generated/app-server/v2/Thread";
import { requestedOrConfiguredServiceTier, type RuntimeSnapshot } from "../../../runtime/state";
import { resumedThreadAction } from "../thread-resume";
import type { ChatAppServerBaseHost } from "./shared";
export interface ChatAppServerThreadControllerHost extends ChatAppServerBaseHost {
runtimeSnapshot: () => RuntimeSnapshot;
forceMessagesToBottom: () => void;
publishThreadList: (threads: readonly Thread[]) => void;
syncThreadGoal: (threadId: string) => void;
}
export class ChatAppServerThreadController {
constructor(private readonly host: ChatAppServerThreadControllerHost) {}
applyThreadList(threads: readonly Thread[]): void {
this.host.stateStore.dispatch({ type: "thread-list/applied", threads, threadsLoaded: true });
}
async loadThreadList(): Promise<readonly Thread[]> {
const client = this.host.currentClient();
if (!client) throw new Error("Codex app-server is not connected.");
const response = await client.listThreads(this.host.vaultPath);
return response.data;
}
async startThread(
preview?: string,
options: { syncGoal?: boolean } = {},
): Promise<Awaited<ReturnType<AppServerClient["startThread"]>> | null> {
const client = this.host.currentClient();
if (!client) return null;
const serviceTier = requestedOrConfiguredServiceTier(this.host.runtimeSnapshot());
const response = await client.startThread(this.host.vaultPath, serviceTier);
const state = this.host.stateStore.getState();
const fallbackPreview = preview?.trim();
const thread =
response.thread.preview.trim().length > 0 || !fallbackPreview ? response.thread : { ...response.thread, preview: fallbackPreview };
const listedThreads = upsertThread(state.threadList.listedThreads, thread);
const resumedResponse = thread === response.thread ? response : { ...response, thread };
this.host.stateStore.dispatch(resumedThreadAction({ response: resumedResponse, listedThreads, forceMessagesToBottom: true }));
this.host.publishThreadList(listedThreads);
this.host.forceMessagesToBottom();
if (options.syncGoal ?? true) this.host.syncThreadGoal(response.thread.id);
return response;
}
}

View file

@ -1,11 +1,11 @@
import type { ChatAppServerMetadataController } from "../app-server/metadata-controller";
import type { ChatAppServerThreadController } from "../app-server/thread-controller";
import type { ChatAppServerMetadataActions } from "../app-server/metadata-actions";
import type { ChatAppServerThreadActions } from "../app-server/thread-actions";
import type { ChatPanelContext } from "./context";
export function applyCachedSharedAppServerState(
context: ChatPanelContext,
appServerThreads: ChatAppServerThreadController,
appServerMetadata: ChatAppServerMetadataController,
appServerThreads: ChatAppServerThreadActions,
appServerMetadata: ChatAppServerMetadataActions,
): void {
const threads = context.plugin.cachedThreadList();
if (threads) appServerThreads.applyThreadList(threads);

View file

@ -1,12 +1,12 @@
import { ConnectionManager } from "../../../app-server/connection-manager";
import type { ChatAppServerDiagnosticsController } from "../app-server/diagnostics-controller";
import type { ChatAppServerMetadataController } from "../app-server/metadata-controller";
import type { ChatAppServerThreadController } from "../app-server/thread-controller";
import type { ChatAppServerDiagnosticsActions } from "../app-server/diagnostics-actions";
import type { ChatAppServerMetadataActions } from "../app-server/metadata-actions";
import type { ChatAppServerThreadActions } from "../app-server/thread-actions";
import type { ChatComposerController } from "../composer/controller";
import type { ChatInboundController } from "../inbound/controller";
import type { ChatThreadGoalController } from "../threads/thread-goal-controller";
import type { ChatRuntimeSettingsController } from "../runtime/runtime-settings-controller";
import type { ChatThreadActionController } from "../threads/thread-actions-controller";
import type { ChatThreadGoalActions } from "../threads/thread-goal-actions";
import type { ChatRuntimeSettingsActions } from "../runtime/runtime-settings-actions";
import type { ChatThreadActions } from "../threads/thread-actions";
import type { ThreadHistoryController } from "../threads/thread-history-controller";
import type { ThreadRenameController } from "../threads/thread-rename-controller";
import type { ToolbarPanelController } from "./toolbar-controller";
@ -15,7 +15,7 @@ import type { ChatConnectionController } from "../session/connection-controller"
import type { ChatReconnectActions } from "../session/reconnect-actions";
import type { PendingRequestController } from "../requests/pending-request-controller";
import { createServerRequestActions } from "../requests/server-request-actions";
import type { ComposerSubmissionController } from "../turns/composer-submission-controller";
import type { ComposerSubmissionActions } from "../turns/composer-submission-actions";
import type { RestoredThreadController } from "../threads/restored-thread-controller";
import type { ThreadIdentityActions } from "../threads/thread-identity-actions";
import type { ThreadResumeController } from "../threads/thread-resume-controller";
@ -41,22 +41,22 @@ export interface ChatViewControllers {
controller: ChatInboundController;
};
appServer: {
threads: ChatAppServerThreadController;
metadata: ChatAppServerMetadataController;
diagnostics: ChatAppServerDiagnosticsController;
threads: ChatAppServerThreadActions;
metadata: ChatAppServerMetadataActions;
diagnostics: ChatAppServerDiagnosticsActions;
};
thread: {
history: ThreadHistoryController;
resume: ThreadResumeController;
actions: ChatThreadActionController;
actions: ChatThreadActions;
restored: RestoredThreadController;
identity: ThreadIdentityActions;
rename: ThreadRenameController;
selection: ThreadSelectionActions;
};
runtime: {
settings: ChatRuntimeSettingsController;
goals: ChatThreadGoalController;
settings: ChatRuntimeSettingsActions;
goals: ChatThreadGoalActions;
};
requests: {
pending: PendingRequestController;
@ -66,7 +66,7 @@ export interface ChatViewControllers {
};
composer: {
controller: ChatComposerController;
submission: ComposerSubmissionController;
submission: ComposerSubmissionActions;
};
render: {
controller: ChatViewRenderController;

View file

@ -1,10 +1,10 @@
import type { ChatAction, ChatState, ChatStateStore } from "../chat-state";
import type { ChatThreadActionController } from "../threads/thread-actions-controller";
import type { ChatThreadActions } from "../threads/thread-actions";
import type { ChatViewRenderScheduleOptions } from "./lifecycle";
export interface ToolbarPanelControllerHost {
stateStore: ChatStateStore;
threadActions: ChatThreadActionController;
threadActions: ChatThreadActions;
scheduleRender: (options?: ChatViewRenderScheduleOptions) => void;
}

View file

@ -1,6 +1,6 @@
import type { ConnectionManager } from "../../../app-server/connection-manager";
import type { ChatAppServerMetadataController } from "../app-server/metadata-controller";
import type { ChatAppServerThreadController } from "../app-server/thread-controller";
import type { ChatAppServerMetadataActions } from "../app-server/metadata-actions";
import type { ChatAppServerThreadActions } from "../app-server/thread-actions";
import type { ChatComposerController } from "../composer/controller";
import { createAppServerWarmupActions } from "../session/app-server-warmup-controller";
import { createChatViewOpenCloseActions } from "./open-close-actions";
@ -45,8 +45,8 @@ export function createConnectionLifecycleControllerGroup(
connection: ConnectionManager;
composerController: ChatComposerController;
messageRenderer: ChatMessageRenderer;
appServerThreads: ChatAppServerThreadController;
appServerMetadata: ChatAppServerMetadataController;
appServerThreads: ChatAppServerThreadActions;
appServerMetadata: ChatAppServerMetadataActions;
},
) {
const { obsidian, lifecycle, render, liveState, scroll, client } = context;

View file

@ -0,0 +1,172 @@
import type { AppServerClient } from "../../../app-server/client";
import { requestedServiceTierRequestValue, type RequestedServiceTier } from "../../../app-server/service-tier";
import type { ModeKind } from "../../../generated/app-server/ModeKind";
import type { ReasoningEffort } from "../../../generated/app-server/ReasoningEffort";
import type { ApprovalsReviewer } from "../../../generated/app-server/v2/ApprovalsReviewer";
import type { ThreadSettingsUpdateParams } from "../../../generated/app-server/v2/ThreadSettingsUpdateParams";
import { collaborationModeToggleMessage, nextCollaborationMode } from "../../../runtime/collaboration-mode";
import { readRuntimeConfig } from "../../../runtime/config";
import {
autoReviewActive,
fastModeActive,
fastServiceTierRequestValue,
pendingRuntimeSettingPayload,
requestedTurnRuntimeSettings,
type RuntimeSnapshot,
} from "../../../runtime/state";
import { modelOverrideMessage, reasoningEffortOverrideMessage } from "../../../runtime/settings";
import type { ChatAction, ChatState, ChatStateStore } from "../chat-state";
type ThreadSettingsUpdate = Omit<ThreadSettingsUpdateParams, "threadId">;
export interface RuntimeSettingsActionsHost {
stateStore: ChatStateStore;
currentClient: () => AppServerClient | null;
runtimeSnapshot: () => RuntimeSnapshot;
collaborationModeLabel: () => string;
addSystemMessage: (text: string) => void;
}
export interface ChatRuntimeSettingsActions {
applyPendingThreadSettings: () => Promise<boolean>;
setRequestedModel: (model: string | null) => Promise<boolean>;
setRequestedModelFromUi: (model: string | null) => Promise<void>;
setRequestedReasoningEffort: (effort: ReasoningEffort | null) => Promise<boolean>;
setRequestedReasoningEffortFromUi: (effort: ReasoningEffort | null) => Promise<void>;
toggleFastMode: () => Promise<void>;
toggleCollaborationMode: () => Promise<void>;
setCollaborationMode: (collaborationMode: ModeKind) => Promise<boolean>;
toggleAutoReview: () => Promise<void>;
}
export function createChatRuntimeSettingsActions(host: RuntimeSettingsActionsHost): ChatRuntimeSettingsActions {
return {
applyPendingThreadSettings: () => applyPendingThreadSettings(host),
setRequestedModel: (model) => setRequestedModel(host, model),
setRequestedModelFromUi: (model) => setRequestedModelFromUi(host, model),
setRequestedReasoningEffort: (effort) => setRequestedReasoningEffort(host, effort),
setRequestedReasoningEffortFromUi: (effort) => setRequestedReasoningEffortFromUi(host, effort),
toggleFastMode: () => toggleFastMode(host),
toggleCollaborationMode: () => toggleCollaborationMode(host),
setCollaborationMode: (collaborationMode) => setCollaborationMode(host, collaborationMode),
toggleAutoReview: () => toggleAutoReview(host),
};
}
async function applyPendingThreadSettings(host: RuntimeSettingsActionsHost): Promise<boolean> {
const client = host.currentClient();
const threadId = state(host).activeThread.id;
if (!client || !threadId) return true;
const update = pendingThreadSettingsUpdate(host);
if (Object.keys(update).length === 0) return true;
try {
await client.updateThreadSettings(threadId, update);
dispatch(host, { type: "runtime/pending-thread-settings-committed", update });
return true;
} catch (error) {
host.addSystemMessage(error instanceof Error ? error.message : String(error));
return false;
}
}
async function setRequestedModel(host: RuntimeSettingsActionsHost, model: string | null): Promise<boolean> {
dispatch(host, { type: "runtime/requested-model-set", model });
return applyPendingThreadSettings(host);
}
async function setRequestedModelFromUi(host: RuntimeSettingsActionsHost, model: string | null): Promise<void> {
if (!(await setRequestedModel(host, model))) return;
dispatch(host, { type: "ui/panel-set", panel: null });
host.addSystemMessage(modelOverrideMessage(model));
}
async function setRequestedReasoningEffort(host: RuntimeSettingsActionsHost, effort: ReasoningEffort | null): Promise<boolean> {
dispatch(host, { type: "runtime/requested-effort-set", effort });
return applyPendingThreadSettings(host);
}
async function setRequestedReasoningEffortFromUi(host: RuntimeSettingsActionsHost, effort: ReasoningEffort | null): Promise<void> {
if (!(await setRequestedReasoningEffort(host, effort))) return;
dispatch(host, { type: "ui/panel-set", panel: null });
host.addSystemMessage(reasoningEffortOverrideMessage(effort));
}
async function toggleFastMode(host: RuntimeSettingsActionsHost): Promise<void> {
const snapshot = host.runtimeSnapshot();
const config = readRuntimeConfig(state(host).connection.effectiveConfig);
const next: RequestedServiceTier = fastModeActive(snapshot, config) ? "off" : "fast";
dispatch(host, { type: "runtime/requested-service-tier-set", serviceTier: next });
dispatch(host, { type: "ui/panel-set", panel: null });
if (!(await applyPendingThreadSettings(host))) return;
host.addSystemMessage(next === "fast" ? "Fast mode on for subsequent turns." : "Fast mode off for subsequent turns.");
}
async function toggleCollaborationMode(host: RuntimeSettingsActionsHost): Promise<void> {
const next = nextCollaborationMode(state(host).runtime.selectedCollaborationMode);
await setCollaborationMode(host, next);
}
async function setCollaborationMode(host: RuntimeSettingsActionsHost, collaborationMode: ModeKind): Promise<boolean> {
dispatch(host, { type: "runtime/requested-collaboration-mode-set", collaborationMode });
dispatch(host, { type: "ui/panel-set", panel: null });
const applied = await applyPendingThreadSettings(host);
if (applied) host.addSystemMessage(collaborationModeToggleMessage(collaborationMode));
return applied;
}
async function toggleAutoReview(host: RuntimeSettingsActionsHost): Promise<void> {
const next: ApprovalsReviewer = autoReviewActive(host.runtimeSnapshot(), readRuntimeConfig(state(host).connection.effectiveConfig))
? "user"
: "auto_review";
dispatch(host, { type: "runtime/requested-approvals-reviewer-set", approvalsReviewer: next });
dispatch(host, { type: "ui/panel-set", panel: null });
if (!(await applyPendingThreadSettings(host))) return;
host.addSystemMessage(next === "auto_review" ? "Auto-review on for subsequent turns." : "Auto-review off for subsequent turns.");
}
function pendingThreadSettingsUpdate(host: RuntimeSettingsActionsHost): ThreadSettingsUpdate {
const update: ThreadSettingsUpdate = {};
const currentState = state(host);
const snapshot = host.runtimeSnapshot();
const turnSettings = requestedTurnRuntimeSettings(snapshot);
if (currentState.runtime.requestedModel.kind !== "unchanged") {
const model = pendingRuntimeSettingPayload(currentState.runtime.requestedModel);
if (model !== undefined) update.model = model;
}
if (currentState.runtime.requestedReasoningEffort.kind !== "unchanged") {
const effort = pendingRuntimeSettingPayload(currentState.runtime.requestedReasoningEffort);
if (effort !== undefined) update.effort = effort;
}
if (currentState.runtime.requestedServiceTier.kind === "set") {
const serviceTier = requestedServiceTierRequestValue(
currentState.runtime.requestedServiceTier.value,
fastServiceTierRequestValue(snapshot, readRuntimeConfig(currentState.connection.effectiveConfig)),
);
if (serviceTier !== undefined) update.serviceTier = serviceTier;
} else if (currentState.runtime.requestedServiceTier.kind === "resetToConfig") {
update.serviceTier = null;
}
if (currentState.runtime.requestedApprovalsReviewer.kind !== "unchanged") {
const approvalsReviewer = pendingRuntimeSettingPayload(currentState.runtime.requestedApprovalsReviewer);
if (approvalsReviewer !== undefined) update.approvalsReviewer = approvalsReviewer;
}
if (currentState.runtime.selectedCollaborationMode !== currentState.runtime.activeCollaborationMode) {
if (turnSettings.warning) {
host.addSystemMessage(`${host.collaborationModeLabel()} mode is selected, but ${turnSettings.warning}`);
} else if (turnSettings.collaborationMode) {
update.collaborationMode = turnSettings.collaborationMode;
}
}
return update;
}
function state(host: RuntimeSettingsActionsHost): ChatState {
return host.stateStore.getState();
}
function dispatch(host: RuntimeSettingsActionsHost, action: ChatAction): void {
host.stateStore.dispatch(action);
}

View file

@ -1,150 +0,0 @@
import type { AppServerClient } from "../../../app-server/client";
import { requestedServiceTierRequestValue, type RequestedServiceTier } from "../../../app-server/service-tier";
import type { ModeKind } from "../../../generated/app-server/ModeKind";
import type { ReasoningEffort } from "../../../generated/app-server/ReasoningEffort";
import type { ApprovalsReviewer } from "../../../generated/app-server/v2/ApprovalsReviewer";
import type { ThreadSettingsUpdateParams } from "../../../generated/app-server/v2/ThreadSettingsUpdateParams";
import { collaborationModeToggleMessage, nextCollaborationMode } from "../../../runtime/collaboration-mode";
import { readRuntimeConfig } from "../../../runtime/config";
import {
autoReviewActive,
fastModeActive,
fastServiceTierRequestValue,
pendingRuntimeSettingPayload,
requestedTurnRuntimeSettings,
type RuntimeSnapshot,
} from "../../../runtime/state";
import { modelOverrideMessage, reasoningEffortOverrideMessage } from "../../../runtime/settings";
import type { ChatAction, ChatState, ChatStateStore } from "../chat-state";
type ThreadSettingsUpdate = Omit<ThreadSettingsUpdateParams, "threadId">;
export interface RuntimeSettingsControllerHost {
stateStore: ChatStateStore;
currentClient: () => AppServerClient | null;
runtimeSnapshot: () => RuntimeSnapshot;
collaborationModeLabel: () => string;
addSystemMessage: (text: string) => void;
}
export class ChatRuntimeSettingsController {
constructor(private readonly host: RuntimeSettingsControllerHost) {}
private get state(): ChatState {
return this.host.stateStore.getState();
}
private dispatch(action: ChatAction): void {
this.host.stateStore.dispatch(action);
}
async applyPendingThreadSettings(): Promise<boolean> {
const client = this.host.currentClient();
const threadId = this.state.activeThread.id;
if (!client || !threadId) return true;
const update = this.pendingThreadSettingsUpdate();
if (Object.keys(update).length === 0) return true;
try {
await client.updateThreadSettings(threadId, update);
this.dispatch({ type: "runtime/pending-thread-settings-committed", update });
return true;
} catch (error) {
this.host.addSystemMessage(error instanceof Error ? error.message : String(error));
return false;
}
}
async setRequestedModel(model: string | null): Promise<boolean> {
this.dispatch({ type: "runtime/requested-model-set", model });
return this.applyPendingThreadSettings();
}
async setRequestedModelFromUi(model: string | null): Promise<void> {
if (!(await this.setRequestedModel(model))) return;
this.dispatch({ type: "ui/panel-set", panel: null });
this.host.addSystemMessage(modelOverrideMessage(model));
}
async setRequestedReasoningEffort(effort: ReasoningEffort | null): Promise<boolean> {
this.dispatch({ type: "runtime/requested-effort-set", effort });
return this.applyPendingThreadSettings();
}
async setRequestedReasoningEffortFromUi(effort: ReasoningEffort | null): Promise<void> {
if (!(await this.setRequestedReasoningEffort(effort))) return;
this.dispatch({ type: "ui/panel-set", panel: null });
this.host.addSystemMessage(reasoningEffortOverrideMessage(effort));
}
async toggleFastMode(): Promise<void> {
const snapshot = this.host.runtimeSnapshot();
const config = readRuntimeConfig(this.state.connection.effectiveConfig);
const next: RequestedServiceTier = fastModeActive(snapshot, config) ? "off" : "fast";
this.dispatch({ type: "runtime/requested-service-tier-set", serviceTier: next });
this.dispatch({ type: "ui/panel-set", panel: null });
if (!(await this.applyPendingThreadSettings())) return;
this.host.addSystemMessage(next === "fast" ? "Fast mode on for subsequent turns." : "Fast mode off for subsequent turns.");
}
async toggleCollaborationMode(): Promise<void> {
const next = nextCollaborationMode(this.state.runtime.selectedCollaborationMode);
await this.setCollaborationMode(next);
}
async setCollaborationMode(collaborationMode: ModeKind): Promise<boolean> {
this.dispatch({ type: "runtime/requested-collaboration-mode-set", collaborationMode });
this.dispatch({ type: "ui/panel-set", panel: null });
const applied = await this.applyPendingThreadSettings();
if (applied) this.host.addSystemMessage(collaborationModeToggleMessage(collaborationMode));
return applied;
}
async toggleAutoReview(): Promise<void> {
const next: ApprovalsReviewer = autoReviewActive(this.host.runtimeSnapshot(), readRuntimeConfig(this.state.connection.effectiveConfig))
? "user"
: "auto_review";
this.dispatch({ type: "runtime/requested-approvals-reviewer-set", approvalsReviewer: next });
this.dispatch({ type: "ui/panel-set", panel: null });
if (!(await this.applyPendingThreadSettings())) return;
this.host.addSystemMessage(next === "auto_review" ? "Auto-review on for subsequent turns." : "Auto-review off for subsequent turns.");
}
private pendingThreadSettingsUpdate(): ThreadSettingsUpdate {
const update: ThreadSettingsUpdate = {};
const state = this.state;
const snapshot = this.host.runtimeSnapshot();
const turnSettings = requestedTurnRuntimeSettings(snapshot);
if (state.runtime.requestedModel.kind !== "unchanged") {
const model = pendingRuntimeSettingPayload(state.runtime.requestedModel);
if (model !== undefined) update.model = model;
}
if (state.runtime.requestedReasoningEffort.kind !== "unchanged") {
const effort = pendingRuntimeSettingPayload(state.runtime.requestedReasoningEffort);
if (effort !== undefined) update.effort = effort;
}
if (state.runtime.requestedServiceTier.kind === "set") {
const serviceTier = requestedServiceTierRequestValue(
state.runtime.requestedServiceTier.value,
fastServiceTierRequestValue(snapshot, readRuntimeConfig(state.connection.effectiveConfig)),
);
if (serviceTier !== undefined) update.serviceTier = serviceTier;
} else if (state.runtime.requestedServiceTier.kind === "resetToConfig") {
update.serviceTier = null;
}
if (state.runtime.requestedApprovalsReviewer.kind !== "unchanged") {
const approvalsReviewer = pendingRuntimeSettingPayload(state.runtime.requestedApprovalsReviewer);
if (approvalsReviewer !== undefined) update.approvalsReviewer = approvalsReviewer;
}
if (state.runtime.selectedCollaborationMode !== state.runtime.activeCollaborationMode) {
if (turnSettings.warning) {
this.host.addSystemMessage(`${this.host.collaborationModeLabel()} mode is selected, but ${turnSettings.warning}`);
} else if (turnSettings.collaborationMode) {
update.collaborationMode = turnSettings.collaborationMode;
}
}
return update;
}
}

View file

@ -1,12 +1,12 @@
import { Notice } from "obsidian";
import type { ConnectionManager } from "../../../app-server/connection-manager";
import { ChatAppServerDiagnosticsController } from "../app-server/diagnostics-controller";
import { ChatAppServerMetadataController } from "../app-server/metadata-controller";
import { ChatAppServerThreadController } from "../app-server/thread-controller";
import { createChatAppServerDiagnosticsActions, type ChatAppServerDiagnosticsActions } from "../app-server/diagnostics-actions";
import { createChatAppServerMetadataActions, type ChatAppServerMetadataActions } from "../app-server/metadata-actions";
import { createChatAppServerThreadActions } from "../app-server/thread-actions";
import { ChatConnectionController } from "./connection-controller";
import type { ServerRequestActions } from "../requests/server-request-actions";
import type { ChatThreadGoalController } from "../threads/thread-goal-controller";
import type { ChatThreadGoalActions } from "../threads/thread-goal-actions";
import type { ThreadRenameController } from "../threads/thread-rename-controller";
import { ChatInboundController } from "../inbound/controller";
import type { ChatPanelContext } from "../panel/context";
@ -15,7 +15,7 @@ export function createChatAppServerControllers(
context: ChatPanelContext,
refs: {
connection: ConnectionManager;
goals: ChatThreadGoalController;
goals: ChatThreadGoalActions;
},
) {
const { plugin, runtime, scroll } = context;
@ -25,20 +25,20 @@ export function createChatAppServerControllers(
vaultPath: plugin.vaultPath,
currentClient: () => refs.connection.currentClient(),
};
const appServerMetadata = new ChatAppServerMetadataController({
const appServerMetadata = createChatAppServerMetadataActions({
...appServerBaseHost,
publishAppServerMetadata: (metadata) => {
plugin.publishAppServerMetadata(metadata);
},
});
const appServerDiagnostics = new ChatAppServerDiagnosticsController({
const appServerDiagnostics = createChatAppServerDiagnosticsActions({
...appServerBaseHost,
publishAppServerMetadata: (metadata) => {
plugin.publishAppServerMetadata(metadata);
},
appServerMetadataSnapshot: () => appServerMetadata.appServerMetadataSnapshot(),
});
const appServerThreads = new ChatAppServerThreadController({
const appServerThreads = createChatAppServerThreadActions({
...appServerBaseHost,
runtimeSnapshot: runtime.runtimeSnapshot,
forceMessagesToBottom: scroll.forceBottom,
@ -56,8 +56,8 @@ export function createChatAppServerControllers(
export function createChatInboundController(
context: ChatPanelContext,
refs: {
appServerMetadata: ChatAppServerMetadataController;
appServerDiagnostics: ChatAppServerDiagnosticsController;
appServerMetadata: ChatAppServerMetadataActions;
appServerDiagnostics: ChatAppServerDiagnosticsActions;
threadRename: ThreadRenameController;
serverRequestResponder: ServerRequestActions;
},
@ -91,8 +91,8 @@ export function createChatConnectionControllers(
context: ChatPanelContext,
refs: {
connection: ConnectionManager;
appServerMetadata: ChatAppServerMetadataController;
appServerDiagnostics: ChatAppServerDiagnosticsController;
appServerMetadata: ChatAppServerMetadataActions;
appServerDiagnostics: ChatAppServerDiagnosticsActions;
},
) {
const { plugin, client, thread, status, liveState, render, lifecycle } = context;

View file

@ -1,8 +1,8 @@
import type { ConnectionManager } from "../../../app-server/connection-manager";
import { recoverRolloutTokenUsage } from "../../../app-server/rollout-token-usage";
import { ChatRuntimeSettingsController } from "../runtime/runtime-settings-controller";
import { ChatThreadActionController } from "./thread-actions-controller";
import { ChatThreadGoalController } from "./thread-goal-controller";
import { createChatRuntimeSettingsActions } from "../runtime/runtime-settings-actions";
import { createChatThreadActions } from "./thread-actions";
import { createChatThreadGoalActions } from "./thread-goal-actions";
import { ThreadHistoryController } from "./thread-history-controller";
import { createThreadIdentityActions } from "./thread-identity-actions";
import { ThreadRenameController } from "./thread-rename-controller";
@ -34,7 +34,7 @@ export function createThreadControllerGroup(
keepCurrentScrollPosition: scroll.preservePosition,
setThreadTurnPresence: thread.resetTurnPresence,
});
const threadActions = new ChatThreadActionController({
const threadActions = createChatThreadActions({
stateStore,
vaultPath: plugin.vaultPath,
settings: () => plugin.settings,
@ -86,14 +86,14 @@ export function createThreadControllerGroup(
resumeThread: thread.resumeThread,
addSystemMessage: status.addSystemMessage,
});
const runtimeSettings = new ChatRuntimeSettingsController({
const runtimeSettings = createChatRuntimeSettingsActions({
stateStore,
currentClient,
runtimeSnapshot: runtime.runtimeSnapshot,
collaborationModeLabel: runtime.collaborationModeLabel,
addSystemMessage: status.addSystemMessage,
});
const goals = new ChatThreadGoalController({
const goals = createChatThreadGoalActions({
stateStore,
currentClient,
ensureConnected: client.ensureConnected,

View file

@ -1,188 +0,0 @@
import { Notice } from "obsidian";
import type { AppServerClient } from "../../../app-server/client";
import { exportArchivedThreadMarkdown } from "../../../domain/threads/export";
import type { ArchiveExportAdapter } from "../../../domain/threads/export";
import { inheritedForkThreadName, upsertThread } from "../../../domain/threads/model";
import type { CodexPanelSettings } from "../../../settings/model";
import { chatTurnBusy, type ChatAction, type ChatState, type ChatStateStore } from "../chat-state";
import { turnsAfterTurnId } from "../fork";
import { rollbackCandidateFromItems } from "../rollback";
import type { ThreadHistoryController } from "./thread-history-controller";
export interface ChatThreadActionControllerHost {
stateStore: ChatStateStore;
vaultPath: string;
settings: () => CodexPanelSettings;
archiveAdapter: () => ArchiveExportAdapter;
ensureConnected: () => Promise<void>;
currentClient: () => AppServerClient | null;
history: ThreadHistoryController;
addSystemMessage: (text: string) => void;
setStatus: (status: string) => void;
setComposerText: (text: string) => void;
openThreadInNewView: (threadId: string) => Promise<unknown>;
openThreadInCurrentPanel: (threadId: string) => Promise<void>;
notifyThreadArchived: (threadId: string) => void;
notifyThreadRenamed: (threadId: string, name: string) => void;
notifyActiveThreadIdentityChanged: () => void;
refreshThreads: () => Promise<void>;
refreshSharedThreadListFromOpenSurface: () => void;
}
export class ChatThreadActionController {
constructor(private readonly host: ChatThreadActionControllerHost) {}
private get state(): ChatState {
return this.host.stateStore.getState();
}
private dispatch(action: ChatAction): void {
this.host.stateStore.dispatch(action);
}
async compactThread(threadId: string): Promise<void> {
await this.host.ensureConnected();
const client = this.host.currentClient();
if (!client) return;
try {
await client.compactThread(threadId);
this.host.addSystemMessage("Compaction requested.");
this.host.setStatus("Compaction requested.");
} catch (error) {
this.host.addSystemMessage(error instanceof Error ? error.message : String(error));
}
}
async archiveThread(threadId: string, saveMarkdown = this.host.settings().archiveExportEnabled): Promise<void> {
if (await this.archiveThreadOnServer(threadId, saveMarkdown)) {
this.host.notifyThreadArchived(threadId);
}
}
private async archiveThreadOnServer(threadId: string, saveMarkdown = this.host.settings().archiveExportEnabled): Promise<boolean> {
if (chatTurnBusy(this.state)) {
this.host.addSystemMessage("Finish or interrupt the current turn before archiving threads.");
return false;
}
const client = this.host.currentClient();
if (!client) return false;
try {
const settings = this.host.settings();
if (saveMarkdown) {
const response = await client.readThread(threadId, true);
const result = await exportArchivedThreadMarkdown(
response.thread,
{ ...settings, vaultPath: this.host.vaultPath },
this.host.archiveAdapter(),
);
new Notice(`Saved archived thread to ${result.path}.`);
}
await client.archiveThread(threadId);
return true;
} catch (error) {
this.host.addSystemMessage(error instanceof Error ? error.message : String(error));
return false;
}
}
async forkThread(threadId: string): Promise<void> {
await this.forkThreadFromTurn(threadId, null, false);
}
async forkThreadFromTurn(threadId: string, turnId: string | null, archiveSource: boolean): Promise<void> {
if (chatTurnBusy(this.state)) {
this.host.addSystemMessage("Finish or interrupt the current turn before forking threads.");
return;
}
await this.host.ensureConnected();
const client = this.host.currentClient();
if (!client) return;
const turnsToDrop = turnId ? turnsAfterTurnId(this.state.transcript.displayItems, turnId) : 0;
if (turnsToDrop === null) {
this.host.addSystemMessage("Could not find the selected turn to fork.");
return;
}
try {
const sourceName = inheritedForkThreadName(threadId, this.state.threadList.listedThreads);
const response = await client.forkThread(threadId, this.host.vaultPath);
const forkedThreadId = response.thread.id;
if (turnsToDrop > 0) {
await client.rollbackThread(forkedThreadId, turnsToDrop);
}
if (sourceName) {
try {
await client.setThreadName(forkedThreadId, sourceName);
this.host.notifyThreadRenamed(forkedThreadId, sourceName);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.host.addSystemMessage(`Forked thread ${forkedThreadId}, but could not copy the source thread name: ${message}`);
}
}
if (archiveSource) {
if (!(await this.archiveThreadOnServer(threadId))) return;
try {
await this.host.openThreadInCurrentPanel(forkedThreadId);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.host.addSystemMessage(`Archived thread ${threadId}, but could not open forked thread ${forkedThreadId}: ${message}`);
}
this.host.notifyThreadArchived(threadId);
return;
}
try {
await this.host.openThreadInNewView(forkedThreadId);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.host.addSystemMessage(`Forked thread ${forkedThreadId}, but could not open it in a new panel: ${message}`);
}
} catch (error) {
this.host.addSystemMessage(error instanceof Error ? error.message : String(error));
}
}
async rollbackThread(threadId: string): Promise<void> {
if (chatTurnBusy(this.state)) {
this.host.addSystemMessage("Interrupt the current turn before rolling back.");
return;
}
await this.host.ensureConnected();
const client = this.host.currentClient();
if (!client) return;
const candidate = rollbackCandidateFromItems(this.state.transcript.displayItems);
if (!candidate) {
this.host.addSystemMessage("No completed turn to roll back.");
return;
}
try {
this.host.setStatus("Rolling back latest turn...");
const response = await client.rollbackThread(threadId);
this.dispatch({
type: "active-thread/resumed",
thread: response.thread,
cwd: response.thread.cwd,
model: this.state.runtime.activeModel,
reasoningEffort: this.state.runtime.activeReasoningEffort,
serviceTier: this.state.runtime.activeServiceTier,
approvalPolicy: this.state.runtime.activeApprovalPolicy,
approvalsReviewer: this.state.runtime.activeApprovalsReviewer,
activePermissionProfile: this.state.runtime.activePermissionProfile,
listedThreads: upsertThread(this.state.threadList.listedThreads, response.thread),
});
await this.host.history.loadLatest(response.thread.id);
this.host.setComposerText(candidate.text);
this.host.addSystemMessage("Rolled back the latest turn. Local file changes were not reverted.");
this.host.setStatus("Rolled back latest turn.");
this.host.notifyActiveThreadIdentityChanged();
await this.host.refreshThreads();
this.host.refreshSharedThreadListFromOpenSurface();
} catch (error) {
this.host.addSystemMessage(error instanceof Error ? error.message : String(error));
this.host.setStatus("Rollback failed.");
}
}
}

View file

@ -0,0 +1,211 @@
import { Notice } from "obsidian";
import type { AppServerClient } from "../../../app-server/client";
import { exportArchivedThreadMarkdown } from "../../../domain/threads/export";
import type { ArchiveExportAdapter } from "../../../domain/threads/export";
import { inheritedForkThreadName, upsertThread } from "../../../domain/threads/model";
import type { CodexPanelSettings } from "../../../settings/model";
import { chatTurnBusy, type ChatAction, type ChatState, type ChatStateStore } from "../chat-state";
import { turnsAfterTurnId } from "../fork";
import { rollbackCandidateFromItems } from "../rollback";
import type { ThreadHistoryController } from "./thread-history-controller";
export interface ChatThreadActionsHost {
stateStore: ChatStateStore;
vaultPath: string;
settings: () => CodexPanelSettings;
archiveAdapter: () => ArchiveExportAdapter;
ensureConnected: () => Promise<void>;
currentClient: () => AppServerClient | null;
history: ThreadHistoryController;
addSystemMessage: (text: string) => void;
setStatus: (status: string) => void;
setComposerText: (text: string) => void;
openThreadInNewView: (threadId: string) => Promise<unknown>;
openThreadInCurrentPanel: (threadId: string) => Promise<void>;
notifyThreadArchived: (threadId: string) => void;
notifyThreadRenamed: (threadId: string, name: string) => void;
notifyActiveThreadIdentityChanged: () => void;
refreshThreads: () => Promise<void>;
refreshSharedThreadListFromOpenSurface: () => void;
}
export interface ChatThreadActions {
compactThread: (threadId: string) => Promise<void>;
archiveThread: (threadId: string, saveMarkdown?: boolean) => Promise<void>;
forkThread: (threadId: string) => Promise<void>;
forkThreadFromTurn: (threadId: string, turnId: string | null, archiveSource: boolean) => Promise<void>;
rollbackThread: (threadId: string) => Promise<void>;
}
export function createChatThreadActions(host: ChatThreadActionsHost): ChatThreadActions {
return {
compactThread: (threadId) => compactThread(host, threadId),
archiveThread: (threadId, saveMarkdown) => archiveThread(host, threadId, saveMarkdown),
forkThread: (threadId) => forkThread(host, threadId),
forkThreadFromTurn: (threadId, turnId, archiveSource) => forkThreadFromTurn(host, threadId, turnId, archiveSource),
rollbackThread: (threadId) => rollbackThread(host, threadId),
};
}
async function compactThread(host: ChatThreadActionsHost, threadId: string): Promise<void> {
await host.ensureConnected();
const client = host.currentClient();
if (!client) return;
try {
await client.compactThread(threadId);
host.addSystemMessage("Compaction requested.");
host.setStatus("Compaction requested.");
} catch (error) {
host.addSystemMessage(error instanceof Error ? error.message : String(error));
}
}
async function archiveThread(
host: ChatThreadActionsHost,
threadId: string,
saveMarkdown = host.settings().archiveExportEnabled,
): Promise<void> {
if (await archiveThreadOnServer(host, threadId, saveMarkdown)) {
host.notifyThreadArchived(threadId);
}
}
async function archiveThreadOnServer(
host: ChatThreadActionsHost,
threadId: string,
saveMarkdown = host.settings().archiveExportEnabled,
): Promise<boolean> {
if (chatTurnBusy(state(host))) {
host.addSystemMessage("Finish or interrupt the current turn before archiving threads.");
return false;
}
const client = host.currentClient();
if (!client) return false;
try {
const settings = host.settings();
if (saveMarkdown) {
const response = await client.readThread(threadId, true);
const result = await exportArchivedThreadMarkdown(response.thread, { ...settings, vaultPath: host.vaultPath }, host.archiveAdapter());
new Notice(`Saved archived thread to ${result.path}.`);
}
await client.archiveThread(threadId);
return true;
} catch (error) {
host.addSystemMessage(error instanceof Error ? error.message : String(error));
return false;
}
}
function forkThread(host: ChatThreadActionsHost, threadId: string): Promise<void> {
return forkThreadFromTurn(host, threadId, null, false);
}
async function forkThreadFromTurn(
host: ChatThreadActionsHost,
threadId: string,
turnId: string | null,
archiveSource: boolean,
): Promise<void> {
if (chatTurnBusy(state(host))) {
host.addSystemMessage("Finish or interrupt the current turn before forking threads.");
return;
}
await host.ensureConnected();
const client = host.currentClient();
if (!client) return;
const turnsToDrop = turnId ? turnsAfterTurnId(state(host).transcript.displayItems, turnId) : 0;
if (turnsToDrop === null) {
host.addSystemMessage("Could not find the selected turn to fork.");
return;
}
try {
const sourceName = inheritedForkThreadName(threadId, state(host).threadList.listedThreads);
const response = await client.forkThread(threadId, host.vaultPath);
const forkedThreadId = response.thread.id;
if (turnsToDrop > 0) {
await client.rollbackThread(forkedThreadId, turnsToDrop);
}
if (sourceName) {
try {
await client.setThreadName(forkedThreadId, sourceName);
host.notifyThreadRenamed(forkedThreadId, sourceName);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
host.addSystemMessage(`Forked thread ${forkedThreadId}, but could not copy the source thread name: ${message}`);
}
}
if (archiveSource) {
if (!(await archiveThreadOnServer(host, threadId))) return;
try {
await host.openThreadInCurrentPanel(forkedThreadId);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
host.addSystemMessage(`Archived thread ${threadId}, but could not open forked thread ${forkedThreadId}: ${message}`);
}
host.notifyThreadArchived(threadId);
return;
}
try {
await host.openThreadInNewView(forkedThreadId);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
host.addSystemMessage(`Forked thread ${forkedThreadId}, but could not open it in a new panel: ${message}`);
}
} catch (error) {
host.addSystemMessage(error instanceof Error ? error.message : String(error));
}
}
async function rollbackThread(host: ChatThreadActionsHost, threadId: string): Promise<void> {
if (chatTurnBusy(state(host))) {
host.addSystemMessage("Interrupt the current turn before rolling back.");
return;
}
await host.ensureConnected();
const client = host.currentClient();
if (!client) return;
const candidate = rollbackCandidateFromItems(state(host).transcript.displayItems);
if (!candidate) {
host.addSystemMessage("No completed turn to roll back.");
return;
}
try {
host.setStatus("Rolling back latest turn...");
const response = await client.rollbackThread(threadId);
dispatch(host, {
type: "active-thread/resumed",
thread: response.thread,
cwd: response.thread.cwd,
model: state(host).runtime.activeModel,
reasoningEffort: state(host).runtime.activeReasoningEffort,
serviceTier: state(host).runtime.activeServiceTier,
approvalPolicy: state(host).runtime.activeApprovalPolicy,
approvalsReviewer: state(host).runtime.activeApprovalsReviewer,
activePermissionProfile: state(host).runtime.activePermissionProfile,
listedThreads: upsertThread(state(host).threadList.listedThreads, response.thread),
});
await host.history.loadLatest(response.thread.id);
host.setComposerText(candidate.text);
host.addSystemMessage("Rolled back the latest turn. Local file changes were not reverted.");
host.setStatus("Rolled back latest turn.");
host.notifyActiveThreadIdentityChanged();
await host.refreshThreads();
host.refreshSharedThreadListFromOpenSurface();
} catch (error) {
host.addSystemMessage(error instanceof Error ? error.message : String(error));
host.setStatus("Rollback failed.");
}
}
function state(host: ChatThreadActionsHost): ChatState {
return host.stateStore.getState();
}
function dispatch(host: ChatThreadActionsHost, action: ChatAction): void {
host.stateStore.dispatch(action);
}

View file

@ -0,0 +1,148 @@
import type { AppServerClient } from "../../../app-server/client";
import type { JsonValue } from "../../../generated/app-server/serde_json/JsonValue";
import type { ThreadGoal } from "../../../generated/app-server/v2/ThreadGoal";
import type { ThreadGoalStatus } from "../../../generated/app-server/v2/ThreadGoalStatus";
import type { ChatStateStore } from "../chat-state";
import type { GoalDisplayItem } from "../display/types";
import { goalChangeItem } from "../goal-messages";
export interface ChatThreadGoalActionsHost {
stateStore: ChatStateStore;
currentClient: () => AppServerClient | null;
ensureConnected: () => Promise<void>;
addSystemMessage: (text: string) => void;
addGoalEvent: (item: GoalDisplayItem) => void;
render: () => void;
refreshLiveState: () => void;
}
export interface ChatThreadGoalActions {
activeGoal: () => ThreadGoal | null;
syncThreadGoal: (threadId: string) => Promise<void>;
setObjective: (threadId: string, objective: string, tokenBudget: number | null) => Promise<boolean>;
setStatus: (threadId: string, status: ThreadGoalStatus) => Promise<boolean>;
clear: (threadId: string) => Promise<boolean>;
}
export function createChatThreadGoalActions(host: ChatThreadGoalActionsHost): ChatThreadGoalActions {
return {
activeGoal: () => host.stateStore.getState().activeThread.goal,
syncThreadGoal: (threadId) => syncThreadGoal(host, threadId),
setObjective: (threadId, objective, tokenBudget) => setObjective(host, threadId, objective, tokenBudget),
setStatus: (threadId, status) => setGoalStatus(host, threadId, status),
clear: (threadId) => clearGoal(host, threadId),
};
}
async function syncThreadGoal(host: ChatThreadGoalActionsHost, threadId: string): Promise<void> {
const client = host.currentClient();
if (!client) return;
try {
const response = await client.getThreadGoal(threadId);
applyGoalIfActive(host, threadId, response.goal, { reportChange: false });
} catch (error) {
if (host.stateStore.getState().activeThread.id !== threadId) return;
host.addSystemMessage(`Could not load thread goal: ${errorMessage(error)}`);
}
}
async function setObjective(
host: ChatThreadGoalActionsHost,
threadId: string,
objective: string,
tokenBudget: number | null,
): Promise<boolean> {
const trimmed = objective.trim();
if (!trimmed) {
host.addSystemMessage("Goal objective cannot be empty.");
return false;
}
const current = host.stateStore.getState().activeThread.goal;
const isNewGoal = current === null;
const applied = await setGoal(host, threadId, {
objective: trimmed,
status: current?.status ?? "active",
tokenBudget,
});
if (applied && isNewGoal) {
await recordGoalUserMessage(host, threadId, trimmed);
}
return applied;
}
function setGoalStatus(host: ChatThreadGoalActionsHost, threadId: string, status: ThreadGoalStatus): Promise<boolean> {
return setGoal(host, threadId, { status });
}
async function clearGoal(host: ChatThreadGoalActionsHost, threadId: string): Promise<boolean> {
await host.ensureConnected();
const client = host.currentClient();
if (!client) return false;
try {
await client.clearThreadGoal(threadId);
applyGoalIfActive(host, threadId, null, { reportChange: true });
return true;
} catch (error) {
host.addSystemMessage(errorMessage(error));
return false;
}
}
async function setGoal(
host: ChatThreadGoalActionsHost,
threadId: string,
params: { objective?: string | null; status?: ThreadGoalStatus | null; tokenBudget?: number | null },
): Promise<boolean> {
await host.ensureConnected();
const client = host.currentClient();
if (!client) return false;
try {
const response = await client.setThreadGoal(threadId, params);
return applyGoalIfActive(host, threadId, response.goal, { reportChange: true });
} catch (error) {
host.addSystemMessage(errorMessage(error));
return false;
}
}
function applyGoalIfActive(
host: ChatThreadGoalActionsHost,
threadId: string,
goal: ThreadGoal | null,
options: { reportChange: boolean },
): boolean {
const state = host.stateStore.getState();
if (state.activeThread.id !== threadId) return false;
const item = options.reportChange ? goalChangeItem(goalEventId(), state.activeThread.goal, goal) : null;
host.stateStore.dispatch({ type: "active-thread/goal-set", goal });
if (item) host.addGoalEvent(item);
host.refreshLiveState();
host.render();
return true;
}
async function recordGoalUserMessage(host: ChatThreadGoalActionsHost, threadId: string, objective: string): Promise<void> {
const client = host.currentClient();
if (!client) return;
try {
await client.injectThreadItems(threadId, [goalUserHistoryItem(objective)]);
} catch (error) {
host.addSystemMessage(`Could not record goal message: ${errorMessage(error)}`);
}
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function goalEventId(): string {
return `goal-${String(Date.now())}-${Math.random().toString(36).slice(2)}`;
}
function goalUserHistoryItem(text: string): JsonValue {
return {
type: "message",
role: "user",
content: [{ type: "input_text", text }],
};
}

View file

@ -1,127 +0,0 @@
import type { AppServerClient } from "../../../app-server/client";
import type { JsonValue } from "../../../generated/app-server/serde_json/JsonValue";
import type { ThreadGoal } from "../../../generated/app-server/v2/ThreadGoal";
import type { ThreadGoalStatus } from "../../../generated/app-server/v2/ThreadGoalStatus";
import type { ChatStateStore } from "../chat-state";
import type { GoalDisplayItem } from "../display/types";
import { goalChangeItem } from "../goal-messages";
export interface ChatThreadGoalControllerHost {
stateStore: ChatStateStore;
currentClient: () => AppServerClient | null;
ensureConnected: () => Promise<void>;
addSystemMessage: (text: string) => void;
addGoalEvent: (item: GoalDisplayItem) => void;
render: () => void;
refreshLiveState: () => void;
}
export class ChatThreadGoalController {
constructor(private readonly host: ChatThreadGoalControllerHost) {}
activeGoal(): ThreadGoal | null {
return this.host.stateStore.getState().activeThread.goal;
}
async syncThreadGoal(threadId: string): Promise<void> {
const client = this.host.currentClient();
if (!client) return;
try {
const response = await client.getThreadGoal(threadId);
this.applyGoalIfActive(threadId, response.goal, { reportChange: false });
} catch (error) {
if (this.host.stateStore.getState().activeThread.id !== threadId) return;
this.host.addSystemMessage(`Could not load thread goal: ${errorMessage(error)}`);
}
}
async setObjective(threadId: string, objective: string, tokenBudget: number | null): Promise<boolean> {
const trimmed = objective.trim();
if (!trimmed) {
this.host.addSystemMessage("Goal objective cannot be empty.");
return false;
}
const current = this.host.stateStore.getState().activeThread.goal;
const isNewGoal = current === null;
const applied = await this.setGoal(threadId, {
objective: trimmed,
status: current?.status ?? "active",
tokenBudget,
});
if (applied && isNewGoal) {
await this.recordGoalUserMessage(threadId, trimmed);
}
return applied;
}
async setStatus(threadId: string, status: ThreadGoalStatus): Promise<boolean> {
return this.setGoal(threadId, { status });
}
async clear(threadId: string): Promise<boolean> {
await this.host.ensureConnected();
const client = this.host.currentClient();
if (!client) return false;
try {
await client.clearThreadGoal(threadId);
this.applyGoalIfActive(threadId, null, { reportChange: true });
return true;
} catch (error) {
this.host.addSystemMessage(errorMessage(error));
return false;
}
}
private async setGoal(
threadId: string,
params: { objective?: string | null; status?: ThreadGoalStatus | null; tokenBudget?: number | null },
): Promise<boolean> {
await this.host.ensureConnected();
const client = this.host.currentClient();
if (!client) return false;
try {
const response = await client.setThreadGoal(threadId, params);
return this.applyGoalIfActive(threadId, response.goal, { reportChange: true });
} catch (error) {
this.host.addSystemMessage(errorMessage(error));
return false;
}
}
private applyGoalIfActive(threadId: string, goal: ThreadGoal | null, options: { reportChange: boolean }): boolean {
const state = this.host.stateStore.getState();
if (state.activeThread.id !== threadId) return false;
const item = options.reportChange ? goalChangeItem(goalEventId(), state.activeThread.goal, goal) : null;
this.host.stateStore.dispatch({ type: "active-thread/goal-set", goal });
if (item) this.host.addGoalEvent(item);
this.host.refreshLiveState();
this.host.render();
return true;
}
private async recordGoalUserMessage(threadId: string, objective: string): Promise<void> {
const client = this.host.currentClient();
if (!client) return;
try {
await client.injectThreadItems(threadId, [goalUserHistoryItem(objective)]);
} catch (error) {
this.host.addSystemMessage(`Could not record goal message: ${errorMessage(error)}`);
}
}
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function goalEventId(): string {
return `goal-${String(Date.now())}-${Math.random().toString(36).slice(2)}`;
}
function goalUserHistoryItem(text: string): JsonValue {
return {
type: "message",
role: "user",
content: [{ type: "input_text", text }],
};
}

View file

@ -0,0 +1,96 @@
import type { AppServerClient } from "../../../app-server/client";
import { submissionStateSnapshot } from "../chat-state-selectors";
import type { ChatStateStore } from "../chat-state";
import { parseSlashCommand } from "../composer/suggestions";
import type { SlashCommandExecutionResult } from "../slash-command-execution";
import type { SlashCommandName } from "../composer/slash-commands";
import type { ReferencedThreadDisplay } from "../../../domain/threads/reference";
import type { UserInput } from "../../../generated/app-server/v2/UserInput";
interface ComposerDraftPort {
readonly trimmedDraft: string;
setDraft(text: string, options?: { clearSuggestions?: boolean; focus?: boolean }): void;
}
interface ComposerSlashCommandPort {
execute(command: SlashCommandName, args: string): Promise<SlashCommandExecutionResult | undefined>;
}
interface ComposerTurnSubmissionPort {
sendTurnText(text: string, codexInputOverride?: UserInput[], referencedThread?: ReferencedThreadDisplay): Promise<void>;
}
interface ComposerConnectionPort {
ensureConnected: () => Promise<void>;
currentClient: () => AppServerClient | null;
}
interface ComposerStatusPort {
setStatus: (status: string) => void;
addSystemMessage: (text: string) => void;
}
export interface ComposerSubmissionActionsHost {
stateStore: ChatStateStore;
composer: ComposerDraftPort;
slashCommands: ComposerSlashCommandPort;
turnSubmission: ComposerTurnSubmissionPort;
connection: ComposerConnectionPort;
status: ComposerStatusPort;
}
export interface ComposerSubmissionActions {
submit: () => Promise<void>;
}
export function createComposerSubmissionActions(host: ComposerSubmissionActionsHost): ComposerSubmissionActions {
return {
submit: () => submitComposer(host),
};
}
async function submitComposer(host: ComposerSubmissionActionsHost): Promise<void> {
const draft = host.composer.trimmedDraft;
const state = submissionStateSnapshot(host.stateStore.getState());
if (state.busy && state.activeThreadId && state.activeTurnId && draft.length === 0) {
await interruptTurn(host);
return;
}
await sendMessage(host);
}
async function sendMessage(host: ComposerSubmissionActionsHost): Promise<void> {
const text = host.composer.trimmedDraft;
if (!text) return;
await host.connection.ensureConnected();
if (!host.connection.currentClient()) return;
const slashCommand = parseSlashCommand(text);
if (slashCommand) {
host.composer.setDraft("", { clearSuggestions: true });
const result = await host.slashCommands.execute(slashCommand.command, slashCommand.args);
if (result?.composerDraft !== undefined) {
host.composer.setDraft(result.composerDraft, { focus: true, clearSuggestions: true });
}
if (result?.sendText) {
await host.turnSubmission.sendTurnText(result.sendText, result.sendInput, result.referencedThread);
}
return;
}
await host.turnSubmission.sendTurnText(text);
}
async function interruptTurn(host: ComposerSubmissionActionsHost): Promise<void> {
const state = submissionStateSnapshot(host.stateStore.getState());
const turnId = state.activeTurnId;
const client = host.connection.currentClient();
if (!client || !state.activeThreadId || !turnId) return;
try {
await client.interruptTurn(state.activeThreadId, turnId);
host.status.setStatus("Interrupt requested.");
} catch (error) {
host.status.addSystemMessage(error instanceof Error ? error.message : String(error));
}
}

View file

@ -1,90 +0,0 @@
import type { AppServerClient } from "../../../app-server/client";
import { submissionStateSnapshot } from "../chat-state-selectors";
import type { ChatStateStore } from "../chat-state";
import { parseSlashCommand } from "../composer/suggestions";
import type { SlashCommandExecutionResult } from "../slash-command-execution";
import type { SlashCommandName } from "../composer/slash-commands";
import type { ReferencedThreadDisplay } from "../../../domain/threads/reference";
import type { UserInput } from "../../../generated/app-server/v2/UserInput";
interface ComposerDraftPort {
readonly trimmedDraft: string;
setDraft(text: string, options?: { clearSuggestions?: boolean; focus?: boolean }): void;
}
interface ComposerSlashCommandPort {
execute(command: SlashCommandName, args: string): Promise<SlashCommandExecutionResult | undefined>;
}
interface ComposerTurnSubmissionPort {
sendTurnText(text: string, codexInputOverride?: UserInput[], referencedThread?: ReferencedThreadDisplay): Promise<void>;
}
interface ComposerConnectionPort {
ensureConnected: () => Promise<void>;
currentClient: () => AppServerClient | null;
}
interface ComposerStatusPort {
setStatus: (status: string) => void;
addSystemMessage: (text: string) => void;
}
export interface ComposerSubmissionControllerHost {
stateStore: ChatStateStore;
composer: ComposerDraftPort;
slashCommands: ComposerSlashCommandPort;
turnSubmission: ComposerTurnSubmissionPort;
connection: ComposerConnectionPort;
status: ComposerStatusPort;
}
export class ComposerSubmissionController {
constructor(private readonly host: ComposerSubmissionControllerHost) {}
async submit(): Promise<void> {
const draft = this.host.composer.trimmedDraft;
const state = submissionStateSnapshot(this.host.stateStore.getState());
if (state.busy && state.activeThreadId && state.activeTurnId && draft.length === 0) {
await this.interruptTurn();
return;
}
await this.sendMessage();
}
private async sendMessage(): Promise<void> {
const text = this.host.composer.trimmedDraft;
if (!text) return;
await this.host.connection.ensureConnected();
if (!this.host.connection.currentClient()) return;
const slashCommand = parseSlashCommand(text);
if (slashCommand) {
this.host.composer.setDraft("", { clearSuggestions: true });
const result = await this.host.slashCommands.execute(slashCommand.command, slashCommand.args);
if (result?.composerDraft !== undefined) {
this.host.composer.setDraft(result.composerDraft, { focus: true, clearSuggestions: true });
}
if (result?.sendText) {
await this.host.turnSubmission.sendTurnText(result.sendText, result.sendInput, result.referencedThread);
}
return;
}
await this.host.turnSubmission.sendTurnText(text);
}
private async interruptTurn(): Promise<void> {
const state = submissionStateSnapshot(this.host.stateStore.getState());
const turnId = state.activeTurnId;
const client = this.host.connection.currentClient();
if (!client || !state.activeThreadId || !turnId) return;
try {
await client.interruptTurn(state.activeThreadId, turnId);
this.host.status.setStatus("Interrupt requested.");
} catch (error) {
this.host.status.addSystemMessage(error instanceof Error ? error.message : String(error));
}
}
}

View file

@ -1,15 +1,15 @@
import type { ChatAppServerThreadController } from "../app-server/thread-controller";
import type { ChatAppServerThreadActions } from "../app-server/thread-actions";
import { ChatComposerController } from "../composer/controller";
import { activeTurnId } from "../chat-state";
import type { ChatReconnectActions } from "../session/reconnect-actions";
import { PendingRequestController } from "../requests/pending-request-controller";
import type { ChatRuntimeSettingsController } from "../runtime/runtime-settings-controller";
import { ComposerSubmissionController } from "./composer-submission-controller";
import type { ChatRuntimeSettingsActions } from "../runtime/runtime-settings-actions";
import { createComposerSubmissionActions } from "./composer-submission-actions";
import { createPlanImplementationActions } from "./plan-implementation-actions";
import { SlashCommandController } from "./slash-command-controller";
import { createSlashCommandActions } from "./slash-command-actions";
import { TurnSubmissionController } from "./turn-submission-controller";
import type { ChatThreadActionController } from "../threads/thread-actions-controller";
import type { ChatThreadGoalController } from "../threads/thread-goal-controller";
import type { ChatThreadActions } from "../threads/thread-actions";
import type { ChatThreadGoalActions } from "../threads/thread-goal-actions";
import type { ThreadHistoryController } from "../threads/thread-history-controller";
import type { ThreadRenameController } from "../threads/thread-rename-controller";
import type { ChatInboundController } from "../inbound/controller";
@ -21,12 +21,12 @@ export function createTurnControllerGroup(
context: ChatPanelContext,
refs: {
controller: ChatInboundController;
appServerThreads: ChatAppServerThreadController;
runtimeSettings: ChatRuntimeSettingsController;
threadActions: ChatThreadActionController;
appServerThreads: ChatAppServerThreadActions;
runtimeSettings: ChatRuntimeSettingsActions;
threadActions: ChatThreadActions;
threadRename: ThreadRenameController;
reconnectActions: ChatReconnectActions;
goals: ChatThreadGoalController;
goals: ChatThreadGoalActions;
history: ThreadHistoryController;
},
) {
@ -97,7 +97,7 @@ export function createTurnControllerGroup(
addSystemMessage: status.addSystemMessage,
},
});
const slashCommands = new SlashCommandController({
const slashCommands = createSlashCommandActions({
stateStore,
currentClient,
codexInput: (text) => composerController.codexInput(text),
@ -178,7 +178,7 @@ export function createTurnControllerGroup(
renderPending: () => pendingRequests.renderNode(),
},
});
const composerSubmission = new ComposerSubmissionController({
const composerSubmission = createComposerSubmissionActions({
stateStore,
composer: composerController,
slashCommands,

View file

@ -0,0 +1,148 @@
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 {
executeSlashCommand as runSlashCommand,
type SlashCommandExecutionResult,
type ThreadReferenceInput,
} from "../slash-command-execution";
import type { SlashCommandName } from "../composer/slash-commands";
import type { DisplayDetailSection } from "../display/types";
import type { ReasoningEffort } from "../../../generated/app-server/ReasoningEffort";
import type { ThreadGoal } from "../../../generated/app-server/v2/ThreadGoal";
import type { ThreadGoalStatus } from "../../../generated/app-server/v2/ThreadGoalStatus";
import type { UserInput } from "../../../generated/app-server/v2/UserInput";
import { submissionStateSnapshot } from "../chat-state-selectors";
import type { ChatStateStore } from "../chat-state";
export interface SlashCommandThreadPort {
startNewThread: () => Promise<void>;
startThreadForGoal: (objective: string) => Promise<string | null>;
resumeThread: (threadId: string) => Promise<void>;
forkThread: (threadId: string) => Promise<void>;
rollbackThread: (threadId: string) => Promise<void>;
compactThread: (threadId: string) => Promise<void>;
archiveThread: (threadId: string) => Promise<void>;
renameThread: (threadId: string, name: string) => Promise<void>;
reconnect: () => Promise<void>;
}
export interface SlashCommandRuntimePort {
toggleFastMode: () => void | Promise<void>;
toggleCollaborationMode: () => void | Promise<void>;
toggleAutoReview: () => void | Promise<void>;
setRequestedModel: (model: string | null) => boolean | undefined | Promise<boolean | undefined>;
setRequestedReasoningEffort: (effort: ReasoningEffort | null) => boolean | undefined | Promise<boolean | undefined>;
}
export interface SlashCommandStatusPort {
addSystemMessage: (text: string) => void;
addStructuredSystemMessage: (text: string, details: DisplayDetailSection[]) => void;
setStatus: (status: string) => void;
statusSummaryLines: () => string[];
connectionDiagnosticDetails: () => DisplayDetailSection[];
mcpStatusLines: () => Promise<string[]>;
modelStatusLines: () => string[];
effortStatusLines: () => string[];
}
export interface SlashCommandGoalPort {
activeGoal: () => ThreadGoal | null;
setObjective: (threadId: string, objective: string, tokenBudget: number | null) => Promise<boolean>;
setStatus: (threadId: string, status: ThreadGoalStatus) => Promise<boolean>;
clear: (threadId: string) => Promise<boolean>;
}
export interface SlashCommandActionsHost {
stateStore: ChatStateStore;
currentClient: () => AppServerClient | null;
codexInput: (text: string) => UserInput[];
threads: SlashCommandThreadPort;
runtime: SlashCommandRuntimePort;
goals: SlashCommandGoalPort;
status: SlashCommandStatusPort;
}
export interface SlashCommandActions {
execute: (command: SlashCommandName, args: string) => Promise<SlashCommandExecutionResult | undefined>;
}
export function createSlashCommandActions(host: SlashCommandActionsHost): SlashCommandActions {
return {
execute: (command, args) => executeSlashCommand(host, command, args),
};
}
async function executeSlashCommand(
host: SlashCommandActionsHost,
command: SlashCommandName,
args: string,
): Promise<SlashCommandExecutionResult | undefined> {
const state = submissionStateSnapshot(host.stateStore.getState());
const client = host.currentClient();
if (!client && command !== "reconnect" && command !== "compact") return;
return runSlashCommand(command, args, {
activeThreadId: state.activeThreadId,
listedThreads: state.listedThreads,
startNewThread: () => host.threads.startNewThread(),
startThreadForGoal: (objective) => host.threads.startThreadForGoal(objective),
resumeThread: (threadId) => host.threads.resumeThread(threadId),
reconnect: () => host.threads.reconnect(),
referThread: (thread, message) => {
if (!client) return Promise.resolve(null);
return referencedThreadInput(host, client, thread, message);
},
forkThread: (threadId) => host.threads.forkThread(threadId),
rollbackThread: (threadId) => host.threads.rollbackThread(threadId),
compactThread: (threadId) => host.threads.compactThread(threadId),
archiveThread: (threadId) => host.threads.archiveThread(threadId),
renameThread: (threadId, name) => host.threads.renameThread(threadId, name),
busy: state.busy,
toggleFastMode: () => host.runtime.toggleFastMode(),
toggleCollaborationMode: () => host.runtime.toggleCollaborationMode(),
toggleAutoReview: () => host.runtime.toggleAutoReview(),
addSystemMessage: (text) => {
host.status.addSystemMessage(text);
},
addStructuredSystemMessage: (text, details) => {
host.status.addStructuredSystemMessage(text, details);
},
setRequestedModel: (model) => host.runtime.setRequestedModel(model),
setRequestedReasoningEffort: (effort) => host.runtime.setRequestedReasoningEffort(effort),
activeGoal: () => host.goals.activeGoal(),
setGoalObjective: (threadId, objective, tokenBudget) => host.goals.setObjective(threadId, objective, tokenBudget),
setGoalStatus: (threadId, status) => host.goals.setStatus(threadId, status),
clearGoal: (threadId) => host.goals.clear(threadId),
statusSummaryLines: () => host.status.statusSummaryLines(),
connectionDiagnosticDetails: () => host.status.connectionDiagnosticDetails(),
mcpStatusLines: () => host.status.mcpStatusLines(),
modelStatusLines: () => host.status.modelStatusLines(),
effortStatusLines: () => host.status.effortStatusLines(),
});
}
async function referencedThreadInput(
host: SlashCommandActionsHost,
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) {
host.status.addSystemMessage("Referenced thread has no readable conversation turns.");
return null;
}
const reference = buildReferencedThreadInput(thread, turns, message, host.codexInput(message));
host.status.setStatus(reference.status);
return reference;
} catch (error) {
host.status.addSystemMessage(error instanceof Error ? error.message : String(error));
return null;
}
}

View file

@ -1,133 +0,0 @@
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 {
executeSlashCommand as runSlashCommand,
type SlashCommandExecutionResult,
type ThreadReferenceInput,
} from "../slash-command-execution";
import type { SlashCommandName } from "../composer/slash-commands";
import type { DisplayDetailSection } from "../display/types";
import type { ReasoningEffort } from "../../../generated/app-server/ReasoningEffort";
import type { ThreadGoal } from "../../../generated/app-server/v2/ThreadGoal";
import type { ThreadGoalStatus } from "../../../generated/app-server/v2/ThreadGoalStatus";
import type { UserInput } from "../../../generated/app-server/v2/UserInput";
import { submissionStateSnapshot } from "../chat-state-selectors";
import type { ChatStateStore } from "../chat-state";
export interface SlashCommandThreadPort {
startNewThread: () => Promise<void>;
startThreadForGoal: (objective: string) => Promise<string | null>;
resumeThread: (threadId: string) => Promise<void>;
forkThread: (threadId: string) => Promise<void>;
rollbackThread: (threadId: string) => Promise<void>;
compactThread: (threadId: string) => Promise<void>;
archiveThread: (threadId: string) => Promise<void>;
renameThread: (threadId: string, name: string) => Promise<void>;
reconnect: () => Promise<void>;
}
export interface SlashCommandRuntimePort {
toggleFastMode: () => void | Promise<void>;
toggleCollaborationMode: () => void | Promise<void>;
toggleAutoReview: () => void | Promise<void>;
setRequestedModel: (model: string | null) => boolean | undefined | Promise<boolean | undefined>;
setRequestedReasoningEffort: (effort: ReasoningEffort | null) => boolean | undefined | Promise<boolean | undefined>;
}
export interface SlashCommandStatusPort {
addSystemMessage: (text: string) => void;
addStructuredSystemMessage: (text: string, details: DisplayDetailSection[]) => void;
setStatus: (status: string) => void;
statusSummaryLines: () => string[];
connectionDiagnosticDetails: () => DisplayDetailSection[];
mcpStatusLines: () => Promise<string[]>;
modelStatusLines: () => string[];
effortStatusLines: () => string[];
}
export interface SlashCommandGoalPort {
activeGoal: () => ThreadGoal | null;
setObjective: (threadId: string, objective: string, tokenBudget: number | null) => Promise<boolean>;
setStatus: (threadId: string, status: ThreadGoalStatus) => Promise<boolean>;
clear: (threadId: string) => Promise<boolean>;
}
export interface SlashCommandControllerHost {
stateStore: ChatStateStore;
currentClient: () => AppServerClient | null;
codexInput: (text: string) => UserInput[];
threads: SlashCommandThreadPort;
runtime: SlashCommandRuntimePort;
goals: SlashCommandGoalPort;
status: SlashCommandStatusPort;
}
export class SlashCommandController {
constructor(private readonly host: SlashCommandControllerHost) {}
async execute(command: SlashCommandName, args: string): Promise<SlashCommandExecutionResult | undefined> {
const state = submissionStateSnapshot(this.host.stateStore.getState());
const client = this.host.currentClient();
if (!client && command !== "reconnect" && command !== "compact") return;
return runSlashCommand(command, args, {
activeThreadId: state.activeThreadId,
listedThreads: state.listedThreads,
startNewThread: () => this.host.threads.startNewThread(),
startThreadForGoal: (objective) => this.host.threads.startThreadForGoal(objective),
resumeThread: (threadId) => this.host.threads.resumeThread(threadId),
reconnect: () => this.host.threads.reconnect(),
referThread: (thread, message) => {
if (!client) return Promise.resolve(null);
return this.referencedThreadInput(client, thread, message);
},
forkThread: (threadId) => this.host.threads.forkThread(threadId),
rollbackThread: (threadId) => this.host.threads.rollbackThread(threadId),
compactThread: (threadId) => this.host.threads.compactThread(threadId),
archiveThread: (threadId) => this.host.threads.archiveThread(threadId),
renameThread: (threadId, name) => this.host.threads.renameThread(threadId, name),
busy: state.busy,
toggleFastMode: () => this.host.runtime.toggleFastMode(),
toggleCollaborationMode: () => this.host.runtime.toggleCollaborationMode(),
toggleAutoReview: () => this.host.runtime.toggleAutoReview(),
addSystemMessage: (text) => {
this.host.status.addSystemMessage(text);
},
addStructuredSystemMessage: (text, details) => {
this.host.status.addStructuredSystemMessage(text, details);
},
setRequestedModel: (model) => this.host.runtime.setRequestedModel(model),
setRequestedReasoningEffort: (effort) => this.host.runtime.setRequestedReasoningEffort(effort),
activeGoal: () => this.host.goals.activeGoal(),
setGoalObjective: (threadId, objective, tokenBudget) => this.host.goals.setObjective(threadId, objective, tokenBudget),
setGoalStatus: (threadId, status) => this.host.goals.setStatus(threadId, status),
clearGoal: (threadId) => this.host.goals.clear(threadId),
statusSummaryLines: () => this.host.status.statusSummaryLines(),
connectionDiagnosticDetails: () => this.host.status.connectionDiagnosticDetails(),
mcpStatusLines: () => this.host.status.mcpStatusLines(),
modelStatusLines: () => this.host.status.modelStatusLines(),
effortStatusLines: () => this.host.status.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.status.addSystemMessage("Referenced thread has no readable conversation turns.");
return null;
}
const reference = buildReferencedThreadInput(thread, turns, message, this.host.codexInput(message));
this.host.status.setStatus(reference.status);
return reference;
} catch (error) {
this.host.status.addSystemMessage(error instanceof Error ? error.message : String(error));
return null;
}
}
}

View file

@ -1,9 +1,9 @@
import { describe, expect, it, vi } from "vitest";
import type { AppServerClient } from "../../../src/app-server/client";
import { ChatAppServerDiagnosticsController } from "../../../src/features/chat/app-server/diagnostics-controller";
import { ChatAppServerMetadataController } from "../../../src/features/chat/app-server/metadata-controller";
import { ChatAppServerThreadController } from "../../../src/features/chat/app-server/thread-controller";
import { createChatAppServerDiagnosticsActions } from "../../../src/features/chat/app-server/diagnostics-actions";
import { createChatAppServerMetadataActions } from "../../../src/features/chat/app-server/metadata-actions";
import { createChatAppServerThreadActions } from "../../../src/features/chat/app-server/thread-actions";
import { createChatState, createChatStateStore } from "../../../src/features/chat/chat-state";
import type { Model } from "../../../src/generated/app-server/v2/Model";
import type { McpServerStatus } from "../../../src/generated/app-server/v2/McpServerStatus";
@ -34,7 +34,7 @@ describe("chat app-server controllers", () => {
}),
} as unknown as AppServerClient;
const controller = new ChatAppServerThreadController({
const controller = createChatAppServerThreadActions({
stateStore,
vaultPath: "/vault",
currentClient: () => client,
@ -68,7 +68,7 @@ describe("chat app-server controllers", () => {
}),
} as unknown as AppServerClient;
const controller = new ChatAppServerThreadController({
const controller = createChatAppServerThreadActions({
stateStore,
vaultPath: "/vault",
currentClient: () => client,
@ -100,7 +100,7 @@ describe("chat app-server controllers", () => {
}),
} as unknown as AppServerClient;
const controller = new ChatAppServerThreadController({
const controller = createChatAppServerThreadActions({
stateStore,
vaultPath: "/vault",
currentClient: () => client,
@ -134,13 +134,13 @@ describe("chat app-server controllers", () => {
readModelProviderCapabilities: vi.fn().mockResolvedValue({}),
} as unknown as AppServerClient;
const metadata = new ChatAppServerMetadataController({
const metadata = createChatAppServerMetadataActions({
stateStore,
vaultPath: "/vault",
currentClient: () => client,
publishAppServerMetadata: () => undefined,
});
const diagnostics = new ChatAppServerDiagnosticsController({
const diagnostics = createChatAppServerDiagnosticsActions({
stateStore,
vaultPath: "/vault",
currentClient: () => client,
@ -181,7 +181,7 @@ describe("chat app-server controllers", () => {
const client = {
readAccountRateLimits: vi.fn().mockResolvedValue({ rateLimits: rateLimit, rateLimitsByLimitId: null }),
} as unknown as AppServerClient;
const controller = new ChatAppServerMetadataController({
const controller = createChatAppServerMetadataActions({
stateStore,
vaultPath: "/vault",
currentClient: () => client,
@ -206,7 +206,7 @@ describe("chat app-server controllers", () => {
const client = {
readAccountRateLimits: vi.fn().mockRejectedValue(new Error("offline")),
} as unknown as AppServerClient;
const controller = new ChatAppServerMetadataController({
const controller = createChatAppServerMetadataActions({
stateStore,
vaultPath: "/vault",
currentClient: () => client,
@ -228,13 +228,13 @@ describe("chat app-server controllers", () => {
const client = {
listMcpServerStatus,
} as unknown as AppServerClient;
const metadata = new ChatAppServerMetadataController({
const metadata = createChatAppServerMetadataActions({
stateStore,
vaultPath: "/vault",
currentClient: () => client,
publishAppServerMetadata: () => undefined,
});
const controller = new ChatAppServerDiagnosticsController({
const controller = createChatAppServerDiagnosticsActions({
stateStore,
vaultPath: "/vault",
currentClient: () => client,

View file

@ -4,7 +4,7 @@ import { describe, expect, it, vi } from "vitest";
import { createChatState, createChatStateStore } from "../../../../src/features/chat/chat-state";
import { ToolbarPanelController } from "../../../../src/features/chat/panel/toolbar-controller";
import type { ChatThreadActionController } from "../../../../src/features/chat/threads/thread-actions-controller";
import type { ChatThreadActions } from "../../../../src/features/chat/threads/thread-actions";
describe("ToolbarPanelController", () => {
it("tracks archive confirmation and delegates archive actions", async () => {
@ -13,7 +13,7 @@ describe("ToolbarPanelController", () => {
const scheduleRender = vi.fn();
const controller = new ToolbarPanelController({
stateStore,
threadActions: { archiveThread } as unknown as ChatThreadActionController,
threadActions: { archiveThread } as unknown as ChatThreadActions,
scheduleRender,
});
@ -32,7 +32,7 @@ describe("ToolbarPanelController", () => {
const scheduleRender = vi.fn();
const controller = new ToolbarPanelController({
stateStore,
threadActions: { archiveThread: vi.fn() } as unknown as ChatThreadActionController,
threadActions: { archiveThread: vi.fn() } as unknown as ChatThreadActions,
scheduleRender,
});
controller.toggleHistory();

View file

@ -1,18 +1,21 @@
import { describe, expect, it, vi } from "vitest";
import { ChatRuntimeSettingsController } from "../../../../src/features/chat/runtime/runtime-settings-controller";
import {
createChatRuntimeSettingsActions,
type ChatRuntimeSettingsActions,
} from "../../../../src/features/chat/runtime/runtime-settings-actions";
import { createChatState, createChatStateStore, type ActiveThreadSettingsAppliedAction } from "../../../../src/features/chat/chat-state";
import type { AppServerClient } from "../../../../src/app-server/client";
import type { Model } from "../../../../src/generated/app-server/v2/Model";
describe("ChatRuntimeSettingsController", () => {
describe("createChatRuntimeSettingsActions", () => {
it("applies pending runtime overrides through thread settings and commits them", async () => {
const state = createChatState();
state.activeThread.id = "thread";
const store = createChatStateStore(state);
const client = clientFixture();
const messages: string[] = [];
const controller = new ChatRuntimeSettingsController({
const controller = createChatRuntimeSettingsActions({
stateStore: store,
currentClient: () => client as AppServerClient,
runtimeSnapshot: () => ({
@ -107,8 +110,8 @@ function runtimeControllerFixture(
store: ReturnType<typeof createChatStateStore>,
client: Pick<AppServerClient, "updateThreadSettings">,
messages: string[],
): ChatRuntimeSettingsController {
return new ChatRuntimeSettingsController({
): ChatRuntimeSettingsActions {
return createChatRuntimeSettingsActions({
stateStore: store,
currentClient: () => client as AppServerClient,
runtimeSnapshot: () => {

View file

@ -3,10 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import type { AppServerClient } from "../../../src/app-server/client";
import type { ArchiveExportAdapter } from "../../../src/domain/threads/export";
import { createChatState, createChatStateStore } from "../../../src/features/chat/chat-state";
import {
ChatThreadActionController,
type ChatThreadActionControllerHost,
} from "../../../src/features/chat/threads/thread-actions-controller";
import { createChatThreadActions, type ChatThreadActionsHost } from "../../../src/features/chat/threads/thread-actions";
import type { DisplayItem } from "../../../src/features/chat/display/types";
import type { Thread } from "../../../src/generated/app-server/v2/Thread";
import { DEFAULT_SETTINGS } from "../../../src/settings/model";
@ -18,7 +15,7 @@ type MockArchiveExportAdapter = ArchiveExportAdapter & {
write: ReturnType<typeof vi.fn<ArchiveExportAdapter["write"]>>;
};
describe("ChatThreadActionController", () => {
describe("createChatThreadActions", () => {
beforeEach(() => {
notices.length = 0;
});
@ -26,7 +23,7 @@ describe("ChatThreadActionController", () => {
it("requests thread compaction and reports the shared status", async () => {
const client = clientMock();
const host = hostMock({ client, displayItems: [] });
const controller = new ChatThreadActionController(host);
const controller = createChatThreadActions(host);
await controller.compactThread("source");
@ -50,7 +47,7 @@ describe("ChatThreadActionController", () => {
archiveExportFilenameTemplate: "{{title}} {{shortId}}",
},
});
const controller = new ChatThreadActionController(host);
const controller = createChatThreadActions(host);
await controller.archiveThread("source");
@ -80,7 +77,7 @@ describe("ChatThreadActionController", () => {
archiveExportFilenameTemplate: "{{title}} {{shortId}}",
},
});
const controller = new ChatThreadActionController(host);
const controller = createChatThreadActions(host);
await controller.archiveThread("source");
@ -93,7 +90,7 @@ describe("ChatThreadActionController", () => {
it("forks from a selected turn by dropping later turns on the fork", async () => {
const client = clientMock();
const host = hostMock({ client, displayItems: turnItems() });
const controller = new ChatThreadActionController(host);
const controller = createChatThreadActions(host);
await controller.forkThreadFromTurn("source", "turn-1", false);
@ -107,7 +104,7 @@ describe("ChatThreadActionController", () => {
it("does not open the fork in a new panel before archiving the source", async () => {
const client = clientMock();
const host = hostMock({ client, displayItems: turnItems() });
const controller = new ChatThreadActionController(host);
const controller = createChatThreadActions(host);
await controller.forkThreadFromTurn("source", "turn-2", true);
@ -131,7 +128,7 @@ describe("ChatThreadActionController", () => {
archiveExportFilenameTemplate: "{{title}} {{shortId}}",
},
});
const controller = new ChatThreadActionController(host);
const controller = createChatThreadActions(host);
await controller.forkThreadFromTurn("source", "turn-3", true);
@ -150,7 +147,7 @@ describe("ChatThreadActionController", () => {
const client = clientMock();
client.archiveThread.mockRejectedValue(new Error("archive failed"));
const host = hostMock({ client, displayItems: turnItems() });
const controller = new ChatThreadActionController(host);
const controller = createChatThreadActions(host);
await controller.forkThreadFromTurn("source", "turn-3", true);
@ -164,7 +161,7 @@ describe("ChatThreadActionController", () => {
it("replaces the source panel before notifying surfaces after fork and archive succeeds", async () => {
const client = clientMock();
const host = hostMock({ client, displayItems: turnItems() });
const controller = new ChatThreadActionController(host);
const controller = createChatThreadActions(host);
await controller.forkThreadFromTurn("source", "turn-3", true);
@ -181,7 +178,7 @@ describe("ChatThreadActionController", () => {
const client = clientMock();
const host = hostMock({ client, displayItems: turnItems() });
host.openThreadInCurrentPanel.mockRejectedValue(new Error("resume failed"));
const controller = new ChatThreadActionController(host);
const controller = createChatThreadActions(host);
await controller.forkThreadFromTurn("source", "turn-3", true);
@ -268,7 +265,7 @@ function hostMock({
notifyActiveThreadIdentityChanged: vi.fn(),
refreshThreads: vi.fn().mockResolvedValue(undefined),
refreshSharedThreadListFromOpenSurface: vi.fn(),
} satisfies ChatThreadActionControllerHost;
} satisfies ChatThreadActionsHost;
}
function archiveAdapterMock(overrides: Partial<MockArchiveExportAdapter> = {}): MockArchiveExportAdapter {

View file

@ -1,11 +1,11 @@
import { describe, expect, it, vi } from "vitest";
import type { AppServerClient } from "../../../../src/app-server/client";
import { ChatThreadGoalController } from "../../../../src/features/chat/threads/thread-goal-controller";
import { createChatThreadGoalActions } from "../../../../src/features/chat/threads/thread-goal-actions";
import { createChatState, createChatStateStore } from "../../../../src/features/chat/chat-state";
import type { ThreadGoal } from "../../../../src/generated/app-server/v2/ThreadGoal";
describe("ChatThreadGoalController", () => {
describe("createChatThreadGoalActions", () => {
it("syncs the active thread goal into chat state", async () => {
const state = createChatState();
state.activeThread.id = "thread";
@ -14,7 +14,7 @@ describe("ChatThreadGoalController", () => {
const client = { getThreadGoal: vi.fn().mockResolvedValue({ goal: currentGoal }) } as unknown as AppServerClient;
const render = vi.fn();
const refreshLiveState = vi.fn();
const controller = new ChatThreadGoalController({
const controller = createChatThreadGoalActions({
stateStore,
currentClient: () => client,
ensureConnected: vi.fn().mockResolvedValue(undefined),
@ -37,7 +37,7 @@ describe("ChatThreadGoalController", () => {
const stateStore = createChatStateStore(state);
const addSystemMessage = vi.fn();
const client = { getThreadGoal: vi.fn().mockRejectedValue(new Error("offline")) } as unknown as AppServerClient;
const controller = new ChatThreadGoalController({
const controller = createChatThreadGoalActions({
stateStore,
currentClient: () => client,
ensureConnected: vi.fn().mockResolvedValue(undefined),
@ -69,7 +69,7 @@ describe("ChatThreadGoalController", () => {
} as unknown as AppServerClient;
const addSystemMessage = vi.fn();
const addGoalEvent = vi.fn();
const controller = new ChatThreadGoalController({
const controller = createChatThreadGoalActions({
stateStore,
currentClient: () => client,
ensureConnected: vi.fn().mockResolvedValue(undefined),
@ -102,7 +102,7 @@ describe("ChatThreadGoalController", () => {
const client = { setThreadGoal, injectThreadItems } as unknown as AppServerClient;
const addSystemMessage = vi.fn();
const addGoalEvent = vi.fn();
const controller = new ChatThreadGoalController({
const controller = createChatThreadGoalActions({
stateStore,
currentClient: () => client,
ensureConnected: vi.fn().mockResolvedValue(undefined),
@ -142,7 +142,7 @@ describe("ChatThreadGoalController", () => {
const injectThreadItems = vi.fn().mockResolvedValue({});
const client = { setThreadGoal, injectThreadItems } as unknown as AppServerClient;
const addGoalEvent = vi.fn();
const controller = new ChatThreadGoalController({
const controller = createChatThreadGoalActions({
stateStore,
currentClient: () => client,
ensureConnected: vi.fn().mockResolvedValue(undefined),
@ -167,7 +167,7 @@ describe("ChatThreadGoalController", () => {
const client = { setThreadGoal } as unknown as AppServerClient;
const addSystemMessage = vi.fn();
const addGoalEvent = vi.fn();
const controller = new ChatThreadGoalController({
const controller = createChatThreadGoalActions({
stateStore,
currentClient: () => client,
ensureConnected: vi.fn().mockResolvedValue(undefined),
@ -190,7 +190,7 @@ describe("ChatThreadGoalController", () => {
const currentGoal = goal();
const client = { getThreadGoal: vi.fn().mockResolvedValue({ goal: currentGoal }) } as unknown as AppServerClient;
const addSystemMessage = vi.fn();
const controller = new ChatThreadGoalController({
const controller = createChatThreadGoalActions({
stateStore,
currentClient: () => client,
ensureConnected: vi.fn().mockResolvedValue(undefined),

View file

@ -2,7 +2,7 @@ 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 { ComposerSubmissionController } from "../../../../src/features/chat/turns/composer-submission-controller";
import { createComposerSubmissionActions } from "../../../../src/features/chat/turns/composer-submission-actions";
import type { Thread } from "../../../../src/generated/app-server/v2/Thread";
function thread(id: string): Thread {
@ -37,7 +37,7 @@ function createController(draft: string) {
const setDraft = vi.fn();
const sendTurnText = vi.fn().mockResolvedValue(undefined);
const execute = vi.fn().mockResolvedValue(undefined);
const controller = new ComposerSubmissionController({
const controller = createComposerSubmissionActions({
stateStore,
composer: {
get trimmedDraft() {
@ -59,7 +59,7 @@ function createController(draft: string) {
return { controller, execute, interruptTurn, sendTurnText, setDraft, stateStore };
}
describe("ComposerSubmissionController", () => {
describe("createComposerSubmissionActions", () => {
it("sends plain drafts as turn text", async () => {
const { controller, sendTurnText } = createController("hello");

View file

@ -3,13 +3,13 @@ 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,
createSlashCommandActions,
type SlashCommandActionsHost,
type SlashCommandGoalPort,
type SlashCommandRuntimePort,
type SlashCommandStatusPort,
type SlashCommandThreadPort,
} from "../../../../src/features/chat/turns/slash-command-controller";
} from "../../../../src/features/chat/turns/slash-command-actions";
import type { Thread } from "../../../../src/generated/app-server/v2/Thread";
import type { UserInput } from "../../../../src/generated/app-server/v2/UserInput";
@ -40,7 +40,7 @@ function thread(id: string, name: string | null = null): Thread {
};
}
interface SlashCommandHostOverrides extends Partial<Omit<SlashCommandControllerHost, "threads" | "runtime" | "goals" | "status">> {
interface SlashCommandHostOverrides extends Partial<Omit<SlashCommandActionsHost, "threads" | "runtime" | "goals" | "status">> {
threads?: Partial<SlashCommandThreadPort>;
runtime?: Partial<SlashCommandRuntimePort>;
goals?: Partial<SlashCommandGoalPort>;
@ -97,7 +97,7 @@ function createHost(overrides: SlashCommandHostOverrides = {}) {
clear: vi.fn().mockResolvedValue(true),
...goalOverrides,
};
const host: SlashCommandControllerHost = {
const host: SlashCommandActionsHost = {
stateStore,
currentClient: () => client,
codexInput: vi.fn((text: string) => textInput(text)),
@ -110,10 +110,10 @@ function createHost(overrides: SlashCommandHostOverrides = {}) {
return { compactThread, host, stateStore, threadTurnsList };
}
describe("SlashCommandController", () => {
describe("createSlashCommandActions", () => {
it("executes slash commands against the current chat state", async () => {
const { host } = createHost();
const controller = new SlashCommandController(host);
const controller = createSlashCommandActions(host);
const result = await controller.execute("clear", "");
@ -138,7 +138,7 @@ describe("SlashCommandController", () => {
approvalsReviewer: null,
activePermissionProfile: null,
});
const controller = new SlashCommandController(host);
const controller = createSlashCommandActions(host);
await controller.execute("compact", "");
@ -158,7 +158,7 @@ describe("SlashCommandController", () => {
approvalsReviewer: null,
activePermissionProfile: null,
});
const controller = new SlashCommandController(host);
const controller = createSlashCommandActions(host);
await controller.execute("compact", "");
@ -167,7 +167,7 @@ describe("SlashCommandController", () => {
it("starts an empty panel before setting a slash command goal", async () => {
const { host } = createHost();
const controller = new SlashCommandController(host);
const controller = createSlashCommandActions(host);
await controller.execute("goal", "set Ship this");
@ -177,7 +177,7 @@ describe("SlashCommandController", () => {
it("runs reconnect even when there is no current app-server client", async () => {
const { host } = createHost({ currentClient: () => null });
const controller = new SlashCommandController(host);
const controller = createSlashCommandActions(host);
await controller.execute("reconnect", "");
@ -190,7 +190,7 @@ describe("SlashCommandController", () => {
type: "thread-list/applied",
threads: [thread("other", "Other")],
});
const controller = new SlashCommandController(host);
const controller = createSlashCommandActions(host);
const result = await controller.execute("refer", "Other summarize");