Align feature naming across UI and tests

This commit is contained in:
murashit 2026-05-23 09:44:35 +09:00
parent 9cb71e113b
commit 993e8782b7
48 changed files with 596 additions and 580 deletions

View file

@ -15,11 +15,11 @@ import { userInputWithWikiLinkMentionsAndSkills } from "./composer/wikilink-cont
import type { UserInput } from "../../generated/app-server/v2/UserInput";
import type { SendShortcut } from "../../settings/model";
import { renderComposerShell, renderComposerSuggestions, syncComposerControls, syncComposerHeight } from "./ui/composer";
import type { PanelState } from "./state";
import type { ChatState } from "./chat-state";
export interface PanelComposerControllerOptions {
export interface ChatComposerControllerOptions {
app: App;
state: PanelState;
state: ChatState;
viewId: string;
sendShortcut: () => SendShortcut;
canInterrupt: () => boolean;
@ -29,13 +29,13 @@ export interface PanelComposerControllerOptions {
onNewThread: () => void;
}
export class PanelComposerController {
export class ChatComposerController {
private composer: HTMLTextAreaElement | null = null;
private suggestionsEl: HTMLElement | null = null;
private noteCandidatesCache: NoteCandidate[] | null = null;
private noteEventsRegistered = false;
constructor(private readonly options: PanelComposerControllerOptions) {}
constructor(private readonly options: ChatComposerControllerOptions) {}
get trimmedDraft(): string {
return this.composer?.value.trim() ?? this.options.state.composerDraft.trim();

View file

@ -25,7 +25,7 @@ import type { ServerRequest } from "../../generated/app-server/ServerRequest";
import type { FileUpdateChange } from "../../generated/app-server/v2/FileUpdateChange";
import type { ThreadItem } from "../../generated/app-server/v2/ThreadItem";
import type { Turn } from "../../generated/app-server/v2/Turn";
import { clearActiveThreadState, type PanelState } from "./state";
import { clearActiveThreadState, type ChatState } from "./chat-state";
import { userInputResponse, type PendingUserInput } from "./user-input/model";
import { jsonPreview } from "../../utils";
import { classifyAppServerLog } from "./app-server-logs";
@ -33,7 +33,7 @@ import { attachHookRunsToTurn, hookRunDisplayItem } from "./hook-display";
import { routeServerNotification, routeServerRequest } from "./inbound-routing";
import { clearUserInputDrafts, createApprovalResultItem, createUserInputResultItem } from "./request-state";
export interface PanelControllerActions {
export interface ChatControllerActions {
refreshThreads: () => void;
refreshSkills: (forceReload?: boolean) => void;
maybeNameThread: (threadId: string, turn: Turn) => void;
@ -42,10 +42,10 @@ export interface PanelControllerActions {
rejectServerRequest: (requestId: RequestId, code: number, message: string) => boolean;
}
export class PanelController {
export class ChatController {
constructor(
private readonly state: PanelState,
private readonly actions: PanelControllerActions,
private readonly state: ChatState,
private readonly actions: ChatControllerActions,
) {}
handleNotification(notification: ServerNotification): void {

View file

@ -5,28 +5,28 @@ import { copyTextWithNotice } from "../../shared/ui/clipboard";
import { renderTextWithWikiLinks as renderInlineWikiLinks } from "../../shared/ui/dom";
import { messageRenderBlocks, notifyMessageContentRendered, syncMessageRenderBlocks } from "./ui/message-stream";
import { bottomScrollTop, captureScrollAnchor, isNearScrollBottom, restoreScrollAnchor } from "./ui/scroll";
import type { TurnDiffViewState } from "./ui/turn-diff";
import type { ChatTurnDiffViewState } from "./ui/turn-diff";
import { markdownFileLinkTarget } from "./markdown-file-links";
import { isRollbackCandidateItem, rollbackCandidateFromItems } from "./rollback";
import type { PanelState } from "./state";
import type { ChatState } from "./chat-state";
export interface PanelMessageRendererOptions {
export interface ChatMessageRendererOptions {
app: App;
owner: Component;
state: PanelState;
state: ChatState;
vaultPath: string;
blockSignatures: Map<string, string>;
consumeForceScrollToBottom: () => boolean;
loadOlderTurns: () => void;
rollbackThread: (threadId: string) => void;
implementPlan: (item: DisplayItem) => void;
openTurnDiff: (state: TurnDiffViewState) => void;
openTurnDiff: (state: ChatTurnDiffViewState) => void;
pendingRequestsSignature: () => string;
renderPendingRequests: () => HTMLElement | null;
}
export class PanelMessageRenderer {
constructor(private readonly options: PanelMessageRendererOptions) {}
export class ChatMessageRenderer {
constructor(private readonly options: ChatMessageRendererOptions) {}
render(parent: HTMLElement): void {
const { state } = this.options;
@ -154,7 +154,7 @@ export class PanelMessageRenderer {
}
export function implementPlanCandidateFromState(
state: Pick<PanelState, "activeThreadId" | "busy" | "composerDraft" | "requestedCollaborationMode" | "displayItems">,
state: Pick<ChatState, "activeThreadId" | "busy" | "composerDraft" | "requestedCollaborationMode" | "displayItems">,
): DisplayItem | null {
if (!state.activeThreadId || state.busy || state.composerDraft.trim().length > 0 || state.requestedCollaborationMode !== "plan") {
return null;

View file

@ -8,18 +8,18 @@ import {
import { reportedServiceTier } from "../../app-server/service-tier";
import type { McpServerStatus } from "../../generated/app-server/v2/McpServerStatus";
import { requestedOrConfiguredServiceTier, type RuntimeSnapshot } from "../../runtime/state";
import type { PanelState } from "./state";
import type { ChatState } from "./chat-state";
export interface PanelSessionControllerHost {
state: PanelState;
export interface ChatSessionControllerHost {
state: ChatState;
vaultPath: string;
currentClient: () => AppServerClient | null;
runtimeSnapshot: () => RuntimeSnapshot;
forceMessagesToBottom: () => void;
}
export class PanelSessionController {
constructor(private readonly host: PanelSessionControllerHost) {}
export class ChatSessionController {
constructor(private readonly host: ChatSessionControllerHost) {}
async refreshThreadList(): Promise<void> {
const client = this.host.currentClient();

View file

@ -22,7 +22,7 @@ export interface PendingTurnStart {
promptSubmitHookItemIds: string[];
}
export interface PanelState {
export interface ChatState {
status: string;
effectiveConfig: ConfigReadResponse | null;
initializeResponse: InitializeResponse | null;
@ -66,7 +66,7 @@ export interface PanelState {
openDetails: Set<string>;
}
export function createPanelState(): PanelState {
export function createChatState(): ChatState {
return {
status: "Idle",
effectiveConfig: null,
@ -112,7 +112,7 @@ export function createPanelState(): PanelState {
};
}
export function clearActiveTurnState(state: PanelState): void {
export function clearActiveTurnState(state: ChatState): void {
state.activeTurnId = null;
state.busy = false;
state.pendingTurnStart = null;
@ -121,7 +121,7 @@ export function clearActiveTurnState(state: PanelState): void {
state.userInputDrafts.clear();
}
export function clearActiveThreadState(state: PanelState): void {
export function clearActiveThreadState(state: ChatState): void {
state.activeThreadId = null;
state.activeThreadCwd = null;
state.activeModel = null;
@ -139,7 +139,7 @@ export function clearActiveThreadState(state: PanelState): void {
clearActiveTurnState(state);
}
export function clearConnectionScopedState(state: PanelState): void {
export function clearConnectionScopedState(state: ChatState): void {
clearActiveTurnState(state);
state.activeModel = null;
state.activeReasoningEffort = null;

View file

@ -3,23 +3,23 @@ import { ItemView, type ViewStateResult } from "obsidian";
import { VIEW_TYPE_CODEX_TURN_DIFF } from "../../constants";
import { copyTextWithNotice } from "../../shared/ui/clipboard";
import {
isPersistedTurnDiffViewState,
persistedTurnDiffViewState,
renderTurnDiffView,
type PersistedTurnDiffViewState,
type TurnDiffViewState,
isPersistedChatTurnDiffViewState,
persistedChatTurnDiffViewState,
renderChatTurnDiffView,
type PersistedChatTurnDiffViewState,
type ChatTurnDiffViewState,
} from "./ui/turn-diff";
export class CodexTurnDiffView extends ItemView {
private metadata: PersistedTurnDiffViewState | null = null;
private payload: TurnDiffViewState | null = null;
export class CodexChatTurnDiffView extends ItemView {
private metadata: PersistedChatTurnDiffViewState | null = null;
private payload: ChatTurnDiffViewState | null = null;
override getViewType(): string {
return VIEW_TYPE_CODEX_TURN_DIFF;
}
override getDisplayText(): string {
return "Codex turn diff";
return "Codex chat turn diff";
}
override getIcon(): string {
@ -32,7 +32,7 @@ export class CodexTurnDiffView extends ItemView {
override async setState(state: unknown, result: ViewStateResult): Promise<void> {
await super.setState(state, result);
this.metadata = isPersistedTurnDiffViewState(state)
this.metadata = isPersistedChatTurnDiffViewState(state)
? {
threadId: state.threadId,
turnId: state.turnId,
@ -48,15 +48,15 @@ export class CodexTurnDiffView extends ItemView {
this.render();
}
setDiffPayload(payload: TurnDiffViewState): void {
this.metadata = persistedTurnDiffViewState(payload);
setDiffPayload(payload: ChatTurnDiffViewState): void {
this.metadata = persistedChatTurnDiffViewState(payload);
this.payload = payload;
this.render();
}
private render(): void {
const root = this.contentEl;
renderTurnDiffView(
renderChatTurnDiffView(
root,
this.payload,
this.payload ? { copyDiff: () => void this.copyDiff(this.payload?.diff ?? "") } : {},

View file

@ -1,9 +1,9 @@
import type { AppServerClient } from "../../app-server/client";
import { displayItemsFromTurns } from "./display/thread-items";
import type { PanelState } from "./state";
import type { ChatState } from "./chat-state";
export interface ThreadHistoryLoaderHost {
state: PanelState;
state: ChatState;
currentClient: () => AppServerClient | null;
render: () => void;
addSystemMessage: (text: string) => void;

View file

@ -2,7 +2,7 @@ import type { AppServerClient } from "../../app-server/client";
import type { Thread } from "../../generated/app-server/v2/Thread";
import type { Turn } from "../../generated/app-server/v2/Turn";
import type { CodexPanelSettings } from "../../settings/model";
import type { PanelState } from "./state";
import type { ChatState } from "./chat-state";
import { getThreadTitle } from "../../domain/threads/model";
import {
findThreadNamingContext,
@ -19,7 +19,7 @@ export interface ThreadRenameEditState {
}
export interface ThreadRenameControllerHost {
state: PanelState;
state: ChatState;
vaultPath: string;
settings: () => CodexPanelSettings;
ensureConnected: () => Promise<void>;

View file

@ -13,7 +13,7 @@ import {
renderReasoningItem,
renderTaskProgressItem,
} from "./work-items";
import type { TurnDiffViewState } from "./turn-diff";
import type { ChatTurnDiffViewState } from "./turn-diff";
const USER_MESSAGE_COLLAPSE_HEIGHT_PX = 360;
const MESSAGE_CONTENT_RENDERED_EVENT = "codex-panel:message-content-rendered";
@ -43,7 +43,7 @@ export interface MessageStreamContext {
onImplementPlanItem?: (item: DisplayItem) => void;
canRollbackItem?: (item: DisplayItem) => boolean;
onRollbackItem?: (item: DisplayItem) => void;
openTurnDiff?: (state: TurnDiffViewState) => void;
openTurnDiff?: (state: ChatTurnDiffViewState) => void;
pendingRequestsSignature?: string;
renderPendingRequests?: () => HTMLElement | null;
}

View file

@ -68,7 +68,7 @@ function renderDetailSection(parent: HTMLElement, section: ToolResultDetailSecti
return;
}
if (section.kind === "diff") {
const diffBlock = renderOutputSection(parent, section.title, "codex-panel__diff-file");
const diffBlock = renderOutputSection(parent, section.title, "codex-panel-diff-file");
renderDiff(diffBlock, section.diff);
return;
}
@ -95,9 +95,9 @@ function renderOutputSection(parent: HTMLElement, title: string, className: stri
}
function renderDiff(parent: HTMLElement, diff: string): void {
const pre = parent.createEl("pre", { cls: "codex-panel__diff" });
const pre = parent.createEl("pre", { cls: "codex-panel-diff" });
for (const line of diff.split("\n")) {
const cls = diffLineClassFromText(line);
pre.createEl("span", { cls: `codex-panel__diff-line codex-panel__diff-line--${cls}`, text: displayDiffLineText(line, cls) });
pre.createEl("span", { cls: `codex-panel-diff__line codex-panel-diff__line--${cls}`, text: displayDiffLineText(line, cls) });
}
}

View file

@ -4,7 +4,7 @@ import { shortThreadId } from "../../../utils";
export { displayDiffLines } from "../../../shared/diff/unified";
export interface TurnDiffViewState {
export interface ChatTurnDiffViewState {
threadId: string;
turnId: string;
cwd: string | null;
@ -12,13 +12,13 @@ export interface TurnDiffViewState {
diff: string;
}
export type PersistedTurnDiffViewState = Omit<TurnDiffViewState, "diff">;
export type PersistedChatTurnDiffViewState = Omit<ChatTurnDiffViewState, "diff">;
export interface TurnDiffViewActions {
export interface ChatTurnDiffViewActions {
copyDiff?: () => void;
}
export function persistedTurnDiffViewState(state: TurnDiffViewState): PersistedTurnDiffViewState {
export function persistedChatTurnDiffViewState(state: ChatTurnDiffViewState): PersistedChatTurnDiffViewState {
return {
threadId: state.threadId,
turnId: state.turnId,
@ -27,9 +27,9 @@ export function persistedTurnDiffViewState(state: TurnDiffViewState): PersistedT
};
}
export function isPersistedTurnDiffViewState(value: unknown): value is PersistedTurnDiffViewState {
export function isPersistedChatTurnDiffViewState(value: unknown): value is PersistedChatTurnDiffViewState {
if (!value || typeof value !== "object") return false;
const record = value as Partial<PersistedTurnDiffViewState>;
const record = value as Partial<PersistedChatTurnDiffViewState>;
return (
typeof record.threadId === "string" &&
typeof record.turnId === "string" &&
@ -39,20 +39,20 @@ export function isPersistedTurnDiffViewState(value: unknown): value is Persisted
);
}
export function renderTurnDiffView(
export function renderChatTurnDiffView(
parent: HTMLElement,
state: TurnDiffViewState | null,
actions: TurnDiffViewActions = {},
metadata: PersistedTurnDiffViewState | null = null,
state: ChatTurnDiffViewState | null,
actions: ChatTurnDiffViewActions = {},
metadata: PersistedChatTurnDiffViewState | null = null,
): void {
parent.empty();
parent.addClass("codex-panel-turn-diff");
parent.addClass("codex-panel-chat-turn-diff");
if (!state) {
if (metadata) {
renderTurnDiffHeader(parent, metadata, null);
parent.createDiv({ cls: "codex-panel-turn-diff__empty", text: "Turn diff is no longer available." });
parent.createDiv({ cls: "codex-panel-chat-turn-diff__empty", text: "Turn diff is no longer available." });
} else {
parent.createDiv({ cls: "codex-panel-turn-diff__empty", text: "No turn diff selected." });
parent.createDiv({ cls: "codex-panel-chat-turn-diff__empty", text: "No turn diff selected." });
}
return;
}
@ -60,7 +60,7 @@ export function renderTurnDiffView(
renderTurnDiffHeader(parent, state, actions.copyDiff ?? null);
if (state.files.length > 0) {
const files = parent.createEl("details", { cls: "codex-panel-turn-diff__files" });
const files = parent.createEl("details", { cls: "codex-panel-chat-turn-diff__files" });
files.createEl("summary", { text: "Changed files" });
const list = files.createEl("ul");
for (const file of state.files) {
@ -73,29 +73,29 @@ export function renderTurnDiffView(
function renderTurnDiffHeader(
parent: HTMLElement,
state: PersistedTurnDiffViewState,
copyDiff: TurnDiffViewActions["copyDiff"] | null,
state: PersistedChatTurnDiffViewState,
copyDiff: ChatTurnDiffViewActions["copyDiff"] | null,
): void {
const header = parent.createDiv({ cls: "codex-panel-turn-diff__header" });
const titleBlock = header.createDiv({ cls: "codex-panel-turn-diff__title-block" });
titleBlock.createDiv({ cls: "codex-panel-turn-diff__title", text: "Turn diff" });
const header = parent.createDiv({ cls: "codex-panel-chat-turn-diff__header" });
const titleBlock = header.createDiv({ cls: "codex-panel-chat-turn-diff__title-block" });
titleBlock.createDiv({ cls: "codex-panel-chat-turn-diff__title", text: "Turn diff" });
titleBlock.createDiv({
cls: "codex-panel-turn-diff__meta",
cls: "codex-panel-chat-turn-diff__meta",
text: `${shortThreadId(state.threadId)} / ${shortThreadId(state.turnId)} · ${fileCountLabel(state.files)}`,
attr: { title: `Thread ${state.threadId}\nTurn ${state.turnId}${state.cwd ? `\n${state.cwd}` : ""}` },
});
if (copyDiff) {
const copyButton = createIconButton(header, "copy", "Copy diff", "codex-panel-turn-diff__copy");
const copyButton = createIconButton(header, "copy", "Copy diff", "codex-panel-chat-turn-diff__copy");
copyButton.onclick = copyDiff;
}
}
export function renderUnifiedDiff(parent: HTMLElement, diff: string): HTMLElement {
const pre = parent.createEl("pre", { cls: "codex-panel__diff codex-panel-turn-diff__diff" });
const pre = parent.createEl("pre", { cls: "codex-panel-diff codex-panel-chat-turn-diff__diff" });
for (const line of displayDiffLines(diff)) {
const lineClass = diffLineClass(line);
pre.createEl("span", {
cls: `codex-panel__diff-line codex-panel__diff-line--${lineClass}`,
cls: `codex-panel-diff__line codex-panel-diff__line--${lineClass}`,
text: displayDiffLineText(line.text, lineClass),
});
}

View file

@ -20,7 +20,7 @@ import {
collaborationModeToggleMessage,
nextCollaborationMode,
} from "../../runtime/collaboration-mode";
import { PanelController } from "./controller";
import { ChatController } from "./chat-controller";
import { connectionDiagnosticSections, diagnosticAlertLevel } from "./diagnostics";
import { rollbackCandidateFromItems } from "./rollback";
import { contextSummary, effectiveConfigSections, rateLimitSummary } from "../../runtime/view";
@ -46,16 +46,16 @@ import { compactContextLabel, modelOverrideMessage, reasoningEffortOverrideMessa
import { executeSlashCommand as runSlashCommand, type SlashCommandExecutionResult } from "./slash-commands";
import type { ThreadReferenceInput } from "./slash-commands";
import { mcpStatusLines } from "./mcp-status";
import { PanelSessionController } from "./session-controller";
import { ChatSessionController } from "./chat-session-controller";
import { statusValue, usageLimitStatusLines } from "./status-lines";
import { ThreadHistoryLoader } from "./thread-history";
import { ThreadRenameController } from "./thread-rename";
import { pendingRequestsSignature as requestStateSignature, userInputDraftKey, userInputOtherDraftKey } from "./request-state";
import type { CodexPanelSettings } from "../../settings/model";
import { questionDefaultAnswer, type PendingUserInput } from "./user-input/model";
import { PanelComposerController } from "./composer-controller";
import { ChatComposerController } from "./chat-composer-controller";
import { attachHookRunsToTurn } from "./hook-display";
import { clearActiveThreadState, clearConnectionScopedState, createPanelState, type PanelState } from "./state";
import { clearActiveThreadState, clearConnectionScopedState, createChatState, type ChatState } from "./chat-state";
import { codexPanelDisplayTitle, getThreadTitle, inheritedForkThreadName, upsertThread } from "../../domain/threads/model";
import { exportArchivedThreadMarkdown } from "../../domain/threads/export";
import {
@ -68,28 +68,28 @@ import {
} from "../../domain/threads/reference";
import { renderPendingRequestMessage } from "./ui/pending-request-message";
import { renderToolbar, toolbarSignature, type ToolbarChoice, type ToolbarViewModel } from "./ui/toolbar";
import type { TurnDiffViewState } from "./ui/turn-diff";
import { PanelMessageRenderer } from "./message-renderer";
import type { ChatTurnDiffViewState } from "./ui/turn-diff";
import { ChatMessageRenderer } from "./chat-message-renderer";
export interface CodexPanelHost {
export interface CodexChatHost {
readonly settings: CodexPanelSettings;
readonly vaultPath: string;
openThreadInNewView(threadId: string): Promise<unknown>;
openTurnDiff(state: TurnDiffViewState): Promise<void>;
openTurnDiff(state: ChatTurnDiffViewState): Promise<void>;
refreshOpenThreadLists(): void;
}
export class CodexPanelView extends ItemView {
export class CodexChatView extends ItemView {
private client: AppServerClient | null = null;
private readonly connection: ConnectionManager;
private readonly controller: PanelController;
private readonly session: PanelSessionController;
private readonly controller: ChatController;
private readonly session: ChatSessionController;
private readonly history: ThreadHistoryLoader;
private readonly threadRename: ThreadRenameController;
private readonly state: PanelState = createPanelState();
private readonly state: ChatState = createChatState();
private readonly viewId = `codex-panel-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
private readonly composerController: PanelComposerController;
private readonly messageRenderer: PanelMessageRenderer;
private readonly composerController: ChatComposerController;
private readonly messageRenderer: ChatMessageRenderer;
private readonly blockSignatures = new Map<string, string>();
private toolbarEl: HTMLElement | null = null;
private configSlotEl: HTMLElement | null = null;
@ -101,10 +101,10 @@ export class CodexPanelView extends ItemView {
constructor(
leaf: WorkspaceLeaf,
private readonly plugin: CodexPanelHost,
private readonly plugin: CodexChatHost,
) {
super(leaf);
this.messageRenderer = new PanelMessageRenderer({
this.messageRenderer = new ChatMessageRenderer({
app: this.app,
owner: this,
state: this.state,
@ -122,7 +122,7 @@ export class CodexPanelView extends ItemView {
pendingRequestsSignature: () => this.pendingRequestsSignature(),
renderPendingRequests: () => this.createPendingRequestsElement(),
});
this.composerController = new PanelComposerController({
this.composerController = new ChatComposerController({
app: this.app,
state: this.state,
viewId: this.viewId,
@ -156,7 +156,7 @@ export class CodexPanelView extends ItemView {
this.render();
},
});
this.controller = new PanelController(this.state, {
this.controller = new ChatController(this.state, {
refreshThreads: () => void this.refreshThreads(),
refreshSkills: (forceReload) => void this.refreshSkills(forceReload),
maybeNameThread: (threadId, turn) => {
@ -169,7 +169,7 @@ export class CodexPanelView extends ItemView {
respondToServerRequest: (requestId, result) => this.respondToServerRequest(requestId, result),
rejectServerRequest: (requestId, code, message) => this.rejectServerRequest(requestId, code, message),
});
this.session = new PanelSessionController({
this.session = new ChatSessionController({
state: this.state,
vaultPath: this.plugin.vaultPath,
currentClient: () => this.connection.currentClient(),
@ -710,7 +710,7 @@ export class CodexPanelView extends ItemView {
return latestProposedPlanItem(this.state.displayItems)?.id === item.id;
}
private toggleRuntimePicker(picker: NonNullable<PanelState["runtimePicker"]>): void {
private toggleRuntimePicker(picker: NonNullable<ChatState["runtimePicker"]>): void {
this.state.runtimePicker = this.state.runtimePicker === picker ? null : picker;
if (this.state.runtimePicker !== null) {
this.state.openDetails.delete("history");

View file

@ -1,19 +1,19 @@
import { MarkdownView, Notice, type Editor, type Plugin } from "obsidian";
import type { RewriteRuntimeSettings, RewriteSession } from "./model";
import { RewriteSelectionPopover } from "./popover";
import type { SelectionRewriteRuntimeSettings, SelectionRewriteSession } from "./model";
import { SelectionRewritePopover } from "./popover";
import type { SendShortcut } from "../../settings/model";
export interface RewriteSelectionCommandHost extends Plugin {
export interface SelectionRewriteCommandHost extends Plugin {
settings: {
codexPath: string;
sendShortcut: SendShortcut;
} & RewriteRuntimeSettings;
} & SelectionRewriteRuntimeSettings;
vaultPath: string;
}
export function registerRewriteSelectionCommand(plugin: RewriteSelectionCommandHost): void {
const activePopovers = new Set<RewriteSelectionPopover>();
export function registerSelectionRewriteCommand(plugin: SelectionRewriteCommandHost): void {
const activePopovers = new Set<SelectionRewritePopover>();
plugin.register(() => {
for (const popover of activePopovers) popover.close();
@ -35,7 +35,7 @@ export function registerRewriteSelectionCommand(plugin: RewriteSelectionCommandH
return;
}
const session: RewriteSession = {
const session: SelectionRewriteSession = {
filePath: view.file.path,
targetRange: {
from: clonePosition(editor.getCursor("from")),
@ -50,7 +50,7 @@ export function registerRewriteSelectionCommand(plugin: RewriteSelectionCommandH
debugText: null,
};
const popover = new RewriteSelectionPopover({
const popover = new SelectionRewritePopover({
codexPath: plugin.settings.codexPath,
cwd: plugin.vaultPath,
editor,

View file

@ -1,13 +1,13 @@
import { isComposerSendKey, type ComposerSendKeyEvent } from "../chat/composer/keys";
import type { SendShortcut } from "../../settings/model";
export type RewriteGenerateKeyEvent = ComposerSendKeyEvent;
export type SelectionRewriteGenerateKeyEvent = ComposerSendKeyEvent;
export function isRewriteGenerateKey(event: RewriteGenerateKeyEvent, shortcut: SendShortcut): boolean {
export function isSelectionRewriteGenerateKey(event: SelectionRewriteGenerateKeyEvent, shortcut: SendShortcut): boolean {
return isComposerSendKey(event, shortcut);
}
export function isRewriteActionKey(event: RewriteGenerateKeyEvent): boolean {
export function isSelectionRewriteActionKey(event: SelectionRewriteGenerateKeyEvent): boolean {
if (event.isComposing || event.key !== "Enter" || event.shiftKey || event.altKey) return false;
return true;
}

View file

@ -1,9 +1,9 @@
import type { EditorPosition } from "obsidian";
import type { ReasoningEffort } from "../../generated/app-server/ReasoningEffort";
export type RewriteStatus = "editing-prompt" | "generating" | "preview" | "applied" | "cancelled" | "failed";
export type SelectionRewriteStatus = "editing-prompt" | "generating" | "preview" | "applied" | "cancelled" | "failed";
export interface RewriteSession {
export interface SelectionRewriteSession {
filePath: string;
targetRange: {
from: EditorPosition;
@ -12,17 +12,17 @@ export interface RewriteSession {
originalText: string;
noteText: string;
instruction: string;
status: RewriteStatus;
status: SelectionRewriteStatus;
streamText: string;
replacementText: string | null;
debugText: string | null;
}
export interface RewriteRuntimeSettings {
export interface SelectionRewriteRuntimeSettings {
rewriteSelectionModel: string | null;
rewriteSelectionEffort: ReasoningEffort | null;
}
export function canApplyRewrite(currentText: string, originalText: string): boolean {
export function canApplySelectionRewrite(currentText: string, originalText: string): boolean {
return currentText === originalText;
}

View file

@ -1,25 +1,25 @@
import type { Turn } from "../../generated/app-server/v2/Turn";
export interface RewriteOutput {
export interface SelectionRewriteOutput {
replacementText: string;
}
export interface RewriteOutputParseResult {
output: RewriteOutput | null;
export interface SelectionRewriteOutputParseResult {
output: SelectionRewriteOutput | null;
rawText: string | null;
}
export class RewriteOutputError extends Error {
export class SelectionRewriteOutputError extends Error {
constructor(
message: string,
readonly rawText: string | null,
) {
super(message);
this.name = "RewriteOutputError";
this.name = "SelectionRewriteOutputError";
}
}
export function parseRewriteOutput(text: string): RewriteOutput | null {
export function parseSelectionRewriteOutput(text: string): SelectionRewriteOutput | null {
try {
const parsed = JSON.parse(text.trim()) as unknown;
if (!parsed || typeof parsed !== "object") return null;
@ -31,14 +31,14 @@ export function parseRewriteOutput(text: string): RewriteOutput | null {
}
}
export function rewriteOutputFromTurn(turn: Turn): RewriteOutput | null {
return rewriteOutputParseResultFromTurn(turn).output;
export function selectionRewriteOutputFromTurn(turn: Turn): SelectionRewriteOutput | null {
return selectionRewriteOutputParseResultFromTurn(turn).output;
}
export function rewriteOutputParseResultFromTurn(turn: Turn): RewriteOutputParseResult {
export function selectionRewriteOutputParseResultFromTurn(turn: Turn): SelectionRewriteOutputParseResult {
const text = lastAgentMessageText(turn);
if (!text) return { output: null, rawText: null };
return { output: parseRewriteOutput(text), rawText: text };
return { output: parseSelectionRewriteOutput(text), rawText: text };
}
function lastAgentMessageText(turn: Turn): string | null {

View file

@ -4,29 +4,29 @@ import { diffLineClass, displayDiffLineText, displayDiffLines } from "../../shar
import { createIconButton } from "../../shared/ui/components";
import { syncTextareaHeight } from "../../shared/ui/textarea-autogrow";
import { buildSelectionUnifiedDiff } from "./diff";
import { isRewriteActionKey, isRewriteGenerateKey } from "./keys";
import { canApplyRewrite, type RewriteRuntimeSettings, type RewriteSession } from "./model";
import { RewriteOutputError } from "./output";
import { positionRewritePopover } from "./position";
import { buildRewritePrompt } from "./prompt";
import { runRewriteSelection, type RewriteActivity } from "./runner";
import { isSelectionRewriteActionKey, isSelectionRewriteGenerateKey } from "./keys";
import { canApplySelectionRewrite, type SelectionRewriteRuntimeSettings, type SelectionRewriteSession } from "./model";
import { SelectionRewriteOutputError } from "./output";
import { positionSelectionRewritePopover } from "./position";
import { buildSelectionRewritePrompt } from "./prompt";
import { runSelectionRewrite, type SelectionRewriteActivity } from "./runner";
import type { SendShortcut } from "../../settings/model";
const POPOVER_MARGIN = 8;
export interface RewriteSelectionPopoverOptions {
export interface SelectionRewritePopoverOptions {
codexPath: string;
cwd: string;
editor: Editor;
onClose?: () => void;
runtimeSettings: RewriteRuntimeSettings;
runtimeSettings: SelectionRewriteRuntimeSettings;
sendShortcut: SendShortcut;
session: RewriteSession;
session: SelectionRewriteSession;
}
type Cleanup = () => void;
interface RewritePopoverElements {
interface SelectionRewriteElements {
root: HTMLElement;
instruction: HTMLTextAreaElement;
generateButton: HTMLButtonElement;
@ -38,12 +38,12 @@ interface RewritePopoverElements {
debug: HTMLDetailsElement | null;
}
export class RewriteSelectionPopover {
private elements: RewritePopoverElements | null = null;
export class SelectionRewritePopover {
private elements: SelectionRewriteElements | null = null;
private abortController: AbortController | null = null;
private readonly cleanups: Cleanup[] = [];
constructor(private readonly options: RewriteSelectionPopoverOptions) {}
constructor(private readonly options: SelectionRewritePopoverOptions) {}
open(): void {
this.close();
@ -104,10 +104,10 @@ export class RewriteSelectionPopover {
this.abortController = abortController;
try {
const output = await runRewriteSelection({
const output = await runSelectionRewrite({
codexPath: this.options.codexPath,
cwd: this.options.cwd,
prompt: buildRewritePrompt(this.options.session),
prompt: buildSelectionRewritePrompt(this.options.session),
runtimeSettings: this.options.runtimeSettings,
onActivity: (activity) => {
this.updateActivity(activity);
@ -118,7 +118,7 @@ export class RewriteSelectionPopover {
signal: abortController.signal,
});
if (abortController.signal.aborted) return;
this.showRewritePreview(output.replacementText);
this.showSelectionRewritePreview(output.replacementText);
} catch (error) {
if (abortController.signal.aborted) {
this.options.session.status = "cancelled";
@ -146,17 +146,17 @@ export class RewriteSelectionPopover {
this.position();
}
private updateActivity(activity: RewriteActivity): void {
private updateActivity(activity: SelectionRewriteActivity): void {
this.setStatus(activity === "reasoning" ? "Reasoning" : "Writing replacement", { active: true });
}
private createElements(): RewritePopoverElements {
const root = activeDocument.body.createDiv({ cls: "codex-panel-rewrite-popover" });
private createElements(): SelectionRewriteElements {
const root = activeDocument.body.createDiv({ cls: "codex-panel-selection-rewrite" });
root.setAttr("role", "dialog");
root.setAttr("aria-label", "Rewrite selection");
const instruction = root.createEl("textarea", {
cls: "codex-panel__input codex-panel-rewrite-popover__instruction",
cls: "codex-panel__input codex-panel-selection-rewrite__instruction",
attr: { placeholder: "How should Codex rewrite this selection?" },
});
instruction.value = this.options.session.instruction;
@ -167,42 +167,47 @@ export class RewriteSelectionPopover {
};
instruction.onkeydown = (event) => {
const hasReplacement = this.options.session.replacementText !== null;
if (!(hasReplacement ? isRewriteActionKey(event) : isRewriteGenerateKey(event, this.options.sendShortcut))) return;
if (!(hasReplacement ? isSelectionRewriteActionKey(event) : isSelectionRewriteGenerateKey(event, this.options.sendShortcut))) return;
event.preventDefault();
event.stopPropagation();
void this.generate();
};
const promptRow = root.createDiv({ cls: "codex-panel-rewrite-popover__prompt-row" });
const promptRow = root.createDiv({ cls: "codex-panel-selection-rewrite__prompt-row" });
promptRow.append(instruction);
const controls = promptRow.createDiv({ cls: "codex-panel__composer-actions codex-panel-rewrite-popover__controls" });
const controls = promptRow.createDiv({ cls: "codex-panel__composer-actions codex-panel-selection-rewrite__controls" });
const generateButton = createIconButton(
controls,
"sparkles",
"Generate",
"codex-panel__composer-action codex-panel-rewrite-popover__icon-button",
"codex-panel__composer-action codex-panel-selection-rewrite__icon-button",
);
generateButton.onclick = () => void this.generate();
const cancelButton = createIconButton(controls, "x", "Cancel", "codex-panel__composer-action codex-panel-rewrite-popover__icon-button");
const cancelButton = createIconButton(
controls,
"x",
"Cancel",
"codex-panel__composer-action codex-panel-selection-rewrite__icon-button",
);
cancelButton.onclick = () => {
this.cancel();
};
const status = root.createDiv({ cls: "codex-panel-rewrite-popover__status" });
const streamPreview = root.createEl("pre", { cls: "codex-panel-rewrite-popover__stream-preview is-hidden" });
const resultRow = root.createDiv({ cls: "codex-panel-rewrite-popover__result-row" });
const diff = resultRow.createDiv({ cls: "codex-panel-rewrite-popover__diff" });
const status = root.createDiv({ cls: "codex-panel-selection-rewrite__status" });
const streamPreview = root.createEl("pre", { cls: "codex-panel-selection-rewrite__stream-preview is-hidden" });
const resultRow = root.createDiv({ cls: "codex-panel-selection-rewrite__result-row" });
const diff = resultRow.createDiv({ cls: "codex-panel-selection-rewrite__diff" });
const applyButton = createIconButton(
resultRow,
"check",
"Apply",
"codex-panel__composer-action codex-panel-rewrite-popover__icon-button mod-cta",
"codex-panel__composer-action codex-panel-selection-rewrite__icon-button mod-cta",
);
applyButton.onclick = () => {
this.apply();
};
applyButton.onkeydown = (event) => {
if (!isRewriteActionKey(event)) return;
if (!isSelectionRewriteActionKey(event)) return;
event.preventDefault();
this.apply();
};
@ -221,7 +226,7 @@ export class RewriteSelectionPopover {
this.renderDebug(null);
}
private showRewritePreview(replacementText: string): void {
private showSelectionRewritePreview(replacementText: string): void {
this.options.session.replacementText = replacementText;
this.options.session.status = "preview";
this.options.session.streamText = "";
@ -232,7 +237,7 @@ export class RewriteSelectionPopover {
private showGenerationFailure(error: unknown): void {
this.options.session.status = "failed";
this.options.session.debugText = error instanceof RewriteOutputError ? error.rawText : null;
this.options.session.debugText = error instanceof SelectionRewriteOutputError ? error.rawText : null;
this.options.session.streamText = "";
this.renderStreamPreview();
this.renderDebug(this.options.session.debugText);
@ -251,7 +256,7 @@ export class RewriteSelectionPopover {
const replacement = this.options.session.replacementText;
if (replacement === null || !this.elements) return;
this.elements.diff.empty();
renderRewriteDiff(
renderSelectionRewriteDiff(
this.elements.diff,
buildSelectionUnifiedDiff(this.options.session.filePath, this.options.session.originalText, replacement),
);
@ -263,7 +268,7 @@ export class RewriteSelectionPopover {
this.elements.debug = null;
if (!debugText) return;
const debugEl = this.elements.root.createEl("details", { cls: "codex-panel-rewrite-popover__debug" });
const debugEl = this.elements.root.createEl("details", { cls: "codex-panel-selection-rewrite__debug" });
debugEl.createEl("summary", { text: "Debug output" });
debugEl.createEl("pre", { text: debugText });
this.elements.debug = debugEl;
@ -275,7 +280,7 @@ export class RewriteSelectionPopover {
const { editor, session } = this.options;
const currentText = editor.getRange(session.targetRange.from, session.targetRange.to);
if (!canApplyRewrite(currentText, session.originalText)) {
if (!canApplySelectionRewrite(currentText, session.originalText)) {
new Notice("Selection changed. Generate the rewrite again before applying.");
this.setStatus("Selection changed. Generate the rewrite again before applying.");
return;
@ -299,7 +304,7 @@ export class RewriteSelectionPopover {
if (!text && !options.active) return;
status.createSpan({ text });
if (!options.active) return;
const dots = status.createSpan({ cls: "codex-panel-rewrite-popover__status-dots" });
const dots = status.createSpan({ cls: "codex-panel-selection-rewrite__status-dots" });
dots.createSpan({ text: "." });
dots.createSpan({ text: "." });
dots.createSpan({ text: "." });
@ -328,7 +333,7 @@ export class RewriteSelectionPopover {
private position(): void {
if (!this.elements) return;
if (!positionRewritePopover(this.elements.root, this.options.editor, POPOVER_MARGIN)) this.close();
if (!positionSelectionRewritePopover(this.elements.root, this.options.editor, POPOVER_MARGIN)) this.close();
}
private addDomListener<K extends keyof WindowEventMap>(
@ -356,13 +361,13 @@ export class RewriteSelectionPopover {
}
}
function renderRewriteDiff(parent: HTMLElement, diff: string): void {
const pre = parent.createEl("pre", { cls: "codex-panel__diff codex-panel-rewrite-popover__diff-body" });
function renderSelectionRewriteDiff(parent: HTMLElement, diff: string): void {
const pre = parent.createEl("pre", { cls: "codex-panel-diff codex-panel-selection-rewrite__diff-body" });
for (const line of displayDiffLines(diff)) {
if (line.kind === "file" || line.text.startsWith("@@")) continue;
const lineClass = diffLineClass(line);
pre.createEl("span", {
cls: `codex-panel__diff-line codex-panel__diff-line--${lineClass}`,
cls: `codex-panel-diff__line codex-panel-diff__line--${lineClass}`,
text: displayDiffLineText(line.text, lineClass),
});
}

View file

@ -1,6 +1,6 @@
import type { Editor } from "obsidian";
export function positionRewritePopover(root: HTMLElement, editor: Editor, margin: number): boolean {
export function positionSelectionRewritePopover(root: HTMLElement, editor: Editor, margin: number): boolean {
if (!root.isConnected) return false;
const view = editorViewFromEditor(editor);

View file

@ -1,10 +1,10 @@
import type { RewriteSession } from "./model";
import type { SelectionRewriteSession } from "./model";
const MAX_NOTE_CONTEXT_CHARS = 20_000;
export const REWRITE_SERVICE_NAME = "codex-panel-rewrite-selection";
export const SELECTION_REWRITE_SERVICE_NAME = "codex-panel-rewrite-selection";
export const REWRITE_DEVELOPER_INSTRUCTIONS = [
export const SELECTION_REWRITE_DEVELOPER_INSTRUCTIONS = [
"You rewrite selected Obsidian note text.",
"Return only JSON matching the requested schema.",
"Do not include Markdown fences, explanations, alternatives, or comments outside JSON.",
@ -12,7 +12,7 @@ export const REWRITE_DEVELOPER_INSTRUCTIONS = [
"Preserve Obsidian-specific syntax such as wikilinks, block ids, callouts, frontmatter-like text, and Dataview blocks unless the user's instruction explicitly asks to change them.",
].join("\n");
export function buildRewritePrompt(session: RewriteSession): string {
export function buildSelectionRewritePrompt(session: SelectionRewriteSession): string {
return [
"Rewrite the selected text according to the user's instruction.",
"",

View file

@ -6,13 +6,13 @@ import type { Model } from "../../generated/app-server/v2/Model";
import type { ThreadItem } from "../../generated/app-server/v2/ThreadItem";
import type { Turn } from "../../generated/app-server/v2/Turn";
import { runtimeOverride, validatedRuntimeOverride } from "../../runtime/model";
import type { RewriteRuntimeSettings } from "./model";
import { REWRITE_DEVELOPER_INSTRUCTIONS, REWRITE_SERVICE_NAME } from "./prompt";
import { RewriteOutputError, rewriteOutputParseResultFromTurn, type RewriteOutput } from "./output";
import type { SelectionRewriteRuntimeSettings } from "./model";
import { SELECTION_REWRITE_DEVELOPER_INSTRUCTIONS, SELECTION_REWRITE_SERVICE_NAME } from "./prompt";
import { SelectionRewriteOutputError, selectionRewriteOutputParseResultFromTurn, type SelectionRewriteOutput } from "./output";
const REWRITE_TIMEOUT_MS = 120_000;
const SELECTION_REWRITE_TIMEOUT_MS = 120_000;
const REWRITE_OUTPUT_SCHEMA: JsonValue = {
const SELECTION_REWRITE_OUTPUT_SCHEMA: JsonValue = {
type: "object",
properties: {
replacementText: {
@ -23,19 +23,19 @@ const REWRITE_OUTPUT_SCHEMA: JsonValue = {
additionalProperties: false,
};
export interface RunRewriteSelectionOptions {
export interface RunSelectionRewriteOptions {
codexPath: string;
cwd: string;
prompt: string;
runtimeSettings?: RewriteRuntimeSettings;
onActivity?: (activity: RewriteActivity) => void;
runtimeSettings?: SelectionRewriteRuntimeSettings;
onActivity?: (activity: SelectionRewriteActivity) => void;
onPreview?: (text: string) => void;
signal?: AbortSignal;
}
export type RewriteActivity = "reasoning" | "writing";
export type SelectionRewriteActivity = "reasoning" | "writing";
export async function runRewriteSelection(options: RunRewriteSelectionOptions): Promise<RewriteOutput> {
export async function runSelectionRewrite(options: RunSelectionRewriteOptions): Promise<SelectionRewriteOutput> {
throwIfAborted(options.signal);
let threadId: string | null = null;
let expectedTurnId: string | null = null;
@ -52,7 +52,7 @@ export async function runRewriteSelection(options: RunRewriteSelectionOptions):
if (completed) return;
completed = true;
reject(new Error("Timed out while rewriting the selection."));
}, REWRITE_TIMEOUT_MS);
}, SELECTION_REWRITE_TIMEOUT_MS);
handleNotification = (notification): void => {
if (completed) return;
@ -101,22 +101,22 @@ export async function runRewriteSelection(options: RunRewriteSelectionOptions):
onExit: () => {
if (completed) return;
completed = true;
rejectCompletedTurn?.(new Error("Codex rewrite app-server exited."));
rejectCompletedTurn?.(new Error("Selection rewrite app-server exited."));
},
});
try {
await abortable(client.connect(), options.signal);
const runtime = options.runtimeSettings
? await abortable(rewriteRuntimeForClient(client, options.runtimeSettings), options.signal)
? await abortable(selectionRewriteRuntimeForClient(client, options.runtimeSettings), options.signal)
: {};
const threadResponse = await abortable(
client.startEphemeralThread(options.cwd, REWRITE_SERVICE_NAME, REWRITE_DEVELOPER_INSTRUCTIONS),
client.startEphemeralThread(options.cwd, SELECTION_REWRITE_SERVICE_NAME, SELECTION_REWRITE_DEVELOPER_INSTRUCTIONS),
options.signal,
);
threadId = threadResponse.thread.id;
const turnResponse = await abortable(
client.startStructuredTurn(threadId, options.cwd, options.prompt, REWRITE_OUTPUT_SCHEMA, runtime.model, runtime.effort),
client.startStructuredTurn(threadId, options.cwd, options.prompt, SELECTION_REWRITE_OUTPUT_SCHEMA, runtime.model, runtime.effort),
options.signal,
);
expectedTurnId = turnResponse.turn.id;
@ -124,8 +124,8 @@ export async function runRewriteSelection(options: RunRewriteSelectionOptions):
turnResponse.turn.status === "completed"
? turnWithCollectedItems(turnResponse.turn, completedItems)
: await abortable(completedTurn, options.signal);
const { output, rawText } = rewriteOutputParseResultFromTurn(turn);
if (!output) throw new RewriteOutputError("Codex did not return a valid rewrite patch.", rawText);
const { output, rawText } = selectionRewriteOutputParseResultFromTurn(turn);
if (!output) throw new SelectionRewriteOutputError("Codex did not return a valid selection rewrite response.", rawText);
return output;
} finally {
completed = true;
@ -135,7 +135,7 @@ export async function runRewriteSelection(options: RunRewriteSelectionOptions):
}
function throwIfAborted(signal: AbortSignal | undefined): void {
if (signal?.aborted) throw rewriteAbortError();
if (signal?.aborted) throw selectionRewriteAbortError();
}
function abortable<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
@ -143,7 +143,7 @@ function abortable<T>(promise: Promise<T>, signal: AbortSignal | undefined): Pro
throwIfAborted(signal);
return new Promise<T>((resolve, reject) => {
const onAbort = (): void => {
reject(rewriteAbortError());
reject(selectionRewriteAbortError());
};
signal.addEventListener("abort", onAbort, { once: true });
promise.then(resolve, reject).finally(() => {
@ -152,7 +152,7 @@ function abortable<T>(promise: Promise<T>, signal: AbortSignal | undefined): Pro
});
}
function rewriteAbortError(): Error {
function selectionRewriteAbortError(): Error {
return new Error("Selection rewrite cancelled.");
}
@ -161,25 +161,28 @@ function turnWithCollectedItems(turn: Turn, completedItems: ThreadItem[]): Turn
return { ...turn, items: completedItems };
}
export interface RewriteRuntime {
export interface SelectionRewriteRuntime {
model?: string;
effort?: ReasoningEffort;
}
export function rewriteRuntime(settings: RewriteRuntimeSettings): RewriteRuntime {
export function selectionRewriteRuntime(settings: SelectionRewriteRuntimeSettings): SelectionRewriteRuntime {
return runtimeOverride({ model: settings.rewriteSelectionModel, effort: settings.rewriteSelectionEffort });
}
export function validatedRewriteRuntime(settings: RewriteRuntimeSettings, models: Model[]): RewriteRuntime {
export function validatedSelectionRewriteRuntime(settings: SelectionRewriteRuntimeSettings, models: Model[]): SelectionRewriteRuntime {
return validatedRuntimeOverride({ model: settings.rewriteSelectionModel, effort: settings.rewriteSelectionEffort }, models);
}
async function rewriteRuntimeForClient(client: AppServerClient, settings: RewriteRuntimeSettings): Promise<RewriteRuntime> {
const runtime = rewriteRuntime(settings);
async function selectionRewriteRuntimeForClient(
client: AppServerClient,
settings: SelectionRewriteRuntimeSettings,
): Promise<SelectionRewriteRuntime> {
const runtime = selectionRewriteRuntime(settings);
if (!runtime.model || !runtime.effort) return runtime;
try {
const response = await client.listModels(false);
return validatedRewriteRuntime(settings, response.data);
return validatedSelectionRewriteRuntime(settings, response.data);
} catch {
return runtime;
}

View file

@ -1,12 +1,12 @@
import { Plugin, type WorkspaceLeaf } from "obsidian";
import { VIEW_TYPE_CODEX_PANEL, VIEW_TYPE_CODEX_TURN_DIFF } from "./constants";
import { registerRewriteSelectionCommand } from "./features/selection-rewrite/command";
import { CodexPanelView } from "./features/chat/view";
import { CodexTurnDiffView } from "./features/chat/turn-diff-view";
import { registerSelectionRewriteCommand } from "./features/selection-rewrite/command";
import { CodexChatView } from "./features/chat/view";
import { CodexChatTurnDiffView } from "./features/chat/chat-turn-diff-view";
import { DEFAULT_SETTINGS, getVaultPath, normalizeSettings, settingsMatchNormalizedData, type CodexPanelSettings } from "./settings/model";
import { CodexPanelSettingTab } from "./settings/tab";
import { persistedTurnDiffViewState, type TurnDiffViewState } from "./features/chat/ui/turn-diff";
import { persistedChatTurnDiffViewState, type ChatTurnDiffViewState } from "./features/chat/ui/turn-diff";
export default class CodexPanelPlugin extends Plugin {
settings: CodexPanelSettings = DEFAULT_SETTINGS;
@ -16,8 +16,8 @@ export default class CodexPanelPlugin extends Plugin {
this.vaultPath = getVaultPath(this.app);
await this.loadSettings();
this.registerView(VIEW_TYPE_CODEX_PANEL, (leaf) => new CodexPanelView(leaf, this));
this.registerView(VIEW_TYPE_CODEX_TURN_DIFF, (leaf) => new CodexTurnDiffView(leaf));
this.registerView(VIEW_TYPE_CODEX_PANEL, (leaf) => new CodexChatView(leaf, this));
this.registerView(VIEW_TYPE_CODEX_TURN_DIFF, (leaf) => new CodexChatTurnDiffView(leaf));
this.addRibbonIcon("bot-message-square", "Open panel", () => {
void this.activateView();
@ -44,40 +44,40 @@ export default class CodexPanelPlugin extends Plugin {
},
});
registerRewriteSelectionCommand(this);
registerSelectionRewriteCommand(this);
this.addSettingTab(new CodexPanelSettingTab(this.app, this));
}
async activateView(): Promise<CodexPanelView> {
async activateView(): Promise<CodexChatView> {
const leaf = await this.app.workspace.ensureSideLeaf(VIEW_TYPE_CODEX_PANEL, "right", {
active: true,
reveal: true,
});
return leaf.view as CodexPanelView;
return leaf.view as CodexChatView;
}
async activateNewView(): Promise<CodexPanelView> {
async activateNewView(): Promise<CodexChatView> {
const leaf = this.createRightSidebarTab();
if (!leaf) throw new Error("Could not create a right sidebar leaf.");
await leaf.setViewState({ type: VIEW_TYPE_CODEX_PANEL, active: true });
await this.app.workspace.revealLeaf(leaf);
return leaf.view as CodexPanelView;
return leaf.view as CodexChatView;
}
async openThreadInNewView(threadId: string): Promise<CodexPanelView> {
async openThreadInNewView(threadId: string): Promise<CodexChatView> {
const view = await this.activateNewView();
await view.openThread(threadId);
return view;
}
async openTurnDiff(state: TurnDiffViewState): Promise<void> {
async openTurnDiff(state: ChatTurnDiffViewState): Promise<void> {
const existing = this.app.workspace.getLeavesOfType(VIEW_TYPE_CODEX_TURN_DIFF).at(0);
const leaf = existing ?? this.app.workspace.getLeaf("tab");
await leaf.setViewState({ type: VIEW_TYPE_CODEX_TURN_DIFF, active: true, state: { ...persistedTurnDiffViewState(state) } });
await leaf.setViewState({ type: VIEW_TYPE_CODEX_TURN_DIFF, active: true, state: { ...persistedChatTurnDiffViewState(state) } });
await leaf.loadIfDeferred();
if (leaf.view instanceof CodexTurnDiffView) {
if (leaf.view instanceof CodexChatTurnDiffView) {
leaf.view.setDiffPayload(state);
}
await this.app.workspace.revealLeaf(leaf);
@ -85,7 +85,7 @@ export default class CodexPanelPlugin extends Plugin {
refreshOpenViews(): void {
for (const leaf of this.app.workspace.getLeavesOfType(VIEW_TYPE_CODEX_PANEL)) {
if (leaf.view instanceof CodexPanelView) {
if (leaf.view instanceof CodexChatView) {
leaf.view.refreshSettings();
}
}
@ -93,7 +93,7 @@ export default class CodexPanelPlugin extends Plugin {
refreshOpenThreadLists(): void {
for (const leaf of this.app.workspace.getLeavesOfType(VIEW_TYPE_CODEX_PANEL)) {
if (leaf.view instanceof CodexPanelView) {
if (leaf.view instanceof CodexChatView) {
leaf.view.refreshThreadList();
}
}

View file

@ -1,6 +1,6 @@
.codex-panel,
.codex-panel-settings,
.codex-panel-rewrite-popover {
.codex-panel-selection-rewrite {
--codex-panel-top-icon-size: var(--icon-s, 16px);
--codex-panel-icon-button-inline-size: calc(var(--codex-panel-top-icon-size) + var(--size-2-3, 6px) * 2);
--codex-panel-icon-button-block-size: calc(var(--codex-panel-top-icon-size) + var(--size-2-2, 4px) * 2);
@ -51,7 +51,7 @@
color: var(--text-normal);
}
.workspace-leaf-content[data-type="codex-app-server-panel"] .view-content.codex-panel {
.workspace-leaf-content[data-type="codex-panel-view"] .view-content.codex-panel {
padding: 0;
overflow: hidden;
}
@ -696,7 +696,7 @@
.codex-panel__config-title,
.codex-panel__approval-details summary,
.codex-panel__output summary,
.codex-panel__diff-file summary,
.codex-panel-diff-file summary,
.codex-panel__output-title {
color: var(--text-muted);
font-size: var(--font-ui-small);
@ -704,7 +704,7 @@
.codex-panel__approval-details summary:hover,
.codex-panel__output summary:hover,
.codex-panel__diff-file summary:hover {
.codex-panel-diff-file summary:hover {
color: var(--text-normal);
}
@ -1309,10 +1309,10 @@
}
.codex-panel__tool-result > .codex-panel__output,
.codex-panel__tool-result > .codex-panel__diff-file,
.codex-panel__tool-result > .codex-panel-diff-file,
.codex-panel__tool-result > .codex-panel__meta-grid,
.codex-panel__tool-result-details > .codex-panel__output,
.codex-panel__tool-result-details > .codex-panel__diff-file,
.codex-panel__tool-result-details > .codex-panel-diff-file,
.codex-panel__tool-result-details > .codex-panel__meta-grid {
margin-left: 0;
}
@ -1395,7 +1395,7 @@
}
.codex-panel__output,
.codex-panel__diff-file {
.codex-panel-diff-file {
margin-top: 8px;
}
@ -1404,7 +1404,7 @@
}
.codex-panel__output pre,
.codex-panel__diff {
.codex-panel-diff {
max-height: 420px;
overflow: auto;
margin: 6px 0 0;
@ -1422,41 +1422,41 @@
white-space: pre-wrap;
}
.codex-panel__diff {
.codex-panel-diff {
white-space: pre;
}
.codex-panel__diff-line {
.codex-panel-diff__line {
display: block;
border-left: 2px solid transparent;
padding-left: var(--size-2-3, 6px);
min-height: 1.35em;
}
.codex-panel__diff-line--added {
.codex-panel-diff__line--added {
border-left-color: var(--text-success);
color: var(--text-normal);
}
.codex-panel__diff-line--removed {
.codex-panel-diff__line--removed {
border-left-color: var(--text-error);
color: var(--text-normal);
}
.codex-panel__diff-line--hunk {
.codex-panel-diff__line--hunk {
color: var(--text-accent);
}
.codex-panel__diff-line--file {
.codex-panel-diff__line--file {
color: var(--text-normal);
font-weight: var(--font-semibold);
}
.codex-panel__diff-line--context {
.codex-panel-diff__line--context {
color: var(--text-muted);
}
.codex-panel-turn-diff {
.codex-panel-chat-turn-diff {
display: flex;
flex-direction: column;
gap: var(--size-4-3, 12px);
@ -1465,51 +1465,51 @@
overflow: auto;
}
.codex-panel-turn-diff__header {
.codex-panel-chat-turn-diff__header {
display: flex;
align-items: flex-start;
gap: var(--size-4-2, 8px);
}
.codex-panel-turn-diff__title-block {
.codex-panel-chat-turn-diff__title-block {
flex: 1 1 auto;
min-width: 0;
}
.codex-panel-turn-diff__title {
.codex-panel-chat-turn-diff__title {
color: var(--text-normal);
font-size: var(--font-ui-medium);
font-weight: var(--font-semibold);
line-height: var(--line-height-tight);
}
.codex-panel-turn-diff__meta,
.codex-panel-turn-diff__files,
.codex-panel-turn-diff__empty {
.codex-panel-chat-turn-diff__meta,
.codex-panel-chat-turn-diff__files,
.codex-panel-chat-turn-diff__empty {
color: var(--text-muted);
font-size: var(--font-ui-small);
}
.codex-panel-turn-diff__copy {
.codex-panel-chat-turn-diff__copy {
flex: 0 0 auto;
}
.codex-panel-turn-diff__files {
.codex-panel-chat-turn-diff__files {
margin: 0;
}
.codex-panel-turn-diff__files ul {
.codex-panel-chat-turn-diff__files ul {
margin-block: var(--size-2-2, 4px) 0;
}
.codex-panel-turn-diff__diff.codex-panel__diff {
.codex-panel-chat-turn-diff__diff.codex-panel-diff {
max-height: none;
margin: 0;
overflow: visible;
white-space: pre-wrap;
}
.codex-panel-rewrite-popover {
.codex-panel-selection-rewrite {
position: fixed;
z-index: var(--layer-popover, 30);
box-sizing: border-box;
@ -1527,59 +1527,59 @@
color: var(--text-normal);
}
.codex-panel-rewrite-popover__prompt-row {
.codex-panel-selection-rewrite__prompt-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--codex-panel-section-gap);
align-items: stretch;
}
.codex-panel-rewrite-popover__result-row {
.codex-panel-selection-rewrite__result-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--codex-panel-section-gap);
align-items: start;
}
.codex-panel-rewrite-popover__result-row.is-hidden,
.codex-panel-rewrite-popover__diff:empty {
.codex-panel-selection-rewrite__result-row.is-hidden,
.codex-panel-selection-rewrite__diff:empty {
display: none;
}
.codex-panel-rewrite-popover__icon-button.is-hidden {
.codex-panel-selection-rewrite__icon-button.is-hidden {
display: none;
}
.codex-panel-rewrite-popover__status {
.codex-panel-selection-rewrite__status {
color: var(--text-muted);
font-size: var(--font-ui-small);
}
.codex-panel-rewrite-popover__status:empty {
.codex-panel-selection-rewrite__status:empty {
display: none;
}
.codex-panel-rewrite-popover__status.is-active {
.codex-panel-selection-rewrite__status.is-active {
color: var(--text-faint);
}
.codex-panel-rewrite-popover__status-dots span {
.codex-panel-selection-rewrite__status-dots span {
opacity: 0.35;
}
.codex-panel-rewrite-popover__status.is-active .codex-panel-rewrite-popover__status-dots span {
.codex-panel-selection-rewrite__status.is-active .codex-panel-selection-rewrite__status-dots span {
animation: codex-panel-reasoning-dot 1.2s infinite ease-in-out;
}
.codex-panel-rewrite-popover__status.is-active .codex-panel-rewrite-popover__status-dots span:nth-child(2) {
.codex-panel-selection-rewrite__status.is-active .codex-panel-selection-rewrite__status-dots span:nth-child(2) {
animation-delay: 0.18s;
}
.codex-panel-rewrite-popover__status.is-active .codex-panel-rewrite-popover__status-dots span:nth-child(3) {
.codex-panel-selection-rewrite__status.is-active .codex-panel-selection-rewrite__status-dots span:nth-child(3) {
animation-delay: 0.36s;
}
.codex-panel-rewrite-popover__stream-preview {
.codex-panel-selection-rewrite__stream-preview {
box-sizing: border-box;
max-height: min(180px, 28vh);
margin: 0;
@ -1592,11 +1592,11 @@
white-space: pre-wrap;
}
.codex-panel-rewrite-popover__stream-preview.is-hidden {
.codex-panel-selection-rewrite__stream-preview.is-hidden {
display: none;
}
.codex-panel-rewrite-popover__diff-body {
.codex-panel-selection-rewrite__diff-body {
box-sizing: border-box;
max-height: min(300px, 38vh);
width: 100%;
@ -1606,16 +1606,16 @@
white-space: pre-wrap;
}
.codex-panel-rewrite-popover__debug {
.codex-panel-selection-rewrite__debug {
color: var(--text-muted);
font-size: var(--font-ui-small);
}
.codex-panel-rewrite-popover__debug.is-hidden {
.codex-panel-selection-rewrite__debug.is-hidden {
display: none;
}
.codex-panel-rewrite-popover__debug pre {
.codex-panel-selection-rewrite__debug pre {
max-height: 160px;
overflow: auto;
margin: var(--size-2-3, 6px) 0 0;

View file

@ -1,15 +1,15 @@
import { describe, expect, it } from "vitest";
import type { Thread } from "../../src/generated/app-server/v2/Thread";
import type { ThreadItem } from "../../src/generated/app-server/v2/ThreadItem";
import type { Turn } from "../../src/generated/app-server/v2/Turn";
import type { Thread } from "../../../src/generated/app-server/v2/Thread";
import type { ThreadItem } from "../../../src/generated/app-server/v2/ThreadItem";
import type { Turn } from "../../../src/generated/app-server/v2/Turn";
import {
exportArchivedThreadMarkdown,
markdownFromThread,
normalizedArchiveTags,
type ArchiveExportAdapter,
} from "../../src/domain/threads/export";
import { referencedThreadPrompt } from "../../src/domain/threads/reference";
} from "../../../src/domain/threads/export";
import { referencedThreadPrompt } from "../../../src/domain/threads/reference";
describe("thread archive export", () => {
it("writes frontmatter and readable user/codex turns with turn timestamps", () => {

View file

@ -1,8 +1,8 @@
import { describe, expect, it } from "vitest";
import type { Thread } from "../../src/generated/app-server/v2/Thread";
import type { Turn } from "../../src/generated/app-server/v2/Turn";
import { referencedThreadDisplayFromPrompt, referencedThreadPrompt, referencedThreadTurns } from "../../src/domain/threads/reference";
import type { Thread } from "../../../src/generated/app-server/v2/Thread";
import type { Turn } from "../../../src/generated/app-server/v2/Turn";
import { referencedThreadDisplayFromPrompt, referencedThreadPrompt, referencedThreadTurns } from "../../../src/domain/threads/reference";
function thread(overrides: Partial<Thread> = {}): Thread {
return {

View file

@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import type { Thread } from "../../src/generated/app-server/v2/Thread";
import { codexPanelDisplayTitle, inheritedForkThreadName, upsertThread } from "../../src/domain/threads/model";
import type { Thread } from "../../../src/generated/app-server/v2/Thread";
import { codexPanelDisplayTitle, inheritedForkThreadName, upsertThread } from "../../../src/domain/threads/model";
describe("thread helpers", () => {
it("formats Codex panel display titles from the active thread", () => {

View file

@ -1,196 +0,0 @@
import { describe, expect, it } from "vitest";
import { buildSelectionUnifiedDiff } from "../../src/features/selection-rewrite/diff";
import { isRewriteActionKey, isRewriteGenerateKey, type RewriteGenerateKeyEvent } from "../../src/features/selection-rewrite/keys";
import { canApplyRewrite, type RewriteSession } from "../../src/features/selection-rewrite/model";
import { parseRewriteOutput, rewriteOutputFromTurn, rewriteOutputParseResultFromTurn } from "../../src/features/selection-rewrite/output";
import { buildRewritePrompt } from "../../src/features/selection-rewrite/prompt";
import { rewriteRuntime, validatedRewriteRuntime } from "../../src/features/selection-rewrite/runner";
import type { Model } from "../../src/generated/app-server/v2/Model";
import type { Turn } from "../../src/generated/app-server/v2/Turn";
describe("editor rewrite output", () => {
it("parses valid rewrite JSON", () => {
expect(parseRewriteOutput('{"replacementText":"rewritten"}')).toEqual({ replacementText: "rewritten" });
});
it("rejects invalid rewrite JSON", () => {
expect(parseRewriteOutput("replacementText: rewritten")).toBeNull();
expect(parseRewriteOutput('{"replacementText":42}')).toBeNull();
expect(parseRewriteOutput('{"text":"rewritten"}')).toBeNull();
});
it("extracts the final rewrite JSON from a completed turn", () => {
expect(
rewriteOutputFromTurn(
turn([
{ type: "agentMessage", id: "a1", text: '{"replacementText":"first"}', phase: "final_answer", memoryCitation: null },
{ type: "agentMessage", id: "a2", text: '{"replacementText":"final"}', phase: "final_answer", memoryCitation: null },
]),
),
).toEqual({ replacementText: "final" });
});
it("keeps raw model text when rewrite output parsing fails", () => {
expect(
rewriteOutputParseResultFromTurn(
turn([{ type: "agentMessage", id: "a1", text: "replacementText: final", phase: "final_answer", memoryCitation: null }]),
),
).toEqual({ output: null, rawText: "replacementText: final" });
});
});
describe("editor rewrite prompt", () => {
it("always includes note context with the selected text", () => {
const prompt = buildRewritePrompt(session());
expect(prompt).toContain("Context mode: Selection + note context");
expect(prompt).toContain("Current note context:");
expect(prompt).toContain("# Heading");
});
it("uses fences that cannot be closed by note code blocks", () => {
const prompt = buildRewritePrompt(
session({
originalText: "```ts\nconst value = 1;\n```",
noteText: "````markdown\n```ts\nconst value = 1;\n```\n````",
}),
);
expect(prompt).toContain("````text\n```ts");
expect(prompt).toContain("`````text\n````markdown");
});
});
describe("editor rewrite diff", () => {
it("renders replacements in a selection-scoped unified diff", () => {
const diff = buildSelectionUnifiedDiff("Note.md", "alpha\nbeta", "alpha\ngamma");
expect(diff).toContain("diff --git a/Note.md b/Note.md");
expect(diff).toContain("@@ -1,2 +1,2 @@");
expect(diff).toContain(" alpha");
expect(diff).toContain("-beta");
expect(diff).toContain("+gamma");
});
it("renders additions and deletions", () => {
expect(buildSelectionUnifiedDiff("Note.md", "", "added")).toContain("+added");
expect(buildSelectionUnifiedDiff("Note.md", "removed", "")).toContain("-removed");
});
it("renders unchanged text as context", () => {
expect(buildSelectionUnifiedDiff("Note.md", "same", "same")).toContain(" same");
});
});
describe("editor rewrite apply guard", () => {
it("allows apply only when the current range still matches the original text", () => {
expect(canApplyRewrite("original", "original")).toBe(true);
expect(canApplyRewrite("changed", "original")).toBe(false);
});
});
describe("editor rewrite runtime", () => {
it("uses explicit rewrite runtime settings", () => {
expect(rewriteRuntime({ rewriteSelectionModel: "gpt-5.4-mini", rewriteSelectionEffort: "minimal" })).toEqual({
model: "gpt-5.4-mini",
effort: "minimal",
});
});
it("omits rewrite runtime overrides that are set to Codex default", () => {
expect(rewriteRuntime({ rewriteSelectionModel: null, rewriteSelectionEffort: null })).toEqual({});
});
it("omits an explicit rewrite effort when the selected model does not support it", () => {
expect(
validatedRewriteRuntime({ rewriteSelectionModel: "gpt-5.4-mini", rewriteSelectionEffort: "minimal" }, [
model("gpt-5.4-mini", ["low", "medium", "high", "xhigh"]),
]),
).toEqual({ model: "gpt-5.4-mini" });
});
});
describe("editor rewrite keys", () => {
const baseEvent: RewriteGenerateKeyEvent = {
key: "Enter",
shiftKey: false,
metaKey: false,
ctrlKey: false,
altKey: false,
isComposing: false,
};
it("generates on plain Enter in Enter mode", () => {
expect(isRewriteGenerateKey(baseEvent, "enter")).toBe(true);
expect(isRewriteGenerateKey({ ...baseEvent, shiftKey: true }, "enter")).toBe(false);
expect(isRewriteGenerateKey({ ...baseEvent, metaKey: true }, "enter")).toBe(false);
});
it("generates on Cmd/Ctrl+Enter in mod-enter mode", () => {
expect(isRewriteGenerateKey({ ...baseEvent, metaKey: true }, "mod-enter")).toBe(true);
expect(isRewriteGenerateKey({ ...baseEvent, ctrlKey: true }, "mod-enter")).toBe(true);
expect(isRewriteGenerateKey(baseEvent, "mod-enter")).toBe(false);
});
it("treats plain Enter and Cmd/Ctrl+Enter as preview action keys", () => {
expect(isRewriteActionKey(baseEvent)).toBe(true);
expect(isRewriteActionKey({ ...baseEvent, metaKey: true })).toBe(true);
expect(isRewriteActionKey({ ...baseEvent, ctrlKey: true })).toBe(true);
expect(isRewriteActionKey({ ...baseEvent, shiftKey: true })).toBe(false);
expect(isRewriteActionKey({ ...baseEvent, isComposing: true })).toBe(false);
});
});
function session(overrides: Partial<RewriteSession> = {}): RewriteSession {
return {
filePath: "Note.md",
targetRange: {
from: { line: 1, ch: 0 },
to: { line: 1, ch: 22 },
},
originalText: "Rewrite this sentence.",
noteText: "# Heading\n\nRewrite this sentence.\n\nNext paragraph.",
instruction: "Make it clearer.",
status: "editing-prompt",
streamText: "",
replacementText: null,
debugText: null,
...overrides,
};
}
function turn(items: Turn["items"], overrides: Partial<Turn> = {}): Turn {
return {
id: "turn",
items,
itemsView: "full",
status: "completed",
error: null,
startedAt: null,
completedAt: null,
durationMs: null,
...overrides,
};
}
function model(name: string, efforts: Model["supportedReasoningEfforts"][number]["reasoningEffort"][]): Model {
return {
id: name,
model: name,
upgrade: null,
upgradeInfo: null,
availabilityNux: null,
displayName: name,
description: "",
hidden: false,
supportedReasoningEfforts: efforts.map((reasoningEffort) => ({ reasoningEffort, description: "" })),
defaultReasoningEffort: efforts[0] ?? "low",
inputModalities: ["text"],
supportsPersonality: false,
additionalSpeedTiers: [],
serviceTiers: [],
defaultServiceTier: null,
isDefault: false,
};
}

View file

@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { classifyAppServerLog } from "../../src/features/chat/app-server-logs";
import { classifyAppServerLog } from "../../../src/features/chat/app-server-logs";
describe("app-server log classification", () => {
it("suppresses raw MCP token refresh stderr", () => {

View file

@ -7,9 +7,9 @@ import {
approvalSummary,
approvalTitle,
toPendingApproval,
} from "../../src/features/chat/approvals/model";
import type { ServerRequest } from "../../src/generated/app-server/ServerRequest";
import type { CommandExecutionApprovalDecision } from "../../src/generated/app-server/v2/CommandExecutionApprovalDecision";
} from "../../../../src/features/chat/approvals/model";
import type { ServerRequest } from "../../../../src/generated/app-server/ServerRequest";
import type { CommandExecutionApprovalDecision } from "../../../../src/generated/app-server/v2/CommandExecutionApprovalDecision";
function expectPresent<T>(value: T | null | undefined): T {
if (value === null || value === undefined) throw new Error("Expected value to be present");

View file

@ -1,18 +1,18 @@
import { describe, expect, it, vi } from "vitest";
import { PanelController } from "../../src/features/chat/controller";
import { attachHookRunsToTurn } from "../../src/features/chat/hook-display";
import { createPanelState } from "../../src/features/chat/state";
import type { ServerNotification } from "../../src/generated/app-server/ServerNotification";
import type { ServerRequest } from "../../src/generated/app-server/ServerRequest";
import type { Thread } from "../../src/generated/app-server/v2/Thread";
import type { Turn } from "../../src/generated/app-server/v2/Turn";
import { ChatController } from "../../../src/features/chat/chat-controller";
import { attachHookRunsToTurn } from "../../../src/features/chat/hook-display";
import { createChatState } from "../../../src/features/chat/chat-state";
import type { ServerNotification } from "../../../src/generated/app-server/ServerNotification";
import type { ServerRequest } from "../../../src/generated/app-server/ServerRequest";
import type { Thread } from "../../../src/generated/app-server/v2/Thread";
import type { Turn } from "../../../src/generated/app-server/v2/Turn";
function controllerForState(
state = createPanelState(),
actions: Partial<ConstructorParameters<typeof PanelController>[1]> = {},
): PanelController {
return new PanelController(state, {
state = createChatState(),
actions: Partial<ConstructorParameters<typeof ChatController>[1]> = {},
): ChatController {
return new ChatController(state, {
refreshThreads: vi.fn(),
refreshSkills: vi.fn(),
maybeNameThread: vi.fn(),
@ -28,10 +28,10 @@ function expectPresent<T>(value: T | null | undefined): T {
return value;
}
describe("PanelController", () => {
describe("ChatController", () => {
describe("active turn routing", () => {
it("applies matching streaming deltas as assistant markdown", () => {
const state = createPanelState();
const state = createChatState();
state.activeThreadId = "thread-active";
state.activeTurnId = "turn-active";
const controller = controllerForState(state);
@ -45,7 +45,7 @@ describe("PanelController", () => {
});
it("marks active reasoning completed when assistant text starts", () => {
const state = createPanelState();
const state = createChatState();
state.activeThreadId = "thread-active";
state.activeTurnId = "turn-active";
state.busy = true;
@ -66,7 +66,7 @@ describe("PanelController", () => {
});
it("streams plan deltas as plain assistant text until completion", () => {
const state = createPanelState();
const state = createChatState();
state.activeThreadId = "thread-active";
state.activeTurnId = "turn-active";
const controller = controllerForState(state);
@ -80,7 +80,7 @@ describe("PanelController", () => {
});
it("updates structured turn plan progress", () => {
const state = createPanelState();
const state = createChatState();
state.activeThreadId = "thread-active";
state.activeTurnId = "turn-active";
const controller = controllerForState(state);
@ -110,7 +110,7 @@ describe("PanelController", () => {
describe("app-server source of truth updates", () => {
it("refreshes skills from disk when app-server reports skill changes", () => {
const refreshSkills = vi.fn();
const controller = controllerForState(createPanelState(), { refreshSkills });
const controller = controllerForState(createChatState(), { refreshSkills });
controller.handleNotification({
method: "skills/changed",
@ -121,7 +121,7 @@ describe("PanelController", () => {
});
it("stores the latest aggregated turn diff for the active turn", () => {
const state = createPanelState();
const state = createChatState();
state.activeThreadId = "thread-active";
state.activeTurnId = "turn-active";
const controller = controllerForState(state);
@ -139,7 +139,7 @@ describe("PanelController", () => {
});
it("ignores aggregated turn diffs outside the active scope", () => {
const state = createPanelState();
const state = createChatState();
state.activeThreadId = "thread-active";
state.activeTurnId = "turn-active";
const controller = controllerForState(state);
@ -157,7 +157,7 @@ describe("PanelController", () => {
});
it("formats hook runs as compact summaries with details", () => {
const state = createPanelState();
const state = createChatState();
state.activeThreadId = "thread-active";
state.activeTurnId = "turn-active";
const controller = controllerForState(state);
@ -210,7 +210,7 @@ describe("PanelController", () => {
});
it("omits hook duration details while duration is unavailable", () => {
const state = createPanelState();
const state = createChatState();
state.activeThreadId = "thread-active";
state.activeTurnId = "turn-active";
const controller = controllerForState(state);
@ -253,7 +253,7 @@ describe("PanelController", () => {
});
it("attaches unscoped hook runs to the active turn while streaming", () => {
const state = createPanelState();
const state = createChatState();
state.activeThreadId = "thread-active";
state.activeTurnId = "turn-active";
state.busy = true;
@ -287,7 +287,7 @@ describe("PanelController", () => {
});
it("leaves non-prompt unscoped hook runs outside the active turn", () => {
const state = createPanelState();
const state = createChatState();
state.activeThreadId = "thread-active";
state.activeTurnId = "turn-active";
state.busy = true;
@ -322,7 +322,7 @@ describe("PanelController", () => {
});
it("keeps repeated hook runs with the same run id as separate display items", () => {
const state = createPanelState();
const state = createChatState();
state.activeThreadId = "thread-active";
state.activeTurnId = "turn-active";
const controller = controllerForState(state);
@ -356,7 +356,7 @@ describe("PanelController", () => {
});
it("attaches pre-turn prompt submit hook runs when the turn starts", () => {
const state = createPanelState();
const state = createChatState();
state.activeThreadId = "thread-active";
state.pendingTurnStart = { anchorItemId: "local-user-1", promptSubmitHookItemIds: ["hook-hook-1-1"] };
state.displayItems = [
@ -426,7 +426,7 @@ describe("PanelController", () => {
});
it("captures only prompt-submit hooks observed during the pending turn start", () => {
const state = createPanelState();
const state = createChatState();
state.activeThreadId = "thread-active";
state.pendingTurnStart = { anchorItemId: "local-user-1", promptSubmitHookItemIds: [] };
const controller = controllerForState(state);
@ -461,7 +461,7 @@ describe("PanelController", () => {
});
it("keeps pre-turn prompt submit hooks through turn start and completed-turn reconciliation", () => {
const state = createPanelState();
const state = createChatState();
state.activeThreadId = "thread-active";
state.pendingTurnStart = { anchorItemId: "local-user-1", promptSubmitHookItemIds: [] };
state.displayItems = [{ id: "local-user-1", kind: "message", role: "user", text: "hello", markdown: true }];
@ -530,7 +530,7 @@ describe("PanelController", () => {
});
it("stores account rate limit updates outside thread scope", () => {
const state = createPanelState();
const state = createChatState();
const controller = controllerForState(state);
controller.handleNotification({
@ -555,7 +555,7 @@ describe("PanelController", () => {
});
it("records MCP startup status for diagnostics without a chat system message", () => {
const state = createPanelState();
const state = createChatState();
const recordMcpStartupStatus = vi.fn();
const controller = controllerForState(state, { recordMcpStartupStatus });
@ -575,7 +575,7 @@ describe("PanelController", () => {
describe("interactive server requests", () => {
it("queues and resolves requestUserInput server requests", () => {
const state = createPanelState();
const state = createChatState();
const respondToServerRequest = vi.fn(() => true);
const controller = controllerForState(state, { respondToServerRequest });
@ -613,7 +613,7 @@ describe("PanelController", () => {
});
it("rejects cancelled requestUserInput server requests", () => {
const state = createPanelState();
const state = createChatState();
const rejectServerRequest = vi.fn(() => true);
const controller = controllerForState(state, { rejectServerRequest });
@ -640,7 +640,7 @@ describe("PanelController", () => {
});
it("records manual permission approvals as colored result items", () => {
const state = createPanelState();
const state = createChatState();
const respondToServerRequest = vi.fn(() => true);
const controller = controllerForState(state, { respondToServerRequest });
@ -674,7 +674,7 @@ describe("PanelController", () => {
});
it("handles known server request families and rejects unsupported requests by default", () => {
const state = createPanelState();
const state = createChatState();
const rejectServerRequest = vi.fn(() => true);
const controller = controllerForState(state, { rejectServerRequest });
@ -718,7 +718,7 @@ describe("PanelController", () => {
});
it("rejects server requests scoped to a different active thread or turn", () => {
const state = createPanelState();
const state = createChatState();
state.activeThreadId = "thread-active";
state.activeTurnId = "turn-active";
const rejectServerRequest = vi.fn(() => true);
@ -762,7 +762,7 @@ describe("PanelController", () => {
});
it("keeps pending requests when response delivery fails", () => {
const state = createPanelState();
const state = createChatState();
const respondToServerRequest = vi.fn(() => false);
const controller = controllerForState(state, { respondToServerRequest });
@ -776,7 +776,7 @@ describe("PanelController", () => {
});
it("clears pending request state when app-server resolves a request", () => {
const state = createPanelState();
const state = createChatState();
state.activeThreadId = "thread-active";
state.approvals = [
{
@ -816,7 +816,7 @@ describe("PanelController", () => {
describe("thread lifecycle and reconciliation", () => {
it("keeps user-visible app-server notices in the message stream", () => {
const state = createPanelState();
const state = createChatState();
const controller = controllerForState(state);
controller.handleNotification({
@ -833,7 +833,7 @@ describe("PanelController", () => {
});
it("clears all active-thread scoped state when the active thread is archived", () => {
const state = createPanelState();
const state = createChatState();
state.activeThreadId = "thread-active";
state.activeTurnId = "turn-active";
state.activeModel = "gpt-5.5";
@ -898,7 +898,7 @@ describe("PanelController", () => {
});
it("does not replace the active cwd from unrelated thread-started notifications", () => {
const state = createPanelState();
const state = createChatState();
state.activeThreadId = "thread-active";
state.activeThreadCwd = "/workspace/active";
const controller = controllerForState(state);
@ -912,7 +912,7 @@ describe("PanelController", () => {
});
it("records cwd from active thread-started notifications", () => {
const state = createPanelState();
const state = createChatState();
state.activeThreadId = "thread-active";
const controller = controllerForState(state);
@ -925,7 +925,7 @@ describe("PanelController", () => {
});
it("replaces optimistic user echoes when completed turns are reconciled", () => {
const state = createPanelState();
const state = createChatState();
state.activeThreadId = "thread-active";
state.activeTurnId = "turn-active";
state.displayItems = [
@ -970,7 +970,7 @@ describe("PanelController", () => {
});
it("asks the view to auto-name completed turns", () => {
const state = createPanelState();
const state = createChatState();
state.activeThreadId = "thread-active";
state.activeTurnId = "turn-active";
const maybeNameThread = vi.fn();
@ -998,7 +998,7 @@ describe("PanelController", () => {
});
it("updates listed thread names from thread name notifications", () => {
const state = createPanelState();
const state = createChatState();
state.activeThreadId = "thread-active";
state.listedThreads = [thread("thread-active", "/workspace/active")];
const refreshThreads = vi.fn();
@ -1014,7 +1014,7 @@ describe("PanelController", () => {
});
it("syncs active runtime state from thread settings notifications", () => {
const state = createPanelState();
const state = createChatState();
state.activeThreadId = "thread-active";
const controller = controllerForState(state);
@ -1050,7 +1050,7 @@ describe("PanelController", () => {
});
it("syncs null service tier from settings notifications", () => {
const state = createPanelState();
const state = createChatState();
state.activeThreadId = "thread-active";
state.activeServiceTier = "standard";
const controller = controllerForState(state);
@ -1114,7 +1114,7 @@ describe("PanelController", () => {
];
for (const { notification, text } of cases) {
const state = createPanelState();
const state = createChatState();
state.activeThreadId = "thread-active";
const controller = controllerForState(state);
@ -1127,7 +1127,7 @@ describe("PanelController", () => {
describe("auto-review display", () => {
it("renders guardian warnings as review results instead of system messages", () => {
const state = createPanelState();
const state = createChatState();
state.activeThreadId = "thread-active";
const controller = controllerForState(state);
@ -1147,7 +1147,7 @@ describe("PanelController", () => {
});
it("renders auto approval review notifications as upserted review results", () => {
const state = createPanelState();
const state = createChatState();
state.activeThreadId = "thread-active";
state.activeTurnId = "turn-active";
const controller = controllerForState(state);
@ -1194,7 +1194,7 @@ describe("PanelController", () => {
});
it("replaces guardian auto-review warnings when structured auto-review notifications arrive", () => {
const state = createPanelState();
const state = createChatState();
state.activeThreadId = "thread-active";
state.activeTurnId = "turn-active";
const controller = controllerForState(state);
@ -1228,7 +1228,7 @@ describe("PanelController", () => {
});
it("ignores guardian auto-review warnings after structured auto-review notifications", () => {
const state = createPanelState();
const state = createChatState();
state.activeThreadId = "thread-active";
state.activeTurnId = "turn-active";
const controller = controllerForState(state);

View file

@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { isComposerSendKey, type ComposerSendKeyEvent } from "../../src/features/chat/composer/keys";
import { isComposerSendKey, type ComposerSendKeyEvent } from "../../../../src/features/chat/composer/keys";
const baseEvent: ComposerSendKeyEvent = {
key: "Enter",

View file

@ -1,8 +1,8 @@
import { describe, expect, it } from "vitest";
import type { ReasoningEffort } from "../../src/generated/app-server/ReasoningEffort";
import type { Model } from "../../src/generated/app-server/v2/Model";
import type { Thread } from "../../src/generated/app-server/v2/Thread";
import type { ReasoningEffort } from "../../../../src/generated/app-server/ReasoningEffort";
import type { Model } from "../../../../src/generated/app-server/v2/Model";
import type { Thread } from "../../../../src/generated/app-server/v2/Thread";
import {
activeComposerSuggestions,
applyComposerSuggestionInsertion,
@ -11,7 +11,7 @@ import {
findWikiLinkSuggestions,
nextComposerSuggestionIndex,
parseSlashCommand,
} from "../../src/features/chat/composer/suggestions";
} from "../../../../src/features/chat/composer/suggestions";
function expectPresent<T>(value: T | null | undefined): T {
if (value === null || value === undefined) throw new Error("Expected value to be present");

View file

@ -1,7 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import { TFile, type App } from "obsidian";
import { noteCandidates, resolveWikiLinkMention } from "../../src/features/chat/composer/obsidian-context";
import { noteCandidates, resolveWikiLinkMention } from "../../../../src/features/chat/composer/obsidian-context";
describe("Obsidian composer context", () => {
it("builds note candidates from markdown files", () => {

View file

@ -1,8 +1,8 @@
import { describe, expect, it, vi } from "vitest";
import { slashCommandHelpLines, slashCommandHelpRows } from "../../src/features/chat/composer/slash-commands";
import type { Thread } from "../../src/generated/app-server/v2/Thread";
import { executeSlashCommand, type SlashCommandExecutionContext } from "../../src/features/chat/slash-commands";
import { slashCommandHelpLines, slashCommandHelpRows } from "../../../../src/features/chat/composer/slash-commands";
import type { Thread } from "../../../../src/generated/app-server/v2/Thread";
import { executeSlashCommand, type SlashCommandExecutionContext } from "../../../../src/features/chat/slash-commands";
function context(overrides: Partial<SlashCommandExecutionContext> = {}): SlashCommandExecutionContext {
return {

View file

@ -5,7 +5,7 @@ import {
parsedWikiLinks,
userInputWithWikiLinkMentions,
userInputWithWikiLinkMentionsAndSkills,
} from "../../src/features/chat/composer/wikilink-context";
} from "../../../../src/features/chat/composer/wikilink-context";
describe("wikilink context", () => {
it("parses aliases, subpaths, and duplicate links", () => {

View file

@ -5,8 +5,8 @@ import {
capabilityProbeOk,
createAppServerDiagnostics,
upsertMcpServerDiagnostic,
} from "../../src/app-server/compatibility";
import { connectionDiagnosticSections, diagnosticAlertLevel } from "../../src/features/chat/diagnostics";
} from "../../../src/app-server/compatibility";
import { connectionDiagnosticSections, diagnosticAlertLevel } from "../../../src/features/chat/diagnostics";
describe("connection diagnostics", () => {
it("formats base rows, capability probes, and MCP issues for /doctor", () => {

View file

@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { activeAgentRunSummary } from "../../src/features/chat/display/agent";
import { displayBlocksForItems } from "../../src/features/chat/display/blocks";
import { activeAgentRunSummary } from "../../../../src/features/chat/display/agent";
import { displayBlocksForItems } from "../../../../src/features/chat/display/blocks";
import {
appendAssistantDelta,
appendItemOutput,
@ -9,15 +9,15 @@ import {
appendPlanDelta,
appendToolOutput,
upsertDisplayItem,
} from "../../src/features/chat/display/stream-updates";
import { normalizeProposedPlanMarkdown, planProgressDisplayItem } from "../../src/features/chat/display/plan";
import { pathRelativeToRoot } from "../../src/features/chat/display/paths";
import { createAutoReviewResultItem, createReviewResultItem } from "../../src/features/chat/display/review";
import { executionState } from "../../src/features/chat/display/state";
import { displayItemFromThreadItem, displayItemsFromTurns } from "../../src/features/chat/display/thread-items";
import type { DisplayItem } from "../../src/features/chat/display/types";
import type { ThreadItem } from "../../src/generated/app-server/v2/ThreadItem";
import type { Turn } from "../../src/generated/app-server/v2/Turn";
} from "../../../../src/features/chat/display/stream-updates";
import { normalizeProposedPlanMarkdown, planProgressDisplayItem } from "../../../../src/features/chat/display/plan";
import { pathRelativeToRoot } from "../../../../src/features/chat/display/paths";
import { createAutoReviewResultItem, createReviewResultItem } from "../../../../src/features/chat/display/review";
import { executionState } from "../../../../src/features/chat/display/state";
import { displayItemFromThreadItem, displayItemsFromTurns } from "../../../../src/features/chat/display/thread-items";
import type { DisplayItem } from "../../../../src/features/chat/display/types";
import type { ThreadItem } from "../../../../src/generated/app-server/v2/ThreadItem";
import type { Turn } from "../../../../src/generated/app-server/v2/Turn";
function expectPresent<T>(value: T | null | undefined): T {
if (value === null || value === undefined) throw new Error("Expected value to be present");

View file

@ -6,13 +6,13 @@ import {
messageTurnId,
routeServerNotification,
routeServerRequest,
} from "../../src/features/chat/inbound-routing";
import type { ServerNotification } from "../../src/generated/app-server/ServerNotification";
import type { ServerRequest } from "../../src/generated/app-server/ServerRequest";
} from "../../../src/features/chat/inbound-routing";
import type { ServerNotification } from "../../../src/generated/app-server/ServerNotification";
import type { ServerRequest } from "../../../src/generated/app-server/ServerRequest";
const activeScope = { activeThreadId: "thread-active", activeTurnId: "turn-active" };
describe("panel inbound routing", () => {
describe("chat inbound routing", () => {
it("extracts thread and turn ids from app-server messages", () => {
const notification = {
method: "turn/completed",

View file

@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { TFile, type App } from "obsidian";
import { markdownFileLinkTarget } from "../../src/features/chat/markdown-file-links";
import { markdownFileLinkTarget } from "../../../src/features/chat/markdown-file-links";
describe("markdown file links", () => {
it("resolves absolute vault paths", () => {

View file

@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import type { McpServerStatus } from "../../src/generated/app-server/v2/McpServerStatus";
import { mcpStatusLines } from "../../src/features/chat/mcp-status";
import type { McpServerStatus } from "../../../src/generated/app-server/v2/McpServerStatus";
import { mcpStatusLines } from "../../../src/features/chat/mcp-status";
function mcpServer(overrides: Partial<McpServerStatus> = {}): McpServerStatus {
return {

View file

@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { isRollbackCandidateItem, rollbackCandidateFromItems } from "../../src/features/chat/rollback";
import type { DisplayItem } from "../../src/features/chat/display/types";
import { isRollbackCandidateItem, rollbackCandidateFromItems } from "../../../src/features/chat/rollback";
import type { DisplayItem } from "../../../src/features/chat/display/types";
function expectPresent<T>(value: T | null | undefined): T {
if (value === null || value === undefined) throw new Error("Expected value to be present");

View file

@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { statusValue, usageLimitStatusLines } from "../../src/features/chat/status-lines";
import type { RateLimitSummary } from "../../src/runtime/view";
import { statusValue, usageLimitStatusLines } from "../../../src/features/chat/status-lines";
import type { RateLimitSummary } from "../../../src/runtime/view";
describe("status line helpers", () => {
it("formats primitive and structured diagnostic values", () => {

View file

@ -10,9 +10,9 @@ import {
normalizeGeneratedTitle,
titleFromNamingTurn,
validatedNamingRuntime,
} from "../../src/features/chat/thread-naming";
import type { Model } from "../../src/generated/app-server/v2/Model";
import type { Turn } from "../../src/generated/app-server/v2/Turn";
} from "../../../src/features/chat/thread-naming";
import type { Model } from "../../../src/generated/app-server/v2/Model";
import type { Turn } from "../../../src/generated/app-server/v2/Turn";
describe("thread naming", () => {
it("extracts the first user request and final assistant response from a completed turn", () => {

View file

@ -2,7 +2,7 @@
import { describe, expect, it, vi } from "vitest";
import { renderTextWithWikiLinks, shortSignature } from "../../src/shared/ui/dom";
import { renderTextWithWikiLinks, shortSignature } from "../../../../src/shared/ui/dom";
import { installObsidianDomShims } from "./dom-test-helpers";
installObsidianDomShims();

View file

@ -2,21 +2,21 @@
import { describe, expect, it, vi } from "vitest";
import type { PendingApproval } from "../../src/features/chat/approvals/model";
import type { PendingUserInput } from "../../src/features/chat/user-input/model";
import type { PendingApproval } from "../../../../src/features/chat/approvals/model";
import type { PendingUserInput } from "../../../../src/features/chat/user-input/model";
import {
renderComposerShell,
renderComposerSuggestions,
scrollComposerSuggestionIntoView,
syncComposerControls,
syncComposerHeight,
} from "../../src/features/chat/ui/composer";
import { renderPendingRequestMessage } from "../../src/features/chat/ui/pending-request-message";
import { renderToolbar, toolbarSignature, type ToolbarViewModel } from "../../src/features/chat/ui/toolbar";
import { displayItemSignature } from "../../src/features/chat/display/signature";
import { implementPlanCandidateFromState } from "../../src/features/chat/message-renderer";
import { messageRenderBlocks as rawMessageRenderBlocks, syncMessageRenderBlocks } from "../../src/features/chat/ui/message-stream";
import { displayDiffLines, persistedTurnDiffViewState, renderTurnDiffView } from "../../src/features/chat/ui/turn-diff";
} from "../../../../src/features/chat/ui/composer";
import { renderPendingRequestMessage } from "../../../../src/features/chat/ui/pending-request-message";
import { renderToolbar, toolbarSignature, type ToolbarViewModel } from "../../../../src/features/chat/ui/toolbar";
import { displayItemSignature } from "../../../../src/features/chat/display/signature";
import { implementPlanCandidateFromState } from "../../../../src/features/chat/chat-message-renderer";
import { messageRenderBlocks as rawMessageRenderBlocks, syncMessageRenderBlocks } from "../../../../src/features/chat/ui/message-stream";
import { displayDiffLines, persistedChatTurnDiffViewState, renderChatTurnDiffView } from "../../../../src/features/chat/ui/turn-diff";
import { composerSuggestionScrollFixture, installObsidianDomShims, topLevelDetailsSummaries } from "./dom-test-helpers";
installObsidianDomShims();
@ -950,12 +950,12 @@ describe("message stream block identity and message actions", () => {
});
});
describe("turn diff view decisions", () => {
describe("chat turn diff view decisions", () => {
it("renders the turn diff view with classified unified diff lines", () => {
const parent = document.createElement("div");
const copyDiff = vi.fn();
renderTurnDiffView(
renderChatTurnDiffView(
parent,
{
threadId: "019e061e-0046-7653-a362-86de9a47cb5c",
@ -967,26 +967,26 @@ describe("turn diff view decisions", () => {
{ copyDiff },
);
expect(parent.querySelector(".codex-panel-turn-diff__title")?.textContent).toBe("Turn diff");
expect(parent.querySelector(".codex-panel-turn-diff__meta")?.textContent).toContain("019e061e");
expect(parent.querySelector(".codex-panel-turn-diff__files summary")?.textContent).toBe("Changed files");
expect(parent.querySelector(".codex-panel-turn-diff__files")?.textContent).toContain("src/main.ts");
expect(parent.querySelector(".codex-panel__diff-line--file")?.textContent).toBe("src/main.ts");
expect(parent.querySelector(".codex-panel-chat-turn-diff__title")?.textContent).toBe("Turn diff");
expect(parent.querySelector(".codex-panel-chat-turn-diff__meta")?.textContent).toContain("019e061e");
expect(parent.querySelector(".codex-panel-chat-turn-diff__files summary")?.textContent).toBe("Changed files");
expect(parent.querySelector(".codex-panel-chat-turn-diff__files")?.textContent).toContain("src/main.ts");
expect(parent.querySelector(".codex-panel-diff__line--file")?.textContent).toBe("src/main.ts");
expect(parent.textContent).not.toContain("diff --git");
expect(parent.textContent).not.toContain("+++ b/src/main.ts");
expect(parent.textContent).not.toContain("-old");
expect(parent.textContent).not.toContain("+new");
expect(parent.textContent).toContain("old");
expect(parent.textContent).toContain("new");
expect(parent.querySelectorAll(".codex-panel__diff-line--hunk")).toHaveLength(1);
expect(parent.querySelectorAll(".codex-panel__diff-line--removed")).toHaveLength(1);
expect(parent.querySelectorAll(".codex-panel__diff-line--added")).toHaveLength(1);
parent.querySelector<HTMLButtonElement>(".codex-panel-turn-diff__copy")?.click();
expect(parent.querySelectorAll(".codex-panel-diff__line--hunk")).toHaveLength(1);
expect(parent.querySelectorAll(".codex-panel-diff__line--removed")).toHaveLength(1);
expect(parent.querySelectorAll(".codex-panel-diff__line--added")).toHaveLength(1);
parent.querySelector<HTMLButtonElement>(".codex-panel-chat-turn-diff__copy")?.click();
expect(copyDiff).toHaveBeenCalled();
});
it("keeps unified diff text out of persisted turn diff view state", () => {
const persisted = persistedTurnDiffViewState({
const persisted = persistedChatTurnDiffViewState({
threadId: "thread",
turnId: "turn",
cwd: "/vault/project",
@ -1006,12 +1006,12 @@ describe("turn diff view decisions", () => {
it("renders restored turn diff metadata without unavailable diff text", () => {
const parent = document.createElement("div");
renderTurnDiffView(parent, null, {}, { threadId: "thread", turnId: "turn", cwd: "/vault/project", files: ["src/main.ts"] });
renderChatTurnDiffView(parent, null, {}, { threadId: "thread", turnId: "turn", cwd: "/vault/project", files: ["src/main.ts"] });
expect(parent.querySelector(".codex-panel-turn-diff__meta")?.textContent).toContain("thread / turn");
expect(parent.querySelector(".codex-panel-chat-turn-diff__meta")?.textContent).toContain("thread / turn");
expect(parent.textContent).toContain("Turn diff is no longer available.");
expect(parent.querySelector(".codex-panel-turn-diff__copy")).toBeNull();
expect(parent.querySelector(".codex-panel-turn-diff__diff")).toBeNull();
expect(parent.querySelector(".codex-panel-chat-turn-diff__copy")).toBeNull();
expect(parent.querySelector(".codex-panel-chat-turn-diff__diff")).toBeNull();
});
it("simplifies git diff file headers for turn diff display", () => {

View file

@ -2,7 +2,7 @@
import { describe, expect, it } from "vitest";
import { bottomScrollTop, captureScrollAnchor, isNearScrollBottom, restoreScrollAnchor } from "../../src/features/chat/ui/scroll";
import { bottomScrollTop, captureScrollAnchor, isNearScrollBottom, restoreScrollAnchor } from "../../../../src/features/chat/ui/scroll";
describe("message scroll helpers", () => {
it("detects whether the transcript is pinned near the bottom", () => {

View file

@ -1,8 +1,8 @@
import { describe, expect, it } from "vitest";
import { questionDefaultAnswer, toPendingUserInput, userInputResponse } from "../../src/features/chat/user-input/model";
import { pendingRequestsSignature } from "../../src/features/chat/request-state";
import type { ServerRequest } from "../../src/generated/app-server/ServerRequest";
import { questionDefaultAnswer, toPendingUserInput, userInputResponse } from "../../../../src/features/chat/user-input/model";
import { pendingRequestsSignature } from "../../../../src/features/chat/request-state";
import type { ServerRequest } from "../../../../src/generated/app-server/ServerRequest";
function expectPresent<T>(value: T | null | undefined): T {
if (value === null || value === undefined) throw new Error("Expected value to be present");

View file

@ -0,0 +1,204 @@
import { describe, expect, it } from "vitest";
import { buildSelectionUnifiedDiff } from "../../../src/features/selection-rewrite/diff";
import {
isSelectionRewriteActionKey,
isSelectionRewriteGenerateKey,
type SelectionRewriteGenerateKeyEvent,
} from "../../../src/features/selection-rewrite/keys";
import { canApplySelectionRewrite, type SelectionRewriteSession } from "../../../src/features/selection-rewrite/model";
import {
parseSelectionRewriteOutput,
selectionRewriteOutputFromTurn,
selectionRewriteOutputParseResultFromTurn,
} from "../../../src/features/selection-rewrite/output";
import { buildSelectionRewritePrompt } from "../../../src/features/selection-rewrite/prompt";
import { selectionRewriteRuntime, validatedSelectionRewriteRuntime } from "../../../src/features/selection-rewrite/runner";
import type { Model } from "../../../src/generated/app-server/v2/Model";
import type { Turn } from "../../../src/generated/app-server/v2/Turn";
describe("selection selection rewrite output", () => {
it("parses valid selection rewrite JSON", () => {
expect(parseSelectionRewriteOutput('{"replacementText":"rewritten"}')).toEqual({ replacementText: "rewritten" });
});
it("rejects invalid selection rewrite JSON", () => {
expect(parseSelectionRewriteOutput("replacementText: rewritten")).toBeNull();
expect(parseSelectionRewriteOutput('{"replacementText":42}')).toBeNull();
expect(parseSelectionRewriteOutput('{"text":"rewritten"}')).toBeNull();
});
it("extracts the final selection rewrite JSON from a completed turn", () => {
expect(
selectionRewriteOutputFromTurn(
turn([
{ type: "agentMessage", id: "a1", text: '{"replacementText":"first"}', phase: "final_answer", memoryCitation: null },
{ type: "agentMessage", id: "a2", text: '{"replacementText":"final"}', phase: "final_answer", memoryCitation: null },
]),
),
).toEqual({ replacementText: "final" });
});
it("keeps raw model text when selection rewrite output parsing fails", () => {
expect(
selectionRewriteOutputParseResultFromTurn(
turn([{ type: "agentMessage", id: "a1", text: "replacementText: final", phase: "final_answer", memoryCitation: null }]),
),
).toEqual({ output: null, rawText: "replacementText: final" });
});
});
describe("selection rewrite prompt", () => {
it("always includes note context with the selected text", () => {
const prompt = buildSelectionRewritePrompt(session());
expect(prompt).toContain("Context mode: Selection + note context");
expect(prompt).toContain("Current note context:");
expect(prompt).toContain("# Heading");
});
it("uses fences that cannot be closed by note code blocks", () => {
const prompt = buildSelectionRewritePrompt(
session({
originalText: "```ts\nconst value = 1;\n```",
noteText: "````markdown\n```ts\nconst value = 1;\n```\n````",
}),
);
expect(prompt).toContain("````text\n```ts");
expect(prompt).toContain("`````text\n````markdown");
});
});
describe("selection rewrite diff", () => {
it("renders replacements in a selection-scoped unified diff", () => {
const diff = buildSelectionUnifiedDiff("Note.md", "alpha\nbeta", "alpha\ngamma");
expect(diff).toContain("diff --git a/Note.md b/Note.md");
expect(diff).toContain("@@ -1,2 +1,2 @@");
expect(diff).toContain(" alpha");
expect(diff).toContain("-beta");
expect(diff).toContain("+gamma");
});
it("renders additions and deletions", () => {
expect(buildSelectionUnifiedDiff("Note.md", "", "added")).toContain("+added");
expect(buildSelectionUnifiedDiff("Note.md", "removed", "")).toContain("-removed");
});
it("renders unchanged text as context", () => {
expect(buildSelectionUnifiedDiff("Note.md", "same", "same")).toContain(" same");
});
});
describe("selection rewrite apply guard", () => {
it("allows apply only when the current range still matches the original text", () => {
expect(canApplySelectionRewrite("original", "original")).toBe(true);
expect(canApplySelectionRewrite("changed", "original")).toBe(false);
});
});
describe("selection selection rewrite runtime", () => {
it("uses explicit selection rewrite runtime settings", () => {
expect(selectionRewriteRuntime({ rewriteSelectionModel: "gpt-5.4-mini", rewriteSelectionEffort: "minimal" })).toEqual({
model: "gpt-5.4-mini",
effort: "minimal",
});
});
it("omits selection rewrite runtime overrides that are set to Codex default", () => {
expect(selectionRewriteRuntime({ rewriteSelectionModel: null, rewriteSelectionEffort: null })).toEqual({});
});
it("omits an explicit selection rewrite effort when the selected model does not support it", () => {
expect(
validatedSelectionRewriteRuntime({ rewriteSelectionModel: "gpt-5.4-mini", rewriteSelectionEffort: "minimal" }, [
model("gpt-5.4-mini", ["low", "medium", "high", "xhigh"]),
]),
).toEqual({ model: "gpt-5.4-mini" });
});
});
describe("selection rewrite keys", () => {
const baseEvent: SelectionRewriteGenerateKeyEvent = {
key: "Enter",
shiftKey: false,
metaKey: false,
ctrlKey: false,
altKey: false,
isComposing: false,
};
it("generates on plain Enter in Enter mode", () => {
expect(isSelectionRewriteGenerateKey(baseEvent, "enter")).toBe(true);
expect(isSelectionRewriteGenerateKey({ ...baseEvent, shiftKey: true }, "enter")).toBe(false);
expect(isSelectionRewriteGenerateKey({ ...baseEvent, metaKey: true }, "enter")).toBe(false);
});
it("generates on Cmd/Ctrl+Enter in mod-enter mode", () => {
expect(isSelectionRewriteGenerateKey({ ...baseEvent, metaKey: true }, "mod-enter")).toBe(true);
expect(isSelectionRewriteGenerateKey({ ...baseEvent, ctrlKey: true }, "mod-enter")).toBe(true);
expect(isSelectionRewriteGenerateKey(baseEvent, "mod-enter")).toBe(false);
});
it("treats plain Enter and Cmd/Ctrl+Enter as preview action keys", () => {
expect(isSelectionRewriteActionKey(baseEvent)).toBe(true);
expect(isSelectionRewriteActionKey({ ...baseEvent, metaKey: true })).toBe(true);
expect(isSelectionRewriteActionKey({ ...baseEvent, ctrlKey: true })).toBe(true);
expect(isSelectionRewriteActionKey({ ...baseEvent, shiftKey: true })).toBe(false);
expect(isSelectionRewriteActionKey({ ...baseEvent, isComposing: true })).toBe(false);
});
});
function session(overrides: Partial<SelectionRewriteSession> = {}): SelectionRewriteSession {
return {
filePath: "Note.md",
targetRange: {
from: { line: 1, ch: 0 },
to: { line: 1, ch: 22 },
},
originalText: "Revise this sentence.",
noteText: "# Heading\n\nRevise this sentence.\n\nNext paragraph.",
instruction: "Make it clearer.",
status: "editing-prompt",
streamText: "",
replacementText: null,
debugText: null,
...overrides,
};
}
function turn(items: Turn["items"], overrides: Partial<Turn> = {}): Turn {
return {
id: "turn",
items,
itemsView: "full",
status: "completed",
error: null,
startedAt: null,
completedAt: null,
durationMs: null,
...overrides,
};
}
function model(name: string, efforts: Model["supportedReasoningEfforts"][number]["reasoningEffort"][]): Model {
return {
id: name,
model: name,
upgrade: null,
upgradeInfo: null,
availabilityNux: null,
displayName: name,
description: "",
hidden: false,
supportedReasoningEfforts: efforts.map((reasoningEffort) => ({ reasoningEffort, description: "" })),
defaultReasoningEffort: efforts[0] ?? "low",
inputModalities: ["text"],
supportsPersonality: false,
additionalSpeedTiers: [],
serviceTiers: [],
defaultServiceTier: null,
isDefault: false,
};
}