Rename selection rewrite state

This commit is contained in:
murashit 2026-05-28 17:42:20 +09:00
parent c1af92c6e9
commit 4abd131a02
6 changed files with 56 additions and 54 deletions

View file

@ -19,7 +19,7 @@ The source tree is organized by responsibility rather than by the original singl
- `src/app-server/` owns app-server transport, connection lifecycle, compatibility helpers, and RPC facades.
- `src/features/chat/` owns the main Codex chat surface: Obsidian `ItemView` classes, panel orchestration, request/app-server controllers, composer behavior, display models, chat-only UI renderers, approvals, user input, thread actions, and panel-specific state.
- `src/features/threads-view/` owns the dedicated Obsidian thread list view, including app-server thread list rendering and live open-panel snapshot aggregation.
- `src/features/selection-rewrite/` owns the Markdown editor selection rewrite command, popover, prompt/output handling, and short-lived rewrite session runner.
- `src/features/selection-rewrite/` owns the Markdown editor selection rewrite command, popover, prompt/output handling, and ephemeral rewrite thread runner.
- `src/runtime/` owns Codex runtime configuration projection, model metadata, collaboration mode, and compact labels used by views.
- `src/shared/` contains feature-neutral helpers, including reusable DOM pieces and unified diff display helpers shared by chat and selection rewrite.
- `src/settings/` contains Obsidian settings models, settings-tab rendering, and app-server-backed dynamic settings data.

View file

