murashit_codex-panel/src/plugin-runtime.ts

401 lines
16 KiB
TypeScript
Raw Normal View History

import type { App } from "obsidian";
import type { AppServerClient } from "./app-server/connection/client";
import type { AppServerClientAccess, AppServerClientAccessOptions } from "./app-server/connection/client-access";
import { withShortLivedAppServerClient } from "./app-server/connection/short-lived-client";
import {
type AppServerContextLease,
type AppServerQueryContext,
type AppServerQueryContextIdentity,
appServerQueryContextIdentityMatches,
appServerQueryContextIsComplete,
} from "./app-server/query/keys";
import { AppServerResourceStore, StaleAppServerResourceContextError } from "./app-server/query/resource-store";
2026-06-24 05:44:16 +00:00
import { VIEW_TYPE_CODEX_THREADS, VIEW_TYPE_CODEX_TURN_DIFF } from "./constants";
import { hasPendingRequests } from "./domain/pending-requests/aggregate";
import { createThreadGoalOperationCoordinator } from "./features/chat/application/threads/goal-actions";
import type {
ChatPanelClientSurface,
2026-06-27 11:55:38 +00:00
ChatPanelSettingsAccess,
ChatSharedThreadSurface,
ChatViewLifecycleSurface,
ChatWorkspacePanelSurface,
2026-06-27 01:32:29 +00:00
CodexChatHost,
} from "./features/chat/host/contracts";
import type { SelectionRewriteCommandController } from "./features/selection-rewrite/command.obsidian";
2026-06-25 02:18:01 +00:00
import { openThreadPicker, type ThreadPickerHost } from "./features/thread-picker/modal.obsidian";
import { createThreadOperationsTransport, createThreadTitleTransport } from "./features/threads/app-server/workflow-transports";
import { createThreadCatalog, type ThreadCatalog, type ThreadCatalogEvent } from "./features/threads/catalog/thread-catalog";
import { createThreadNameMutationCoordinator } from "./features/threads/workflows/thread-name-mutation-coordinator";
2026-06-26 11:23:37 +00:00
import type { ThreadsViewHost, ThreadsViewSettingsAccess } from "./features/threads-view/session";
import type { ThreadsViewPanelActivity } from "./features/threads-view/state";
2026-06-27 02:36:00 +00:00
import { CodexThreadsView } from "./features/threads-view/view.obsidian";
2026-06-27 12:49:26 +00:00
import { persistedTurnDiffViewState, type TurnDiffViewState } from "./features/turn-diff/model";
import { CodexTurnDiffView } from "./features/turn-diff/view.obsidian";
2026-06-30 01:32:23 +00:00
import { createSettingsAppServerDynamicData } from "./settings/app-server-dynamic-data";
import type { SettingsDynamicDataAccess } from "./settings/dynamic-data";
2026-06-17 01:09:50 +00:00
import type { CodexPanelSettingTabHost } from "./settings/host";
2026-06-27 11:55:38 +00:00
import type { CodexPanelSettings } from "./settings/model";
import { createKeyedOperationQueue } from "./shared/runtime/keyed-operation-queue";
import { WorkspacePanelCoordinator } from "./workspace/panel-coordinator";
2026-06-27 11:55:38 +00:00
interface CodexPanelRuntimeSettingsRef {
readonly settings: CodexPanelSettings;
readonly vaultPath: string;
}
export interface CodexPanelRuntimeOptions {
app: App;
2026-06-27 11:55:38 +00:00
settingsRef: CodexPanelRuntimeSettingsRef;
saveSettings(settings: CodexPanelSettings): Promise<void>;
}
export class CodexPanelRuntime implements AppServerClientAccess {
private readonly appServerResourceStore = new AppServerResourceStore({
clientRunner: {
runWithClient: (context, operation, options) => this.runWithAppServerClient(context, operation, options),
},
});
private readonly panels: WorkspacePanelCoordinator;
private readonly threadCatalog: ThreadCatalog;
private readonly settingsDynamicData: SettingsDynamicDataAccess;
private readonly threadNameMutations = createThreadNameMutationCoordinator();
private readonly threadGoalOperations = createThreadGoalOperationCoordinator();
private readonly runtimeSettingsCommitQueue = createKeyedOperationQueue<string>();
private selectionRewriteController: SelectionRewriteCommandController | null = null;
constructor(private readonly options: CodexPanelRuntimeOptions) {
this.panels = new WorkspacePanelCoordinator({
app: options.app,
refreshThreadsViewLiveState: () => {
this.refreshThreadsViewLiveState();
},
});
this.threadCatalog = createThreadCatalog({
store: this.appServerResourceStore,
onEventApplied: (event) => {
this.applyThreadCatalogSurfaceEvent(event);
},
});
this.settingsDynamicData = createSettingsAppServerDynamicData({
vaultPath: options.settingsRef.vaultPath,
clientAccess: this,
appServerQueries: this.appServerResourceStore,
threadCatalog: this.threadCatalog,
});
}
initialize(): void {
this.appServerResourceStore.initialize(this.configuredAppServerContext());
}
appServerContextLease(): AppServerContextLease {
return this.appServerResourceStore.contextLease();
}
reset(): void {
this.selectionRewriteController?.closeAll();
this.selectionRewriteController = null;
this.panels.reset();
this.appServerResourceStore.reset();
}
activeWorkspaceLeafChanged(leaf: Parameters<WorkspacePanelCoordinator["activeLeafChanged"]>[0]): void {
this.panels.activeLeafChanged(leaf);
}
activatePanel(): Promise<unknown> {
return this.panels.activateView();
}
activateNewPanel(): Promise<unknown> {
return this.panels.activateNewView();
}
async startNewChat(): Promise<void> {
await this.panels.startNewChat();
}
openThreadPicker(): void {
void openThreadPicker(this.threadPickerHost());
}
async activateThreadsView(): Promise<CodexThreadsView> {
const leaf = await this.options.app.workspace.ensureSideLeaf(VIEW_TYPE_CODEX_THREADS, "left", {
active: true,
reveal: true,
});
const view = leaf.view as CodexThreadsView;
await view.refresh();
return view;
}
scheduleWorkspacePanelReconcile(): void {
this.panels.scheduleWorkspacePanelReconcile();
}
cancelWorkspacePanelReconcile(): void {
this.panels.cancelWorkspacePanelReconcile();
}
setSelectionRewriteController(controller: SelectionRewriteCommandController): void {
this.selectionRewriteController = controller;
}
chatHost(): CodexChatHost {
return {
2026-06-27 11:55:38 +00:00
settingsRef: {
settings: this.chatSettings(),
vaultPath: this.options.settingsRef.vaultPath,
},
workspace: {
openThreadInNewView: (threadId) => this.panels.openThreadInNewView(threadId),
focusThreadInOpenView: (threadId) => this.panels.focusThreadInOpenView(threadId),
openTurnDiff: (state) => this.openTurnDiff(state),
openSideChat: (sourceThreadId, sourceThreadTitle) => this.panels.openSideChat(sourceThreadId, sourceThreadTitle),
refreshThreadsViewLiveState: () => {
this.refreshThreadsViewLiveState();
},
},
appServerQueries: this.appServerResourceStore,
2026-06-15 06:00:14 +00:00
threadCatalog: this.threadCatalog,
threadNameMutations: this.threadNameMutations,
threadGoalOperations: this.threadGoalOperations,
runtimeSettingsCommitQueue: this.runtimeSettingsCommitQueue,
};
}
2026-06-27 11:55:38 +00:00
private chatSettings(): ChatPanelSettingsAccess {
return {
2026-07-05 02:24:47 +00:00
referenceActiveNoteOnSend: () => this.options.settingsRef.settings.referenceActiveNoteOnSend,
2026-06-28 11:20:01 +00:00
attachmentFolder: () => this.options.settingsRef.settings.attachmentFolder,
2026-06-27 11:55:38 +00:00
archiveExportEnabled: () => this.options.settingsRef.settings.archiveExportEnabled,
archiveExportSettings: () => ({
archiveExportFolderTemplate: this.options.settingsRef.settings.archiveExportFolderTemplate,
archiveExportFilenameTemplate: this.options.settingsRef.settings.archiveExportFilenameTemplate,
archiveExportTags: this.options.settingsRef.settings.archiveExportTags,
}),
codexPath: () => this.options.settingsRef.settings.codexPath,
scrollThreadFromComposerEdges: () => this.options.settingsRef.settings.scrollThreadFromComposerEdges,
sendShortcut: () => this.options.settingsRef.settings.sendShortcut,
showToolbar: () => this.options.settingsRef.settings.showToolbar,
threadNamingEffort: () => this.options.settingsRef.settings.threadNamingEffort,
threadNamingModel: () => this.options.settingsRef.settings.threadNamingModel,
};
}
2026-06-26 11:23:37 +00:00
threadsHost(): ThreadsViewHost {
return {
settings: this.threadsSettings(),
vaultPath: this.options.settingsRef.vaultPath,
appServerContextLease: () => this.appServerResourceStore.contextLease(),
2026-06-15 06:00:14 +00:00
threadCatalog: this.threadCatalog,
threadNameMutations: this.threadNameMutations,
threadOperationsTransport: createThreadOperationsTransport(this),
threadTitleTransport: createThreadTitleTransport({
clientAccess: this,
codexPath: () => this.appServerResourceStore.contextLease().context.codexPath,
vaultPath: this.appServerResourceStore.contextLease().context.vaultPath,
threadNamingModel: () => this.options.settingsRef.settings.threadNamingModel,
threadNamingEffort: () => this.options.settingsRef.settings.threadNamingEffort,
}),
openNewPanel: () => this.panels.openNewPanel(),
openThreadInAvailableView: (threadId) => this.panels.openThreadInAvailableView(threadId),
openPanelActivities: () => this.openPanelActivities(),
closeOpenPanelsForThread: (threadId) => {
this.closeOpenPanelsForThread(threadId);
},
};
}
2026-06-26 11:23:37 +00:00
private threadsSettings(): ThreadsViewSettingsAccess {
return {
archiveExportEnabled: () => this.options.settingsRef.settings.archiveExportEnabled,
codexPath: () => this.options.settingsRef.settings.codexPath,
threadNamingModel: () => this.options.settingsRef.settings.threadNamingModel,
threadNamingEffort: () => this.options.settingsRef.settings.threadNamingEffort,
2026-06-24 03:19:07 +00:00
archiveExportSettings: () => ({
archiveExportFolderTemplate: this.options.settingsRef.settings.archiveExportFolderTemplate,
archiveExportFilenameTemplate: this.options.settingsRef.settings.archiveExportFilenameTemplate,
archiveExportTags: this.options.settingsRef.settings.archiveExportTags,
}),
};
}
settingTabHost(): CodexPanelSettingTabHost {
return {
settings: this.options.settingsRef.settings,
dynamicData: this.settingsDynamicData,
publishSettings: (settings) => this.publishSettings(settings),
};
}
private threadPickerHost(): ThreadPickerHost {
return {
app: this.options.app,
2026-06-15 06:00:14 +00:00
threadCatalog: this.threadCatalog,
openThreadInCurrentView: (threadId) => this.panels.openThreadInCurrentView(threadId),
openThreadInAvailableView: (threadId) => this.panels.openThreadInAvailableView(threadId),
};
}
withClient<T>(operation: (client: AppServerClient) => Promise<T>, options: AppServerClientAccessOptions = {}): Promise<T> {
return this.runWithAppServerClient(this.appServerResourceStore.contextIdentity(), operation, options);
}
private async publishSettings(settings: CodexPanelSettings): Promise<{ appServerContextReplaced: boolean }> {
const previousSettings = { ...this.options.settingsRef.settings };
await this.options.saveSettings(settings);
const appServerContextReplaced = previousSettings.codexPath !== settings.codexPath;
if (appServerContextReplaced) this.prepareAppServerContextChange();
Object.assign(this.options.settingsRef.settings, settings);
if (appServerContextReplaced) {
this.appServerResourceStore.replaceContext(this.configuredAppServerContext());
}
if (appServerContextReplaced || previousSettings.showToolbar !== settings.showToolbar) this.refreshOpenViews();
return { appServerContextReplaced };
}
private prepareAppServerContextChange(): void {
this.selectionRewriteController?.closeAll();
for (const view of this.panels.panelViews()) view.surface.prepareAppServerContextChange();
for (const view of this.threadsViews()) view.prepareAppServerContextChange();
}
2026-06-27 12:49:26 +00:00
private async openTurnDiff(state: TurnDiffViewState): Promise<void> {
const existing = this.options.app.workspace.getLeavesOfType(VIEW_TYPE_CODEX_TURN_DIFF).at(0);
const leaf = existing ?? this.options.app.workspace.getLeaf("tab");
2026-06-27 12:49:26 +00:00
await leaf.setViewState({ type: VIEW_TYPE_CODEX_TURN_DIFF, active: true, state: { ...persistedTurnDiffViewState(state) } });
await leaf.loadIfDeferred();
2026-06-27 12:49:26 +00:00
if (leaf.view instanceof CodexTurnDiffView) {
leaf.view.setDiffPayload(state);
}
await this.options.app.workspace.revealLeaf(leaf);
}
private refreshOpenViews(): void {
for (const view of this.panels.panelViews()) {
const surface: ChatViewLifecycleSurface = view.surface;
surface.refreshSettings();
}
for (const view of this.threadsViews()) view.refreshSettings();
}
private applyThreadArchived(threadId: string): void {
for (const view of this.panels.panelViews()) {
const surface: ChatSharedThreadSurface = view.surface;
surface.applyThreadArchived(threadId);
}
}
private closeOpenPanelsForThread(threadId: string): void {
const leavesToClose = this.panels.panelLeavesForThread(threadId);
for (const leaf of leavesToClose) {
leaf.detach();
}
}
private applyThreadRenamed(threadId: string, name: string | null): void {
for (const view of this.panels.panelViews()) {
const surface: ChatSharedThreadSurface = view.surface;
surface.applyThreadRenamed(threadId, name);
}
}
private applyThreadCatalogSurfaceEvent(event: ThreadCatalogEvent): void {
switch (event.type) {
case "thread-archived":
this.applyThreadArchived(event.threadId);
return;
case "thread-renamed":
this.applyThreadRenamed(event.threadId, event.name);
return;
default:
return;
}
}
private refreshThreadsViewLiveState(): void {
for (const view of this.threadsViews()) {
view.refreshLiveState();
}
}
private openPanelActivities(): readonly ThreadsViewPanelActivity[] {
return this.panels.getOpenPanelSnapshots().map((snapshot) => ({
threadId: snapshot.threadId,
selected: snapshot.lastFocused,
pending: hasPendingRequests(snapshot.pendingRequests),
running: snapshot.turnLifecycle.kind !== "idle",
}));
}
private threadsViews(): CodexThreadsView[] {
return this.options.app.workspace
.getLeavesOfType(VIEW_TYPE_CODEX_THREADS)
.flatMap((leaf) => (leaf.view instanceof CodexThreadsView ? [leaf.view] : []));
}
private async runWithAppServerClient<T>(
context: AppServerQueryContextIdentity,
operation: (client: AppServerClient) => Promise<T>,
options: AppServerClientAccessOptions = {},
): Promise<T> {
if (!appServerQueryContextIsComplete(context)) {
throw new Error("Codex app-server query context is incomplete.");
}
this.assertCurrentAppServerContext(context);
const result = await this.runWithContextClient(
context,
(client) => {
this.assertCurrentAppServerContext(context);
return operation(client);
},
options,
);
this.assertCurrentAppServerContext(context);
return result;
}
private assertCurrentAppServerContext(context: AppServerQueryContextIdentity): void {
let current = false;
try {
current = appServerQueryContextIdentityMatches(this.appServerResourceStore.contextIdentity(), context);
} catch {
// A reset resource store has no current app-server context.
}
if (!current) throw new StaleAppServerResourceContextError();
}
private runWithContextClient<T>(
context: AppServerQueryContextIdentity,
operation: (client: AppServerClient) => Promise<T>,
options: AppServerClientAccessOptions,
): Promise<T> {
if (options.serverRequests?.kind === "reject") {
return withShortLivedAppServerClient(context.codexPath, context.vaultPath, operation, options);
}
const chatSurface = this.connectedClientSurface(context);
return chatSurface
? chatSurface.runWithAppServerClient(operation)
: withShortLivedAppServerClient(context.codexPath, context.vaultPath, operation, options);
}
private connectedClientSurface(context: AppServerQueryContextIdentity): ChatPanelClientSurface | null {
for (const view of this.panels.panelViews()) {
const workspaceSurface: ChatWorkspacePanelSurface = view.surface;
if (!workspaceSurface.openPanelSnapshot().connected) continue;
const clientSurface: ChatPanelClientSurface = view.surface;
if (!clientSurface.canServeAppServerContext(context)) continue;
return clientSurface;
}
return null;
}
private configuredAppServerContext(): AppServerQueryContext {
return {
codexPath: this.options.settingsRef.settings.codexPath,
vaultPath: this.options.settingsRef.vaultPath,
};
}
}