@ -1,6 +1,6 @@
import { MarkdownView, Notice, type Editor, type Plugin } from "obsidian";
import type { SelectionRewriteRuntimeSettings, SelectionRewriteSession } from "./model";
import type { SelectionRewriteRuntimeSettings, SelectionRewriteState } from "./model";
import { SelectionRewritePopover } from "./popover";
import type { SendShortcut } from "../../shared/ui/keyboard";
@ -35,7 +35,7 @@ export function registerSelectionRewriteCommand(plugin: SelectionRewriteCommandH
return;
}
const session: SelectionRewriteSession = {
const rewriteState: SelectionRewriteState = {
filePath: view.file.path,
targetRange: {
from: clonePosition(editor.getCursor("from")),
@ -57,7 +57,7 @@ export function registerSelectionRewriteCommand(plugin: SelectionRewriteCommandH
onClose: () => activePopovers.delete(popover),
runtimeSettings: plugin.settings,
sendShortcut: plugin.settings.sendShortcut,
session,
state: rewriteState,
});
popover.open();
activePopovers.add(popover);

View file

@ -3,7 +3,7 @@ import type { ReasoningEffort } from "../../generated/app-server/ReasoningEffort
export type SelectionRewriteStatus = "editing-prompt" | "generating" | "preview" | "applied" | "cancelled" | "failed";
export interface SelectionRewriteSession {
export interface SelectionRewriteState {
filePath: string;
targetRange: {
from: EditorPosition;

View file

@ -8,7 +8,7 @@ import { renderReactRoot, unmountReactRoot } from "../../shared/ui/react-root";
import { syncTextareaHeight } from "../../shared/ui/textarea-autogrow";
import { buildSelectionUnifiedDiff } from "./diff";
import { isSelectionRewriteActionKey, isSelectionRewriteGenerateKey } from "./keys";
import { canApplySelectionRewrite, type SelectionRewriteRuntimeSettings, type SelectionRewriteSession } from "./model";
import { canApplySelectionRewrite, type SelectionRewriteRuntimeSettings, type SelectionRewriteState } from "./model";
import { SelectionRewriteOutputError } from "./output";
import { positionSelectionRewritePopover } from "./position";
import { buildSelectionRewritePrompt } from "./prompt";
@ -24,7 +24,7 @@ export interface SelectionRewritePopoverOptions {
onClose?: () => void;
runtimeSettings: SelectionRewriteRuntimeSettings;
sendShortcut: SendShortcut;
session: SelectionRewriteSession;
state: SelectionRewriteState;
}
type Cleanup = () => void;
@ -44,7 +44,7 @@ export class SelectionRewritePopover {
private statusActive = false;
constructor(private readonly options: SelectionRewritePopoverOptions) {
this.instructionDraft = options.session.instruction;
this.instructionDraft = options.state.instruction;
}
open(): void {
@ -80,8 +80,8 @@ export class SelectionRewritePopover {
close(): void {
const hadElements = this.elements !== null;
if (!hadElements && this.options.session.status !== "generating") return;
if (this.options.session.status === "generating") {
if (!hadElements && this.options.state.status !== "generating") return;
if (this.options.state.status === "generating") {
this.abortController?.abort();
}
for (const cleanup of this.cleanups.splice(0)) cleanup();
@ -94,7 +94,7 @@ export class SelectionRewritePopover {
}
private async generate(): Promise<void> {
if (this.options.session.status === "generating") return;
if (this.options.state.status === "generating") return;
const instruction = this.instructionDraft.trim();
if (!instruction) {
new Notice("Enter a rewrite instruction first.");
@ -112,7 +112,7 @@ export class SelectionRewritePopover {
const output = await runSelectionRewrite({
codexPath: this.options.codexPath,
cwd: this.options.cwd,
prompt: buildSelectionRewritePrompt(this.options.session),
prompt: buildSelectionRewritePrompt(this.options.state),
runtimeSettings: this.options.runtimeSettings,
onActivity: (activity) => {
this.updateActivity(activity);
@ -126,26 +126,26 @@ export class SelectionRewritePopover {
this.showSelectionRewritePreview(output.replacementText);
} catch (error) {
if (abortController.signal.aborted) {
this.options.session.status = "cancelled";
this.options.state.status = "cancelled";
return;
}
this.showGenerationFailure(error);
} finally {
if (this.abortController === abortController) this.abortController = null;
this.syncControls();
if (this.options.session.status === "preview") this.focusApplyButton();
if (this.options.state.status === "preview") this.focusApplyButton();
this.position();
}
}
private cancel(): void {
this.options.session.status = "cancelled";
this.options.state.status = "cancelled";
this.abortController?.abort();
this.close();
}
private updatePreview(text: string): void {
this.options.session.streamText = text;
this.options.state.streamText = text;
this.setStatus("Writing replacement", { active: true });
this.position();
}
@ -164,42 +164,42 @@ export class SelectionRewritePopover {
}
private startGeneration(instruction: string): void {
this.options.session.instruction = instruction;
this.options.session.status = "generating";
this.options.session.streamText = "";
this.options.session.replacementText = null;
this.options.session.debugText = null;
this.options.state.instruction = instruction;
this.options.state.status = "generating";
this.options.state.streamText = "";
this.options.state.replacementText = null;
this.options.state.debugText = null;
this.renderView();
}
private showSelectionRewritePreview(replacementText: string): void {
this.options.session.replacementText = replacementText;
this.options.session.status = "preview";
this.options.session.streamText = "";
this.options.state.replacementText = replacementText;
this.options.state.status = "preview";
this.options.state.streamText = "";
this.setStatus("");
}
private showGenerationFailure(error: unknown): void {
this.options.session.status = "failed";
this.options.session.debugText = error instanceof SelectionRewriteOutputError ? error.rawText : null;
this.options.session.streamText = "";
this.options.state.status = "failed";
this.options.state.debugText = error instanceof SelectionRewriteOutputError ? error.rawText : null;
this.options.state.streamText = "";
this.setStatus(error instanceof Error ? error.message : String(error));
}
private apply(): void {
const replacement = this.options.session.replacementText;
const replacement = this.options.state.replacementText;
if (replacement === null) return;
const { editor, session } = this.options;
const currentText = editor.getRange(session.targetRange.from, session.targetRange.to);
if (!canApplySelectionRewrite(currentText, session.originalText)) {
const { editor, state } = this.options;
const currentText = editor.getRange(state.targetRange.from, state.targetRange.to);
if (!canApplySelectionRewrite(currentText, state.originalText)) {
new Notice("Selection changed. Generate the rewrite again before applying.");
this.setStatus("Selection changed. Generate the rewrite again before applying.");
return;
}
editor.replaceRange(replacement, session.targetRange.from, session.targetRange.to, "codex-panel-rewrite");
session.status = "applied";
editor.replaceRange(replacement, state.targetRange.from, state.targetRange.to, "codex-panel-rewrite");
state.status = "applied";
this.close();
}
@ -233,17 +233,17 @@ export class SelectionRewritePopover {
private renderView(elements: SelectionRewriteElements | null = this.elements): void {
if (!elements) return;
const session = this.options.session;
const replacement = session.replacementText;
const state = this.options.state;
const replacement = state.replacementText;
renderReactRoot(
elements.root,
<SelectionRewritePopoverView
applyButtonRef={(element) => {
elements.applyButton = element;
}}
debugText={session.debugText}
diff={replacement === null ? null : buildSelectionUnifiedDiff(session.filePath, session.originalText, replacement)}
generating={session.status === "generating"}
debugText={state.debugText}
diff={replacement === null ? null : buildSelectionUnifiedDiff(state.filePath, state.originalText, replacement)}
generating={state.status === "generating"}
hasInstruction={Boolean(this.instructionDraft.trim())}
hasReplacement={replacement !== null}
instruction={this.instructionDraft}
@ -264,7 +264,7 @@ export class SelectionRewritePopover {
this.position();
}}
onInstructionKeyDown={(event) => {
const hasReplacement = this.options.session.replacementText !== null;
const hasReplacement = this.options.state.replacementText !== null;
if (
!(hasReplacement
? isSelectionRewriteActionKey(event.nativeEvent)
@ -283,7 +283,7 @@ export class SelectionRewritePopover {
}}
statusActive={this.statusActive}
statusText={this.statusText}
streamPreview={session.streamText.trim()}
streamPreview={state.streamText.trim()}
/>,
);
}

View file

@ -1,4 +1,4 @@
import type { SelectionRewriteSession } from "./model";
import type { SelectionRewriteState } from "./model";
const MAX_NOTE_CONTEXT_CHARS = 20_000;
@ -12,7 +12,7 @@ export const SELECTION_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 buildSelectionRewritePrompt(session: SelectionRewriteSession): string {
export function buildSelectionRewritePrompt(state: SelectionRewriteState): string {
return [
"Rewrite the selected text according to the user's instruction.",
"",
@ -23,18 +23,18 @@ export function buildSelectionRewritePrompt(session: SelectionRewriteSession): s
"- Preserve the language and style of the surrounding note unless instructed otherwise.",
"",
"Target:",
`- File: ${session.filePath}`,
`- Selection: ${positionLabel(session.targetRange.from)} to ${positionLabel(session.targetRange.to)}`,
`- File: ${state.filePath}`,
`- Selection: ${positionLabel(state.targetRange.from)} to ${positionLabel(state.targetRange.to)}`,
"- Context mode: Selection + note context",
"",
"User instruction:",
session.instruction,
state.instruction,
"",
"Selected text:",
fenced(session.originalText),
fenced(state.originalText),
"",
"Current note context:",
fenced(truncateNoteContext(session.noteText)),
fenced(truncateNoteContext(state.noteText)),
"",
"Reminder: use the note context only to make the selected-text replacement coherent.",
].join("\n");

View file

@ -9,7 +9,7 @@ import {
isSelectionRewriteGenerateKey,
type SelectionRewriteGenerateKeyEvent,
} from "../../../src/features/selection-rewrite/keys";
import { canApplySelectionRewrite, type SelectionRewriteSession } from "../../../src/features/selection-rewrite/model";
import { canApplySelectionRewrite, type SelectionRewriteState } from "../../../src/features/selection-rewrite/model";
import {
parseSelectionRewriteOutput,
selectionRewriteOutputFromTurn,
@ -68,7 +68,7 @@ describe("selection selection rewrite output", () => {
describe("selection rewrite prompt", () => {
it("always includes note context with the selected text", () => {
const prompt = buildSelectionRewritePrompt(session());
const prompt = buildSelectionRewritePrompt(rewriteState());
expect(prompt).toContain("Context mode: Selection + note context");
expect(prompt).toContain("Current note context:");
@ -77,7 +77,7 @@ describe("selection rewrite prompt", () => {
it("uses fences that cannot be closed by note code blocks", () => {
const prompt = buildSelectionRewritePrompt(
session({
rewriteState({
originalText: "```ts\nconst value = 1;\n```",
noteText: "````markdown\n```ts\nconst value = 1;\n```\n````",
}),
@ -137,7 +137,7 @@ describe("selection rewrite apply guard", () => {
describe("selection rewrite popover", () => {
it("enables Generate only after the instruction has content", () => {
const popover = new SelectionRewritePopover(popoverOptions({ session: session({ instruction: "" }) }));
const popover = new SelectionRewritePopover(popoverOptions({ state: rewriteState({ instruction: "" }) }));
popover.open();
@ -162,7 +162,9 @@ describe("selection rewrite popover", () => {
options.onPreview?.("Rewritten sentence.");
return { replacementText: "Rewritten sentence." };
});
const popover = new SelectionRewritePopover(popoverOptions({ editor: editor.editor, onClose, session: session({ instruction: "" }) }));
const popover = new SelectionRewritePopover(
popoverOptions({ editor: editor.editor, onClose, state: rewriteState({ instruction: "" }) }),
);
popover.open();
const instruction = expectPresent(document.querySelector<HTMLTextAreaElement>(".codex-panel-selection-rewrite__instruction"));
@ -257,7 +259,7 @@ describe("selection rewrite keys", () => {
});
});
function session(overrides: Partial<SelectionRewriteSession> = {}): SelectionRewriteSession {
function rewriteState(overrides: Partial<SelectionRewriteState> = {}): SelectionRewriteState {
return {
filePath: "Note.md",
targetRange: {
@ -284,7 +286,7 @@ function popoverOptions(
editor: editorFixture().editor,
runtimeSettings: { rewriteSelectionModel: null, rewriteSelectionEffort: null },
sendShortcut: "enter",
session: session(),
state: rewriteState(),
...overrides,
};
}