From 25bda423cc29b84de72de0aadb5ec157fcf2c0b4 Mon Sep 17 00:00:00 2001 From: murashit Date: Sun, 28 Jun 2026 18:25:59 +0900 Subject: [PATCH] Prototype Obsidian context references --- README.md | 2 + .../references/thread-reference-resolver.ts | 9 +- .../chat/app-server/session-gateway.ts | 4 +- .../composer/context-references.ts | 50 +++++++++ .../chat/application/composer/suggestions.ts | 41 +++++++ .../application/composer/wikilink-context.ts | 101 ++++++++++++++++- .../conversation/composer-submit-actions.ts | 21 ++-- .../application/conversation/composition.ts | 5 +- .../conversation/slash-command-execution.ts | 3 +- .../conversation/turn-submission-actions.ts | 23 ++-- .../chat/application/state/root-reducer.ts | 21 +++- src/features/chat/host/composer-bundle.ts | 2 + src/features/chat/host/turn-bundle.ts | 4 +- ...ser-context-reference-provider.obsidian.ts | 104 +++++++++++++++++ .../vault-note-candidate-provider.obsidian.ts | 17 +-- .../chat/host/vault-note-links.obsidian.ts | 13 +++ .../chat/panel/composer-controller.ts | 67 ++++++++++- .../chat/app-server/transports.test.ts | 3 +- .../application/composer/suggestions.test.ts | 33 ++++++ .../composer/wikilink-context.test.ts | 62 +++++++++- .../composer-submit-actions.test.ts | 26 +++++ .../slash-command-execution.test.ts | 3 +- .../slash-command-executor.test.ts | 3 +- .../turn-submission-actions.test.ts | 34 +++++- tests/features/chat/host/turn-bundle.test.ts | 2 + .../vault-note-candidate-provider.test.ts | 49 +++++++- .../chat/panel/composer-controller.test.ts | 106 +++++++++++++++++- 27 files changed, 747 insertions(+), 61 deletions(-) create mode 100644 src/features/chat/application/composer/context-references.ts create mode 100644 src/features/chat/host/vault-composer-context-reference-provider.obsidian.ts create mode 100644 src/features/chat/host/vault-note-links.obsidian.ts diff --git a/README.md b/README.md index fcda14b0..e8527a26 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,8 @@ The Threads sidebar gives you a vault-local view of Codex work in progress. It s The composer understands Obsidian and Codex context together. Wikilink suggestions come from Obsidian file search and recent notes; sent wikilinks resolve to Codex file mentions when possible; rendered vault file links open back in Obsidian. +Use the `@active-note` completion when you want the composer to insert the last active note as a wikilink. Use the `@selection` completion to insert the selected range from the last active Markdown note as `[[note]] (Lx:Cy-Lx:Cy)`; Codex Panel keeps that completed range as the visible prompt text and sends the selected text as additional turn context. + Completions are also available for slash commands, enabled skills such as `$skill-name`, recent threads, models, and supported reasoning efforts. Use `/help` in the composer for the current slash command list. You can reference another non-archived thread without leaving the active panel, or use `/refer` when you want Codex to include recent turns from another thread in a message. While a turn is running, the panel streams assistant messages, reasoning, commands, tool calls, hooks, file changes, plan updates, and agent activity. You can answer Codex questions, approve commands, respond to MCP and file requests, send steering messages, or interrupt the turn when the composer is empty. If a thread has an active Codex goal, the panel shows it above the message stream and lets you edit, pause, resume, or clear it. diff --git a/src/features/chat/app-server/references/thread-reference-resolver.ts b/src/features/chat/app-server/references/thread-reference-resolver.ts index ca7e1473..8e9dac34 100644 --- a/src/features/chat/app-server/references/thread-reference-resolver.ts +++ b/src/features/chat/app-server/references/thread-reference-resolver.ts @@ -7,7 +7,7 @@ import type { ThreadReferenceInput } from "../../application/conversation/slash- interface ThreadReferenceResolverHost { currentClient(): ThreadConversationSummaryClient | null; - codexInput(text: string): CodexInput; + prepareInput(text: string): { text: string; input: CodexInput }; addSystemMessage(text: string): void; setStatus(status: string): void; } @@ -36,11 +36,12 @@ async function referencedThreadInput( host.addSystemMessage("Referenced thread has no readable conversation turns."); return null; } - const reference = referencedThreadPromptBundle(thread, turns, message); - const messageInput = host.codexInput(message); + const messageInput = host.prepareInput(message); + const reference = referencedThreadPromptBundle(thread, turns, messageInput.text); host.setStatus(`Referencing ${shortThreadId(thread.id)} (${String(turns.length)}/${String(REFERENCED_THREAD_TURN_LIMIT)} turns).`); return { - input: codexTextInputWithAttachments(reference.prompt, messageInput), + text: messageInput.text, + input: codexTextInputWithAttachments(reference.prompt, messageInput.input), referencedThread: reference.referencedThread, }; } catch (error) { diff --git a/src/features/chat/app-server/session-gateway.ts b/src/features/chat/app-server/session-gateway.ts index 9f620e37..cdb12cd4 100644 --- a/src/features/chat/app-server/session-gateway.ts +++ b/src/features/chat/app-server/session-gateway.ts @@ -22,7 +22,7 @@ export interface ChatAppServerGatewayHost { } interface ChatThreadReferenceResolverOptions { - codexInput(text: string): CodexInput; + prepareInput(text: string): { text: string; input: CodexInput }; addSystemMessage(text: string): void; setStatus(status: string): void; } @@ -58,7 +58,7 @@ export function createChatAppServerGateway(host: ChatAppServerGatewayHost): Chat threadReferences: (options) => createThreadReferenceResolver({ currentClient: () => host.currentClient(), - codexInput: (text) => options.codexInput(text), + prepareInput: (text) => options.prepareInput(text), addSystemMessage: (text) => { options.addSystemMessage(text); }, diff --git a/src/features/chat/application/composer/context-references.ts b/src/features/chat/application/composer/context-references.ts new file mode 100644 index 00000000..9af8d51a --- /dev/null +++ b/src/features/chat/application/composer/context-references.ts @@ -0,0 +1,50 @@ +interface ComposerContextPosition { + line: number; + ch: number; +} + +export interface ComposerContextRange { + from: ComposerContextPosition; + to: ComposerContextPosition; +} + +interface ActiveNoteContextReference { + name: string; + path: string; + linktext: string; +} + +export interface SelectionContextReference { + name: string; + path: string; + linktext: string; + range: ComposerContextRange; + text: string; +} + +export interface ComposerContextReferences { + activeNote: ActiveNoteContextReference | null; + selection: SelectionContextReference | null; + selectionSnapshots?: readonly SelectionContextReference[]; +} + +export interface ComposerContextReferenceProvider { + contextReferences(sourcePath: string): ComposerContextReferences; + dispose(): void; +} + +export function emptyComposerContextReferences(): ComposerContextReferences { + return { activeNote: null, selection: null, selectionSnapshots: [] }; +} + +export function formatComposerContextRange(range: ComposerContextRange): string { + return `${formatComposerContextPosition(range.from)}-${formatComposerContextPosition(range.to)}`; +} + +export function selectionContextReferenceMarker(selection: SelectionContextReference): string { + return `[[${selection.linktext}]] (${formatComposerContextRange(selection.range)})`; +} + +function formatComposerContextPosition(position: ComposerContextPosition): string { + return `L${String(position.line + 1)}:C${String(position.ch + 1)}`; +} diff --git a/src/features/chat/application/composer/suggestions.ts b/src/features/chat/application/composer/suggestions.ts index a58a3574..a9eaa428 100644 --- a/src/features/chat/application/composer/suggestions.ts +++ b/src/features/chat/application/composer/suggestions.ts @@ -4,6 +4,12 @@ import { findModelMetadataByIdOrName, sortedModelMetadata, supportedEffortsForMo import type { Thread } from "../../../../domain/threads/model"; import { threadDisplayTitle } from "../../../../domain/threads/title"; import { shortThreadId } from "../../../../shared/id/thread-id"; +import { + type ComposerContextReferences, + formatComposerContextRange, + type SelectionContextReference, + selectionContextReferenceMarker, +} from "./context-references"; import { SLASH_COMMANDS, type SlashCommandName, slashCommandSubcommands } from "./slash-commands"; export interface ComposerSuggestion { @@ -14,10 +20,12 @@ export interface ComposerSuggestion { appendSpaceOnInsert?: boolean; tabCursorOffset?: number; suffixOnInsert?: string; + selectionContext?: SelectionContextReference; } export interface ComposerSuggestionOptions { activeThreadId?: string | null; + contextReferences?: ComposerContextReferences; } export interface NoteCandidate { @@ -93,6 +101,7 @@ export function activeComposerSuggestions( ): ComposerSuggestion[] { return ( activeWikiLinkSuggestions(beforeCursor, notes) ?? + activeContextReferenceSuggestions(beforeCursor, options.contextReferences) ?? activeSlashSubcommandSuggestions(beforeCursor) ?? activeThreadCommandSuggestions(beforeCursor, threads, options.activeThreadId ?? null) ?? modelOverrideSuggestions(beforeCursor, models) ?? @@ -103,6 +112,38 @@ export function activeComposerSuggestions( ); } +function activeContextReferenceSuggestions( + beforeCursor: string, + references: ComposerContextReferences | undefined, +): ComposerSuggestion[] | null { + const match = /(^|[\s([{])@([A-Za-z-]{0,120})$/.exec(beforeCursor); + if (!match) return null; + + const rawQuery = match[2]; + if (rawQuery === undefined) return null; + const query = rawQuery.toLowerCase(); + const start = beforeCursor.length - rawQuery.length - 1; + const suggestions: ComposerSuggestion[] = []; + if (references?.activeNote && "active-note".startsWith(query) && query !== "active-note") { + suggestions.push({ + display: "Active note", + detail: references.activeNote.path, + replacement: `[[${references.activeNote.linktext}]]`, + start, + }); + } + if (references?.selection && "selection".startsWith(query) && query !== "selection") { + suggestions.push({ + display: "Selection", + detail: `${references.selection.path} ${formatComposerContextRange(references.selection.range)}`, + replacement: selectionContextReferenceMarker(references.selection), + start, + selectionContext: references.selection, + }); + } + return suggestions.slice(0, 8); +} + export function applyComposerSuggestionInsertion( value: string, cursor: number, diff --git a/src/features/chat/application/composer/wikilink-context.ts b/src/features/chat/application/composer/wikilink-context.ts index 3d5a9afd..26ecc53d 100644 --- a/src/features/chat/application/composer/wikilink-context.ts +++ b/src/features/chat/application/composer/wikilink-context.ts @@ -1,8 +1,20 @@ import { parseLinktext } from "obsidian"; import type { SkillMetadata } from "../../../../domain/catalog/metadata"; -import { codexTextInputWithMentions, type RequestAdditionalContext, type RequestMention } from "../../../../domain/chat/input"; +import { + type CodexInput, + codexTextInputWithMentions, + type RequestAdditionalContext, + type RequestMention, +} from "../../../../domain/chat/input"; +import { + type ComposerContextReferences, + emptyComposerContextReferences, + formatComposerContextRange, + type SelectionContextReference, + selectionContextReferenceMarker, +} from "./context-references"; -export interface ParsedWikiLink { +interface ParsedWikiLink { raw: string; target: string; subpath: string; @@ -12,9 +24,15 @@ export interface ParsedWikiLink { export type WikiLinkMentionResolver = (target: string) => { name: string; path: string } | null; const WIKILINK_ADDITIONAL_CONTEXT_KEY = "codex_panel_wikilinks"; +const OBSIDIAN_CONTEXT_ADDITIONAL_CONTEXT_KEY = "codex_panel_obsidian_context"; const WIKILINK_PATTERN = /\[\[([^\]\n]+?)\]\]/g; const SKILL_REFERENCE_PATTERN = /(^|[\s([{])\$([^\s\])}.,;!?]{1,120})(?=$|[\s\])}.,;!?])/g; +export interface PreparedComposerInput { + text: string; + input: CodexInput; +} + function parsedWikiLinks(text: string): ParsedWikiLink[] { const links: ParsedWikiLink[] = []; const seen = new Set(); @@ -37,11 +55,22 @@ export function userInputWithWikiLinkMentionsAndSkills( resolveMention: WikiLinkMentionResolver, skills: readonly SkillMetadata[], ) { + return preparedUserInputWithWikiLinkMentionsSkillsAndContext(text, resolveMention, skills).input; +} + +export function preparedUserInputWithWikiLinkMentionsSkillsAndContext( + text: string, + resolveMention: WikiLinkMentionResolver, + skills: readonly SkillMetadata[], + contextReferences: ComposerContextReferences = emptyComposerContextReferences(), +): PreparedComposerInput { + const contextReplacement = textWithContextReferences(text, contextReferences); + const resolvedText = contextReplacement.text; const mentions: RequestMention[] = []; const wikilinkMappings: string[] = []; const seenPaths = new Set(); - for (const link of parsedWikiLinks(text)) { + for (const link of parsedWikiLinks(resolvedText)) { const mention = resolveMention(link.target); if (!mention || seenPaths.has(mention.path)) continue; seenPaths.add(mention.path); @@ -49,17 +78,60 @@ export function userInputWithWikiLinkMentionsAndSkills( wikilinkMappings.push(`- [[${link.raw}]] -> ${mention.path}`); } + for (const selection of contextReplacement.selections) { + if (seenPaths.has(selection.path)) continue; + seenPaths.add(selection.path); + mentions.push({ name: selection.name, path: selection.path }); + } + const skillByName = firstEnabledSkillByName(skills); const resolvedSkills: RequestMention[] = []; const seenSkillPaths = new Set(); - for (const reference of parsedSkillReferences(text)) { + for (const reference of parsedSkillReferences(resolvedText)) { const skill = skillByName.get(reference.toLowerCase()); if (!skill || seenSkillPaths.has(skill.path)) continue; seenSkillPaths.add(skill.path); resolvedSkills.push({ name: skill.name, path: skill.path }); } - return codexTextInputWithMentions(text, mentions, resolvedSkills, wikilinkAdditionalContext(wikilinkMappings)); + return { + text: resolvedText, + input: codexTextInputWithMentions( + resolvedText, + mentions, + resolvedSkills, + additionalContext(wikilinkMappings, contextReplacement.selections), + ), + }; +} + +function textWithContextReferences( + text: string, + contextReferences: ComposerContextReferences, +): { text: string; selections: SelectionContextReference[] } { + return { + text, + selections: selectionsReferencedByText(text, contextReferences.selectionSnapshots ?? []), + }; +} + +function selectionsReferencedByText(text: string, snapshots: readonly SelectionContextReference[]): SelectionContextReference[] { + const selections: SelectionContextReference[] = []; + const seen = new Set(); + for (const snapshot of snapshots) { + const marker = selectionContextReferenceMarker(snapshot); + if (!text.includes(marker) || seen.has(marker)) continue; + seen.add(marker); + selections.push(snapshot); + } + return selections; +} + +function additionalContext( + wikilinkMappings: readonly string[], + selections: readonly SelectionContextReference[], +): RequestAdditionalContext[] { + return [...wikilinkAdditionalContext(wikilinkMappings), ...obsidianContextAdditionalContext(selections)]; } function wikilinkAdditionalContext(mappings: readonly string[]): RequestAdditionalContext[] { @@ -73,6 +145,25 @@ function wikilinkAdditionalContext(mappings: readonly string[]): RequestAddition ]; } +function obsidianContextAdditionalContext(selections: readonly SelectionContextReference[]): RequestAdditionalContext[] { + if (selections.length === 0) return []; + const references = selections.map((selection) => { + const location = `${selection.path} ${formatComposerContextRange(selection.range)}`; + return `- ${selectionContextReferenceMarker(selection)} -> ${location}`; + }); + const bodies = selections.flatMap((selection, index) => { + const prefix = index === 0 ? [] : [""]; + return [...prefix, `${selectionContextReferenceMarker(selection)}:`, selection.text]; + }); + return [ + { + key: OBSIDIAN_CONTEXT_ADDITIONAL_CONTEXT_KEY, + kind: "untrusted", + value: ["Referenced Obsidian selections for the current user input:", ...references, "", ...bodies].join("\n"), + }, + ]; +} + function parsedSkillReferences(text: string): string[] { const references: string[] = []; const seen = new Set(); diff --git a/src/features/chat/application/conversation/composer-submit-actions.ts b/src/features/chat/application/conversation/composer-submit-actions.ts index 3b744a81..287f27b6 100644 --- a/src/features/chat/application/conversation/composer-submit-actions.ts +++ b/src/features/chat/application/conversation/composer-submit-actions.ts @@ -14,6 +14,7 @@ export interface ComposerSubmitActionsHost { composer: { readonly trimmedDraft: string; setDraft(text: string, options?: { clearSuggestions?: boolean; focus?: boolean }): void; + withPreservedContextReferences(operation: () => Promise): Promise; }; slashCommandExecutor: { execute(command: SlashCommandName, args: string): Promise; @@ -55,15 +56,17 @@ async function sendMessage(host: ComposerSubmitActionsHost): Promise { const slashCommand = parseSlashCommand(text); if (slashCommand) { if (slashCommandRequiresConnection(slashCommand.command) && !(await host.connection.ensureConnected())) return; - host.composer.setDraft("", { clearSuggestions: true }); - const result = await host.slashCommandExecutor.execute(slashCommand.command, slashCommand.args); - if (result?.composerDraft !== undefined) { - host.composer.setDraft(result.composerDraft, { focus: true, clearSuggestions: true }); - } - if (result?.sendText) { - host.scroll.showLatest(); - await host.turnSubmission.sendTurnText(result.sendText, result.sendInput, result.referencedThread); - } + await host.composer.withPreservedContextReferences(async () => { + host.composer.setDraft("", { clearSuggestions: true }); + const result = await host.slashCommandExecutor.execute(slashCommand.command, slashCommand.args); + if (result?.composerDraft !== undefined) { + host.composer.setDraft(result.composerDraft, { focus: true, clearSuggestions: true }); + } + if (result?.sendText) { + host.scroll.showLatest(); + await host.turnSubmission.sendTurnText(result.sendText, result.sendInput, result.referencedThread); + } + }); return; } diff --git a/src/features/chat/application/conversation/composition.ts b/src/features/chat/application/conversation/composition.ts index 8773620f..69715b1a 100644 --- a/src/features/chat/application/conversation/composition.ts +++ b/src/features/chat/application/conversation/composition.ts @@ -40,8 +40,10 @@ export interface ConversationTurnActionsContext { }; composer: { codexInput: (text: string) => CodexInput; + prepareInput: (text: string) => { text: string; input: CodexInput }; trimmedDraft: () => string; setDraft: (text: string, options?: { focus?: boolean; clearSuggestions?: boolean }) => void; + withPreservedContextReferences: (operation: () => Promise) => Promise; }; scroll: { showLatest: () => void; @@ -83,7 +85,7 @@ export function createConversationTurnActions( notifyActiveThreadIdentityChanged: thread.notifyIdentityChanged, resetThreadTurnPresence: thread.resetTurnPresence, applyPendingThreadSettings: () => refs.runtimeSettings.applyPendingThreadSettings(), - codexInput: composer.codexInput, + prepareInput: composer.prepareInput, setDraft: composer.setDraft, setStatus: status.set, addSystemMessage: status.addSystemMessage, @@ -123,6 +125,7 @@ export function createConversationTurnActions( return composer.trimmedDraft(); }, setDraft: composer.setDraft, + withPreservedContextReferences: composer.withPreservedContextReferences, }, slashCommandExecutor: { execute: (command, args) => executeSlashCommandWithState(slashCommandExecutorHost, command, args), diff --git a/src/features/chat/application/conversation/slash-command-execution.ts b/src/features/chat/application/conversation/slash-command-execution.ts index 0d9f183a..31ac28ee 100644 --- a/src/features/chat/application/conversation/slash-command-execution.ts +++ b/src/features/chat/application/conversation/slash-command-execution.ts @@ -73,6 +73,7 @@ export interface SlashCommandExecutionResult { } export interface ThreadReferenceInput { + text: string; input: CodexInput; referencedThread: ReferencedThreadMetadata; } @@ -121,7 +122,7 @@ export async function executeSlashCommand( } const reference = await context.referThread(thread.thread, parsed.message); if (!reference) return; - return { sendText: parsed.message, sendInput: reference.input, referencedThread: reference.referencedThread }; + return { sendText: reference.text, sendInput: reference.input, referencedThread: reference.referencedThread }; } case "fork": if (!context.activeThreadId) { diff --git a/src/features/chat/application/conversation/turn-submission-actions.ts b/src/features/chat/application/conversation/turn-submission-actions.ts index ca8cd55d..db883287 100644 --- a/src/features/chat/application/conversation/turn-submission-actions.ts +++ b/src/features/chat/application/conversation/turn-submission-actions.ts @@ -24,7 +24,7 @@ export interface TurnSubmissionActionsHost { notifyActiveThreadIdentityChanged: () => void; resetThreadTurnPresence: (hadTurns: boolean) => void; applyPendingThreadSettings: () => Promise; - codexInput: (text: string) => CodexInput; + prepareInput: (text: string) => { text: string; input: CodexInput }; setDraft: (text: string, options?: { focus?: boolean; clearSuggestions?: boolean }) => void; setStatus: (status: string) => void; addSystemMessage: (text: string) => void; @@ -56,6 +56,7 @@ async function sendTurnText( codexInputOverride?: CodexInput, referencedThread?: ReferencedThreadMetadata, ): Promise { + const prepared = codexInputOverride ? { text, input: codexInputOverride } : host.prepareInput(text); if (!(await host.turnTransport.ensureConnected())) return; if (!(await host.ensureRestoredThreadLoaded())) return; @@ -69,10 +70,10 @@ async function sendTurnText( host.addSystemMessage(plan.message); return; case "steer": - await steerCurrentTurn(host, localItemIds, plan, text, codexInputOverride, referencedThread); + await steerCurrentTurn(host, localItemIds, plan, text, prepared, referencedThread); return; case "start-thread-then-turn": - if (!(await startThreadForTurn(host, text))) return; + if (!(await startThreadForTurn(host, prepared.text))) return; break; case "start-turn": break; @@ -81,12 +82,11 @@ async function sendTurnText( if (!activeThreadId) return; if (!(await host.applyPendingThreadSettings())) return; - const codexInput = codexInputOverride ?? host.codexInput(text); optimisticUserId = localItemIds.next("local-user"); const optimistic = optimisticTurnStart({ id: optimisticUserId, - text, - codexInput, + text: prepared.text, + codexInput: prepared.input, referencedThread, }); host.stateStore.dispatch({ @@ -98,7 +98,7 @@ async function sendTurnText( const response = await host.turnTransport.startTurn({ threadId: activeThreadId, - input: codexInput, + input: prepared.input, clientUserMessageId: optimisticUserId, }); if (!response) { @@ -169,10 +169,9 @@ async function steerCurrentTurn( localItemIds: LocalIdSource, plan: Extract, text: string, - codexInputOverride?: CodexInput, + prepared: { text: string; input: CodexInput }, referencedThread?: ReferencedThreadMetadata, ): Promise { - const codexInput = codexInputOverride ?? host.codexInput(text); const localSteerId = localItemIds.next("local-steer"); host.setDraft("", { clearSuggestions: true }); @@ -180,7 +179,7 @@ async function steerCurrentTurn( const steered = await host.turnTransport.steerTurn({ threadId: plan.threadId, turnId: plan.turnId, - input: codexInput, + input: prepared.input, clientUserMessageId: localSteerId, }); if (!steered) return; @@ -189,10 +188,10 @@ async function steerCurrentTurn( type: "message-stream/item-added", item: localUserMessageItemFromInput({ id: localSteerId, - text, + text: prepared.text, turnId: plan.turnId, referencedThread, - codexInput, + codexInput: prepared.input, }), }); host.setStatus(STATUS_STEERED_CURRENT_TURN); diff --git a/src/features/chat/application/state/root-reducer.ts b/src/features/chat/application/state/root-reducer.ts index 24444aa9..a047422f 100644 --- a/src/features/chat/application/state/root-reducer.ts +++ b/src/features/chat/application/state/root-reducer.ts @@ -677,11 +677,30 @@ function composerSuggestionsEqual(left: readonly ComposerSuggestion[], right: re item.detail === other.detail && item.replacement === other.replacement && item.start === other.start && - item.appendSpaceOnInsert === other.appendSpaceOnInsert + item.appendSpaceOnInsert === other.appendSpaceOnInsert && + composerSuggestionSelectionContextEqual(item.selectionContext, other.selectionContext) ); }); } +function composerSuggestionSelectionContextEqual( + left: ComposerSuggestion["selectionContext"], + right: ComposerSuggestion["selectionContext"], +): boolean { + if (left === right) return true; + if (!left || !right) return false; + return ( + left.name === right.name && + left.path === right.path && + left.linktext === right.linktext && + left.text === right.text && + left.range.from.line === right.range.from.line && + left.range.from.ch === right.range.from.ch && + left.range.to.line === right.range.to.line && + left.range.to.ch === right.range.to.ch + ); +} + function patchChatState(state: ChatState, patch: Partial): ChatState { return patchObject(state, patch); } diff --git a/src/features/chat/host/composer-bundle.ts b/src/features/chat/host/composer-bundle.ts index 38e67827..13ea226e 100644 --- a/src/features/chat/host/composer-bundle.ts +++ b/src/features/chat/host/composer-bundle.ts @@ -8,6 +8,7 @@ import type { ChatMessageScrollController } from "../panel/surface/message-strea import type { ChatPanelEnvironment } from "./contracts"; import type { ChatPanelRuntimeSettingsActions } from "./runtime-bundle"; import type { ChatPanelThreadLifecycle } from "./thread-bundle"; +import { VaultComposerContextReferenceProvider } from "./vault-composer-context-reference-provider.obsidian"; import { VaultNoteCandidateProvider } from "./vault-note-candidate-provider.obsidian"; interface ChatPanelComposerHost { @@ -62,6 +63,7 @@ function createSessionComposerController( const { environment, stateStore } = host; return new ChatComposerController({ noteCandidateProvider: new VaultNoteCandidateProvider(environment.obsidian.app), + contextReferenceProvider: new VaultComposerContextReferenceProvider(environment.obsidian.app), sourcePath: () => environment.obsidian.app.workspace.getActiveFile()?.path ?? "", stateStore, viewId: environment.obsidian.viewId, diff --git a/src/features/chat/host/turn-bundle.ts b/src/features/chat/host/turn-bundle.ts index 29f3367c..eeea0cfc 100644 --- a/src/features/chat/host/turn-bundle.ts +++ b/src/features/chat/host/turn-bundle.ts @@ -126,7 +126,7 @@ export function createTurnBundle(host: ChatPanelTurnHost, input: ChatPanelTurnIn }; const reconnect = () => reconnectPanel(reconnectHost); const threadReferenceResolver = appServer.threadReferences({ - codexInput: (text) => composerController.codexInput(text), + prepareInput: (text) => composerController.preparedInput(text), addSystemMessage: status.addSystemMessage, setStatus: status.set, }); @@ -163,10 +163,12 @@ export function createTurnBundle(host: ChatPanelTurnHost, input: ChatPanelTurnIn }, composer: { codexInput: (text) => composerController.codexInput(text), + prepareInput: (text) => composerController.preparedInput(text), trimmedDraft: () => composerController.trimmedDraft, setDraft: (text, options) => { composerController.setDraft(text, options); }, + withPreservedContextReferences: (operation) => composerController.withPreservedContextReferences(operation), }, scroll: { showLatest: () => { diff --git a/src/features/chat/host/vault-composer-context-reference-provider.obsidian.ts b/src/features/chat/host/vault-composer-context-reference-provider.obsidian.ts new file mode 100644 index 00000000..77da28d1 --- /dev/null +++ b/src/features/chat/host/vault-composer-context-reference-provider.obsidian.ts @@ -0,0 +1,104 @@ +import type { App, EditorPosition, EventRef } from "obsidian"; +import { MarkdownView, TFile } from "obsidian"; + +import type { + ComposerContextRange, + ComposerContextReferenceProvider, + ComposerContextReferences, +} from "../application/composer/context-references"; +import { displayNameForFile, linktextForFile } from "./vault-note-links.obsidian"; + +interface EventSource { + offref?(ref: EventRef): void; +} + +export class VaultComposerContextReferenceProvider implements ComposerContextReferenceProvider { + private readonly unregisterEvents: (() => void)[] = []; + private lastMarkdownView: MarkdownView | null = null; + + constructor(private readonly app: App) { + this.registerEvent( + app.workspace, + app.workspace.on("active-leaf-change", () => { + this.refreshLastMarkdownView(); + }), + ); + this.registerEvent( + app.workspace, + app.workspace.on("file-open", () => { + this.refreshLastMarkdownView(); + }), + ); + this.refreshLastMarkdownView(); + } + + contextReferences(sourcePath: string): ComposerContextReferences { + const view = this.validLastMarkdownView(); + const activeFile = view?.file ?? this.activeFile(); + const activeNote = activeFile + ? { + name: displayNameForFile(activeFile), + path: activeFile.path, + linktext: linktextForFile(this.app, activeFile, sourcePath || activeFile.path), + } + : null; + return { + activeNote, + selection: view ? selectionContextReference(this.app, view, sourcePath) : null, + }; + } + + dispose(): void { + for (const unregister of this.unregisterEvents.splice(0)) { + unregister(); + } + } + + private registerEvent(source: EventSource, ref: EventRef): void { + this.unregisterEvents.push(() => { + source.offref?.(ref); + }); + } + + private refreshLastMarkdownView(): void { + const view = this.activeMarkdownView(); + if (view?.file) this.lastMarkdownView = view; + } + + private activeMarkdownView(): MarkdownView | null { + const workspace = this.app.workspace as Partial>; + return workspace.getActiveViewOfType?.(MarkdownView) ?? null; + } + + private activeFile(): TFile | null { + const workspace = this.app.workspace as Partial>; + return workspace.getActiveFile?.() ?? null; + } + + private validLastMarkdownView(): MarkdownView | null { + const view = this.lastMarkdownView; + if (!view?.file) return null; + return this.app.vault.getAbstractFileByPath(view.file.path) instanceof TFile ? view : null; + } +} + +function selectionContextReference(app: App, view: MarkdownView, sourcePath: string): ComposerContextReferences["selection"] { + const file = view.file; + if (!file) return null; + const text = view.editor.getSelection(); + if (!text.trim()) return null; + return { + name: displayNameForFile(file), + path: file.path, + linktext: linktextForFile(app, file, sourcePath || file.path), + range: editorSelectionRange(view.editor.getCursor("from"), view.editor.getCursor("to")), + text, + }; +} + +function editorSelectionRange(from: EditorPosition, to: EditorPosition): ComposerContextRange { + return { + from: { line: from.line, ch: from.ch }, + to: { line: to.line, ch: to.ch }, + }; +} diff --git a/src/features/chat/host/vault-note-candidate-provider.obsidian.ts b/src/features/chat/host/vault-note-candidate-provider.obsidian.ts index 3560825a..b440ebfc 100644 --- a/src/features/chat/host/vault-note-candidate-provider.obsidian.ts +++ b/src/features/chat/host/vault-note-candidate-provider.obsidian.ts @@ -3,6 +3,7 @@ import { stripHeadingForLink, TFile } from "obsidian"; import type { NoteCandidateProvider, WikiLinkMention } from "../application/composer/note-context"; import type { NoteCandidate } from "../application/composer/suggestions"; +import { displayNameForFile, linktextForFile } from "./vault-note-links.obsidian"; interface FileCandidate { basename: string; @@ -14,7 +15,7 @@ interface FileCandidate { } interface EventSource { - offref(ref: EventRef): void; + offref?(ref: EventRef): void; } export class VaultNoteCandidateProvider implements NoteCandidateProvider { @@ -71,7 +72,7 @@ export class VaultNoteCandidateProvider implements NoteCandidateProvider { private registerEvent(source: EventSource, ref: EventRef): void { this.unregisterEvents.push(() => { - source.offref(ref); + source.offref?.(ref); }); } @@ -93,18 +94,6 @@ export class VaultNoteCandidateProvider implements NoteCandidateProvider { } } -function linktextForFile(app: App, file: TFile, sourcePath: string): string { - const linktext = app.metadataCache.fileToLinktext(file, sourcePath, true); - const extension = file.extension.toLowerCase(); - return extension === "md" || extension.length === 0 || linktext.toLowerCase().endsWith(`.${extension}`) - ? linktext - : `${linktext}.${file.extension}`; -} - -function displayNameForFile(file: TFile): string { - return file.extension === "md" ? file.basename : file.name; -} - function noteHeadings(app: App, file: TFile): NoteCandidate["headings"] { return (app.metadataCache.getFileCache(file)?.headings ?? []).map((heading) => ({ heading: heading.heading, diff --git a/src/features/chat/host/vault-note-links.obsidian.ts b/src/features/chat/host/vault-note-links.obsidian.ts new file mode 100644 index 00000000..562c91db --- /dev/null +++ b/src/features/chat/host/vault-note-links.obsidian.ts @@ -0,0 +1,13 @@ +import type { App, TFile } from "obsidian"; + +export function linktextForFile(app: App, file: TFile, sourcePath: string): string { + const linktext = app.metadataCache.fileToLinktext(file, sourcePath, true); + const extension = file.extension.toLowerCase(); + return extension === "md" || extension.length === 0 || linktext.toLowerCase().endsWith(`.${extension}`) + ? linktext + : `${linktext}.${file.extension}`; +} + +export function displayNameForFile(file: TFile): string { + return file.extension === "md" ? file.basename : file.name; +} diff --git a/src/features/chat/panel/composer-controller.ts b/src/features/chat/panel/composer-controller.ts index b57cb81a..0ea48203 100644 --- a/src/features/chat/panel/composer-controller.ts +++ b/src/features/chat/panel/composer-controller.ts @@ -1,6 +1,11 @@ import type { CodexInput } from "../../../domain/chat/input"; import { isComposerSendKey, type SendShortcut } from "../../../shared/ui/keyboard"; import type { ComposerBoundaryScrollAction } from "../application/composer/boundary-scroll"; +import { + type ComposerContextReferenceProvider, + type SelectionContextReference, + selectionContextReferenceMarker, +} from "../application/composer/context-references"; import type { NoteCandidateProvider } from "../application/composer/note-context"; import { activeComposerSuggestions, @@ -10,7 +15,11 @@ import { type NoteCandidate, nextComposerSuggestionIndex, } from "../application/composer/suggestions"; -import { userInputWithWikiLinkMentionsAndSkills } from "../application/composer/wikilink-context"; +import { + type PreparedComposerInput, + preparedUserInputWithWikiLinkMentionsSkillsAndContext, + userInputWithWikiLinkMentionsAndSkills, +} from "../application/composer/wikilink-context"; import type { ChatAction, ChatState } from "../application/state/root-reducer"; import type { ChatStateStore } from "../application/state/store"; import type { ComposerCallbacks, ComposerShellProps } from "../ui/composer"; @@ -29,6 +38,7 @@ import type { ChatPanelComposerProjection } from "./surface/composer-projection" export interface ChatComposerControllerOptions { noteCandidateProvider: NoteCandidateProvider; + contextReferenceProvider: ComposerContextReferenceProvider; sourcePath: () => string; stateStore: ChatStateStore; viewId: string; @@ -51,6 +61,8 @@ export interface ChatComposerRenderActions { export class ChatComposerController { private composer: HTMLTextAreaElement | null = null; + private selectionContextSnapshots: SelectionContextReference[] = []; + private preservedSelectionContextSnapshots: readonly SelectionContextReference[] | null = null; constructor(private readonly options: ChatComposerControllerOptions) {} @@ -92,6 +104,7 @@ export class ChatComposerController { }; setDraft(text: string, options: { focus?: boolean; clearSuggestions?: boolean } = {}): void { + this.pruneSelectionContextSnapshots(text); this.dispatch({ type: "composer/draft-set", draft: text, @@ -112,6 +125,7 @@ export class ChatComposerController { dispose(): void { this.composer = null; this.options.noteCandidateProvider.dispose(); + this.options.contextReferenceProvider.dispose(); } refreshSuggestions(): void { @@ -127,6 +141,26 @@ export class ChatComposerController { ); } + preparedInput(text: string): PreparedComposerInput { + const sourcePath = this.options.sourcePath(); + return preparedUserInputWithWikiLinkMentionsSkillsAndContext( + text, + (target) => this.options.noteCandidateProvider.resolveMention(target, sourcePath), + this.state.connection.availableSkills, + this.contextReferences(text), + ); + } + + async withPreservedContextReferences(operation: () => Promise): Promise { + const previous = this.preservedSelectionContextSnapshots; + this.preservedSelectionContextSnapshots = [...this.selectionContextSnapshots]; + try { + return await operation(); + } finally { + this.preservedSelectionContextSnapshots = previous; + } + } + private handleSuggestionKeydown(event: KeyboardEvent): boolean { if (event.isComposing) return false; const state = this.state; @@ -189,7 +223,7 @@ export class ChatComposerController { state.threadList.listedThreads, state.connection.availableModels, this.options.currentModelForSuggestions(), - { activeThreadId: state.activeThread.id }, + { activeThreadId: state.activeThread.id, contextReferences: this.contextReferences() }, ); this.dispatchSuggestions({ @@ -200,6 +234,7 @@ export class ChatComposerController { } private handleInput(value: string): void { + this.pruneSelectionContextSnapshots(value); const suggestionState = this.inputSuggestionState(); this.dispatch({ type: "composer/input-set", @@ -231,7 +266,7 @@ export class ChatComposerController { state.threadList.listedThreads, state.connection.availableModels, this.options.currentModelForSuggestions(), - { activeThreadId: state.activeThread.id }, + { activeThreadId: state.activeThread.id, contextReferences: this.contextReferences() }, ); return { suggestions, @@ -251,6 +286,8 @@ export class ChatComposerController { if (!source) return; const insertion = applyComposerSuggestionInsertion(source.value, source.cursor, suggestion, { activation }); + if (suggestion.selectionContext) this.rememberSelectionContextSnapshot(suggestion.selectionContext); + this.pruneSelectionContextSnapshots(insertion.value); this.dispatch({ type: "composer/draft-set", draft: insertion.value, clearSuggestions: true }); this.options.onDraftChange(); @@ -282,6 +319,30 @@ export class ChatComposerController { return [...this.options.noteCandidateProvider.candidates(this.options.sourcePath())]; } + private contextReferences(text: string | null = null) { + const references = this.options.contextReferenceProvider.contextReferences(this.options.sourcePath()); + const availableSnapshots = this.preservedSelectionContextSnapshots ?? this.selectionContextSnapshots; + const selectionSnapshots = + text === null + ? availableSnapshots + : availableSnapshots.filter((selection) => text.includes(selectionContextReferenceMarker(selection))); + return { ...references, selectionSnapshots }; + } + + private rememberSelectionContextSnapshot(selection: SelectionContextReference): void { + const marker = selectionContextReferenceMarker(selection); + this.selectionContextSnapshots = [ + ...this.selectionContextSnapshots.filter((snapshot) => selectionContextReferenceMarker(snapshot) !== marker), + selection, + ]; + } + + private pruneSelectionContextSnapshots(text: string): void { + this.selectionContextSnapshots = this.selectionContextSnapshots.filter((selection) => + text.includes(selectionContextReferenceMarker(selection)), + ); + } + private composerCallbacks(actions: ChatComposerRenderActions): ComposerCallbacks { return { onInput: (value) => { diff --git a/tests/features/chat/app-server/transports.test.ts b/tests/features/chat/app-server/transports.test.ts index 4632ee5f..1d429e98 100644 --- a/tests/features/chat/app-server/transports.test.ts +++ b/tests/features/chat/app-server/transports.test.ts @@ -280,7 +280,7 @@ describe("chat app-server transports", () => { const setStatus = vi.fn(); const resolver = createThreadReferenceResolver({ currentClient: () => client, - codexInput: (text) => textInput(text), + prepareInput: (text) => ({ text, input: textInput(text) }), addSystemMessage: vi.fn(), setStatus, }); @@ -301,6 +301,7 @@ describe("chat app-server transports", () => { type: "text", text: expect.stringContaining("Reference thread history:"), }); + expect(result?.text).toBe("summarize"); expect(result?.referencedThread).toMatchObject({ title: "Other", includedTurns: 1, turnLimit: 20 }); expect(setStatus).toHaveBeenCalledWith("Referencing 019abcde (1/20 turns)."); }); diff --git a/tests/features/chat/application/composer/suggestions.test.ts b/tests/features/chat/application/composer/suggestions.test.ts index eee69b4b..3d225268 100644 --- a/tests/features/chat/application/composer/suggestions.test.ts +++ b/tests/features/chat/application/composer/suggestions.test.ts @@ -216,6 +216,39 @@ describe("composer suggestions", () => { it("uses one active suggestion family at a time", () => { expect(activeComposerSuggestions("[[bet", notes, [])[0]?.replacement).toBe("[[Beta Note]]"); + expect( + activeComposerSuggestions("@act", notes, [], [], [], null, { + contextReferences: { + activeNote: { name: "Beta Note", path: "topics/Beta Note.md", linktext: "Beta Note" }, + selection: null, + }, + })[0]?.replacement, + ).toBe("[[Beta Note]]"); + expect( + activeComposerSuggestions("@sel", notes, [], [], [], null, { + contextReferences: { + activeNote: null, + selection: { + name: "Beta Note", + path: "topics/Beta Note.md", + linktext: "Beta Note", + range: { from: { line: 41, ch: 4 }, to: { line: 46, ch: 0 } }, + text: "selected", + }, + }, + })[0], + ).toMatchObject({ + display: "Selection", + detail: "topics/Beta Note.md L42:C5-L47:C1", + replacement: "[[Beta Note]] (L42:C5-L47:C1)", + selectionContext: { + name: "Beta Note", + path: "topics/Beta Note.md", + linktext: "Beta Note", + range: { from: { line: 41, ch: 4 }, to: { line: 46, ch: 0 } }, + text: "selected", + }, + }); expect(activeComposerSuggestions("/pla", notes, [])[0]?.replacement).toBe("/plan"); expect(activeComposerSuggestions("/rea", notes, [])[0]?.replacement).toBe("/reasoning"); expect(activeComposerSuggestions("/sta", notes, [])[0]?.replacement).toBe("/status"); diff --git a/tests/features/chat/application/composer/wikilink-context.test.ts b/tests/features/chat/application/composer/wikilink-context.test.ts index 3518e7d7..46fb3500 100644 --- a/tests/features/chat/application/composer/wikilink-context.test.ts +++ b/tests/features/chat/application/composer/wikilink-context.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; -import { userInputWithWikiLinkMentionsAndSkills } from "../../../../../src/features/chat/application/composer/wikilink-context"; +import { + preparedUserInputWithWikiLinkMentionsSkillsAndContext, + userInputWithWikiLinkMentionsAndSkills, +} from "../../../../../src/features/chat/application/composer/wikilink-context"; const wikilinkContext = (...mappings: string[]) => ({ type: "additionalContext" as const, @@ -147,4 +150,61 @@ describe("wikilink context", () => { { type: "skill", name: "First", path: "/skills/first/SKILL.md" }, ]); }); + + it("leaves bare context references as raw text", () => { + const text = "整理して @active-note and @selection"; + const prepared = preparedUserInputWithWikiLinkMentionsSkillsAndContext( + text, + (target) => (target === "notes/Alpha" ? { name: "Alpha", path: "notes/Alpha.md" } : null), + [], + { + activeNote: { name: "Alpha", path: "notes/Alpha.md", linktext: "notes/Alpha" }, + selection: { + name: "Alpha", + path: "notes/Alpha.md", + linktext: "notes/Alpha", + range: { from: { line: 41, ch: 4 }, to: { line: 46, ch: 0 } }, + text: "selected text", + }, + }, + ); + + expect(prepared.text).toBe(text); + expect(prepared.input).toEqual([{ type: "text", text }]); + }); + + it("attaches completed selection snapshots without depending on the current editor selection", () => { + const prepared = preparedUserInputWithWikiLinkMentionsSkillsAndContext( + "整理して [[notes/Alpha]] (L42:C5-L47:C1)", + (target) => (target === "notes/Alpha" ? { name: "Alpha", path: "notes/Alpha.md" } : null), + [], + { + activeNote: null, + selection: { + name: "Beta", + path: "notes/Beta.md", + linktext: "notes/Beta", + range: { from: { line: 0, ch: 0 }, to: { line: 0, ch: 4 } }, + text: "current selection", + }, + selectionSnapshots: [ + { + name: "Alpha", + path: "notes/Alpha.md", + linktext: "notes/Alpha", + range: { from: { line: 41, ch: 4 }, to: { line: 46, ch: 0 } }, + text: "completed selection", + }, + ], + }, + ); + + expect(prepared.input).toContainEqual({ + type: "additionalContext", + key: "codex_panel_obsidian_context", + kind: "untrusted", + value: + "Referenced Obsidian selections for the current user input:\n- [[notes/Alpha]] (L42:C5-L47:C1) -> notes/Alpha.md L42:C5-L47:C1\n\n[[notes/Alpha]] (L42:C5-L47:C1):\ncompleted selection", + }); + }); }); diff --git a/tests/features/chat/application/conversation/composer-submit-actions.test.ts b/tests/features/chat/application/conversation/composer-submit-actions.test.ts index 05765815..fc148398 100644 --- a/tests/features/chat/application/conversation/composer-submit-actions.test.ts +++ b/tests/features/chat/application/conversation/composer-submit-actions.test.ts @@ -31,6 +31,7 @@ function createHost(draft: string) { return draft; }, setDraft, + withPreservedContextReferences: (operation: () => Promise) => operation(), }, slashCommandExecutor: { execute }, turnSubmission: { sendTurnText }, @@ -77,6 +78,31 @@ describe("submitComposer", () => { expect(sendTurnText).toHaveBeenCalledWith("hello", undefined, undefined); }); + it("preserves composer context references until slash command send results are submitted", async () => { + const { host, execute, sendTurnText } = createHost("/refer Other [[Note]] (L1:C1-L1:C2)"); + let preserving = false; + host.composer.withPreservedContextReferences = async (operation) => { + preserving = true; + try { + return await operation(); + } finally { + preserving = false; + } + }; + execute.mockImplementation(async () => { + expect(preserving).toBe(true); + return { sendText: "[[Note]] (L1:C1-L1:C2)", sendInput: [{ type: "text", text: "referenced input" }] }; + }); + sendTurnText.mockImplementation(async () => { + expect(preserving).toBe(true); + }); + + await submitComposer(host); + + expect(preserving).toBe(false); + expect(sendTurnText).toHaveBeenCalledWith("[[Note]] (L1:C1-L1:C2)", [{ type: "text", text: "referenced input" }], undefined); + }); + it("does not execute connection-dependent slash commands when connection fails", async () => { const { host, ensureConnected, execute, setDraft } = createHost("/clear"); ensureConnected.mockResolvedValue(false); diff --git a/tests/features/chat/application/conversation/slash-command-execution.test.ts b/tests/features/chat/application/conversation/slash-command-execution.test.ts index 19fe8290..8898cf9d 100644 --- a/tests/features/chat/application/conversation/slash-command-execution.test.ts +++ b/tests/features/chat/application/conversation/slash-command-execution.test.ts @@ -16,6 +16,7 @@ function context(overrides: Partial = {}): SlashCo startThreadForGoal: vi.fn().mockResolvedValue("thread-new"), resumeThread: vi.fn().mockResolvedValue(undefined), referThread: vi.fn().mockResolvedValue({ + text: "referenced", input: [{ type: "text", text: "referenced" }], referencedThread: { threadId: "thread-2", title: "Referenced", includedTurns: 1, turnLimit: 20 }, }), @@ -143,7 +144,7 @@ describe("slash commands", () => { const referencedThread = { threadId: "thread-alpha", title: "Alpha", includedTurns: 2, turnLimit: 20 }; const ctx = context({ listedThreads: [thread({ id: "thread-current", name: "Current" }), target], - referThread: vi.fn().mockResolvedValue({ input, referencedThread }), + referThread: vi.fn().mockResolvedValue({ text: "質問です", input, referencedThread }), }); const result = await executeSlashCommand("refer", "thread-alpha 質問です", ctx); diff --git a/tests/features/chat/application/conversation/slash-command-executor.test.ts b/tests/features/chat/application/conversation/slash-command-executor.test.ts index 695e7b11..89b2bd65 100644 --- a/tests/features/chat/application/conversation/slash-command-executor.test.ts +++ b/tests/features/chat/application/conversation/slash-command-executor.test.ts @@ -156,6 +156,7 @@ describe("executeSlashCommandWithState", () => { threads: [thread("019abcde-0000-7000-8000-000000000001", "Other")], }); referThread.mockResolvedValue({ + text: "prepared summarize", input: textInput("referenced summarize"), referencedThread: { threadId: "019abcde-0000-7000-8000-000000000001", @@ -167,7 +168,7 @@ describe("executeSlashCommandWithState", () => { const result = await executeSlashCommandWithState(host, "refer", "Other summarize"); - expect(result?.sendText).toBe("summarize"); + expect(result?.sendText).toBe("prepared summarize"); expect(result?.sendInput).toEqual(textInput("referenced summarize")); expect(result?.referencedThread?.title).toBe("Other"); }); diff --git a/tests/features/chat/application/conversation/turn-submission-actions.test.ts b/tests/features/chat/application/conversation/turn-submission-actions.test.ts index f93ee730..4db45a4a 100644 --- a/tests/features/chat/application/conversation/turn-submission-actions.test.ts +++ b/tests/features/chat/application/conversation/turn-submission-actions.test.ts @@ -47,7 +47,7 @@ function createHost(overrides: TurnSubmissionHostOverrides = {}) { notifyActiveThreadIdentityChanged: vi.fn(), resetThreadTurnPresence: vi.fn(), applyPendingThreadSettings: vi.fn().mockResolvedValue(true), - codexInput: vi.fn((text: string) => textInput(text)), + prepareInput: vi.fn((text: string) => ({ text, input: textInput(text) })), setDraft: vi.fn(), setStatus: vi.fn(), addSystemMessage: vi.fn(), @@ -107,6 +107,38 @@ describe("TurnSubmissionActions", () => { expect(applyPendingThreadSettings.mock.invocationCallOrder[0]).toBeLessThan(startTurn.mock.invocationCallOrder[0] ?? 0); }); + it("uses prepared visible text for optimistic history and app-server input", async () => { + const { host, startTurn, stateStore } = createHost({ + prepareInput: vi.fn(() => ({ + text: "fix [[notes/Alpha]] (L42:C5-L47:C1)", + input: [ + { type: "text", text: "fix [[notes/Alpha]] (L42:C5-L47:C1)" }, + { type: "mention", name: "Alpha", path: "notes/Alpha.md" }, + { type: "additionalContext", key: "codex_panel_obsidian_context", kind: "untrusted", value: "selected text" }, + ] satisfies CodexInput, + })), + }); + resumeThread(stateStore); + const actions = createTurnSubmissionActions(host); + + await actions.sendTurnText("fix @selection"); + + expect(startTurn).toHaveBeenCalledWith({ + threadId: "thread", + input: [ + { type: "text", text: "fix [[notes/Alpha]] (L42:C5-L47:C1)" }, + { type: "mention", name: "Alpha", path: "notes/Alpha.md" }, + { type: "additionalContext", key: "codex_panel_obsidian_context", kind: "untrusted", value: "selected text" }, + ], + clientUserMessageId: expect.any(String), + }); + expect(chatStateMessageStreamItems(stateStore.getState())[0]).toMatchObject({ + kind: "message", + text: "fix [[notes/Alpha]] (L42:C5-L47:C1)", + mentionedFiles: [{ name: "Alpha", path: "notes/Alpha.md" }], + }); + }); + it("does not restore stale drafts or report stale start failures after the active thread changes", async () => { const { host, startTurn, stateStore } = createHost(); resumeThread(stateStore); diff --git a/tests/features/chat/host/turn-bundle.test.ts b/tests/features/chat/host/turn-bundle.test.ts index f8816beb..27e8d682 100644 --- a/tests/features/chat/host/turn-bundle.test.ts +++ b/tests/features/chat/host/turn-bundle.test.ts @@ -91,6 +91,8 @@ function turnBundleFixture(options: { stateStore?: ReturnType operation()), hasFocus: vi.fn(() => false), focusComposer: vi.fn(), }, diff --git a/tests/features/chat/host/vault-note-candidate-provider.test.ts b/tests/features/chat/host/vault-note-candidate-provider.test.ts index eadcc723..1fe4fd6f 100644 --- a/tests/features/chat/host/vault-note-candidate-provider.test.ts +++ b/tests/features/chat/host/vault-note-candidate-provider.test.ts @@ -1,6 +1,7 @@ import { type App, type EventRef, TFile } from "obsidian"; import { describe, expect, it, vi } from "vitest"; +import { VaultComposerContextReferenceProvider } from "../../../../src/features/chat/host/vault-composer-context-reference-provider.obsidian"; import { VaultNoteCandidateProvider } from "../../../../src/features/chat/host/vault-note-candidate-provider.obsidian"; describe("VaultNoteCandidateProvider", () => { @@ -169,6 +170,31 @@ describe("VaultNoteCandidateProvider", () => { expect(provider.resolveMention("Bases/Projects.base", "Daily/Today.md")).toEqual({ name: "Projects", path: "Bases/Projects.base" }); expect(getFirstLinkpathDest).toHaveBeenCalledWith("Bases/Projects.base", "Daily/Today.md"); }); + + it("returns active note and selection references from the last markdown view", () => { + const file = tFile("notes/Alpha.md", "Alpha"); + const app = appFixture({ + activeView: markdownView(file, { + selection: "selected text", + from: { line: 2, ch: 4 }, + to: { line: 3, ch: 1 }, + }), + abstractFiles: new Map([["notes/Alpha.md", file]]), + linktexts: new Map([["notes/Alpha.md", "Alpha"]]), + }); + const provider = new VaultComposerContextReferenceProvider(app); + + expect(provider.contextReferences("Inbox.md")).toEqual({ + activeNote: { name: "Alpha", path: "notes/Alpha.md", linktext: "Alpha" }, + selection: { + name: "Alpha", + path: "notes/Alpha.md", + linktext: "Alpha", + range: { from: { line: 2, ch: 4 }, to: { line: 3, ch: 1 } }, + text: "selected text", + }, + }); + }); }); interface AppFixture extends App { @@ -187,15 +213,17 @@ function appFixture( linktexts?: Map; fileToLinktext?: (file: TFile, sourcePath: string, omitMdExtension?: boolean) => string; headings?: Map; + activeFile?: TFile | null; + activeView?: unknown; } = {}, ): AppFixture { - const refs: { source: "vault" | "metadata"; name: string; callback: () => void; ref: EventRef }[] = []; + const refs: { source: "vault" | "metadata" | "workspace"; name: string; callback: () => void; ref: EventRef }[] = []; const offref = vi.fn((ref: EventRef) => { const index = refs.findIndex((event) => event.ref === ref); if (index !== -1) refs.splice(index, 1); }); const on = - (source: "vault" | "metadata") => + (source: "vault" | "metadata" | "workspace") => (name: string, callback: () => void): EventRef => { const ref = { id: `${source}:${name}:${refs.length.toString()}` } as unknown as EventRef; refs.push({ source, name, callback, ref }); @@ -209,6 +237,10 @@ function appFixture( } }, workspace: { + on: on("workspace"), + offref, + getActiveFile: () => options.activeFile ?? null, + getActiveViewOfType: () => options.activeView ?? null, getLastOpenFiles: () => options.lastOpenFiles ?? [], }, metadataCache: { @@ -231,6 +263,19 @@ function appFixture( } as unknown as AppFixture; } +function markdownView( + file: TFile, + selection: { selection: string; from: { line: number; ch: number }; to: { line: number; ch: number } }, +): unknown { + return { + file, + editor: { + getSelection: () => selection.selection, + getCursor: (kind: "from" | "to") => (kind === "from" ? selection.from : selection.to), + }, + }; +} + function tFile(path: string, basename: string): TFile { const name = path.split("/").pop() ?? path; const extensionStart = name.lastIndexOf("."); diff --git a/tests/features/chat/panel/composer-controller.test.ts b/tests/features/chat/panel/composer-controller.test.ts index 6cb419d7..29a41374 100644 --- a/tests/features/chat/panel/composer-controller.test.ts +++ b/tests/features/chat/panel/composer-controller.test.ts @@ -3,6 +3,7 @@ import { h } from "preact"; import { describe, expect, it, vi } from "vitest"; import type { SkillMetadata } from "../../../../src/domain/catalog/metadata"; +import type { ComposerContextReferenceProvider } from "../../../../src/features/chat/application/composer/context-references"; import type { NoteCandidateProvider } from "../../../../src/features/chat/application/composer/note-context"; import type { ChatStateStore } from "../../../../src/features/chat/application/state/store"; import { createChatStateStore } from "../../../../src/features/chat/application/state/store"; @@ -33,6 +34,7 @@ describe("ChatComposerController", () => { })); const controller = new ChatComposerController({ noteCandidateProvider: noteProvider(), + contextReferenceProvider: contextProvider(), sourcePath: () => "", stateStore, viewId: "view", @@ -67,6 +69,7 @@ describe("ChatComposerController", () => { }); const controller = new ChatComposerController({ noteCandidateProvider: noteProvider(), + contextReferenceProvider: contextProvider(), sourcePath: () => "", stateStore, viewId: "view", @@ -95,6 +98,89 @@ describe("ChatComposerController", () => { expect(parent.querySelector(".codex-panel__composer-suggestion")?.textContent).toContain("/"); }); + it("freezes selection context when inserting the selection suggestion", async () => { + const stateStore = createChatStateStore(); + const parent = document.createElement("div"); + let references = { + activeNote: null, + selection: { + name: "Alpha", + path: "notes/Alpha.md", + linktext: "notes/Alpha", + range: { from: { line: 41, ch: 4 }, to: { line: 46, ch: 0 } }, + text: "initial selection", + }, + }; + let controller: ChatComposerController | null = null; + const renderShell = vi.fn(() => { + if (!controller) throw new Error("Expected controller."); + renderComposerController(parent, controller, stateStore); + }); + controller = new ChatComposerController({ + noteCandidateProvider: noteProvider({ + resolveMention: (target) => (target === "notes/Alpha" ? { name: "Alpha", path: "notes/Alpha.md" } : null), + }), + contextReferenceProvider: contextProvider(() => references), + sourcePath: () => "", + stateStore, + viewId: "view", + sendShortcut: () => "enter", + scrollThreadFromComposerEdges: () => false, + threadScrollFromComposer: vi.fn(), + canInterrupt: (_state) => false, + composerProjection: defaultComposerProjection, + currentModelForSuggestions: () => null, + togglePlan: vi.fn(), + toggleAutoReview: vi.fn(), + toggleFast: vi.fn(), + onDraftChange: vi.fn(), + onHeightChange: vi.fn(), + }); + stateStore.subscribe(renderShell); + + renderShell(); + setTextAreaValue(composer(parent), "@sel"); + composer(parent).setSelectionRange(4, 4); + composer(parent).dispatchEvent(new Event("input", { bubbles: true })); + composer(parent).dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" })); + references = { + activeNote: null, + selection: { + name: "Beta", + path: "notes/Beta.md", + linktext: "notes/Beta", + range: { from: { line: 0, ch: 0 }, to: { line: 0, ch: 4 } }, + text: "changed selection", + }, + }; + + const prepared = controller.preparedInput(composer(parent).value); + const completedSelectionReference = composer(parent).value; + + expect(composer(parent).value).toBe("[[notes/Alpha]] (L42:C5-L47:C1)"); + expect(prepared.input).toContainEqual({ + type: "additionalContext", + key: "codex_panel_obsidian_context", + kind: "untrusted", + value: + "Referenced Obsidian selections for the current user input:\n- [[notes/Alpha]] (L42:C5-L47:C1) -> notes/Alpha.md L42:C5-L47:C1\n\n[[notes/Alpha]] (L42:C5-L47:C1):\ninitial selection", + }); + + await controller.withPreservedContextReferences(async () => { + controller.setDraft("", { clearSuggestions: true }); + expect(controller.preparedInput(completedSelectionReference).input).toContainEqual({ + type: "additionalContext", + key: "codex_panel_obsidian_context", + kind: "untrusted", + value: + "Referenced Obsidian selections for the current user input:\n- [[notes/Alpha]] (L42:C5-L47:C1) -> notes/Alpha.md L42:C5-L47:C1\n\n[[notes/Alpha]] (L42:C5-L47:C1):\ninitial selection", + }); + }); + expect(controller.preparedInput(completedSelectionReference).input).not.toContainEqual( + expect.objectContaining({ key: "codex_panel_obsidian_context" }), + ); + }); + it("rerenders suggestion selection from keyboard navigation", () => { const stateStore = createChatStateStore(); const parent = document.createElement("div"); @@ -105,6 +191,7 @@ describe("ChatComposerController", () => { }); controller = new ChatComposerController({ noteCandidateProvider: noteProvider(), + contextReferenceProvider: contextProvider(), sourcePath: () => "", stateStore, viewId: "view", @@ -151,6 +238,7 @@ describe("ChatComposerController", () => { }); controller = new ChatComposerController({ noteCandidateProvider: noteProvider(), + contextReferenceProvider: contextProvider(), sourcePath: () => "", stateStore, viewId: "view", @@ -190,6 +278,7 @@ describe("ChatComposerController", () => { const togglePlan = vi.fn(); const controller = new ChatComposerController({ noteCandidateProvider: noteProvider(), + contextReferenceProvider: contextProvider(), sourcePath: () => "", stateStore, viewId: "view", @@ -220,6 +309,7 @@ describe("ChatComposerController", () => { const submit = vi.fn(); const controller = new ChatComposerController({ noteCandidateProvider: noteProvider(), + contextReferenceProvider: contextProvider(), sourcePath: () => "", stateStore, viewId: "view", @@ -248,6 +338,7 @@ describe("ChatComposerController", () => { const threadScrollFromComposer = vi.fn(); const controller = new ChatComposerController({ noteCandidateProvider: noteProvider(), + contextReferenceProvider: contextProvider(), sourcePath: () => "", stateStore, viewId: "view", @@ -280,6 +371,7 @@ describe("ChatComposerController", () => { const threadScrollFromComposer = vi.fn(); const controller = new ChatComposerController({ noteCandidateProvider: noteProvider(), + contextReferenceProvider: contextProvider(), sourcePath: () => "", stateStore, viewId: "view", @@ -316,6 +408,7 @@ describe("ChatComposerController", () => { const threadScrollFromComposer = vi.fn(); const controller = new ChatComposerController({ noteCandidateProvider: noteProvider(), + contextReferenceProvider: contextProvider(), sourcePath: () => "", stateStore, viewId: "view", @@ -348,6 +441,7 @@ describe("ChatComposerController", () => { const parent = document.createElement("div"); const controller = new ChatComposerController({ noteCandidateProvider: noteProvider(), + contextReferenceProvider: contextProvider(), sourcePath: () => "", stateStore, viewId: "view", @@ -377,11 +471,21 @@ describe("ChatComposerController", () => { }); }); -function noteProvider(): NoteCandidateProvider { +function noteProvider(overrides: Partial = {}): NoteCandidateProvider { return { candidates: () => [], resolveMention: () => null, dispose: vi.fn(), + ...overrides, + }; +} + +function contextProvider( + contextReferences: ComposerContextReferenceProvider["contextReferences"] = () => ({ activeNote: null, selection: null }), +): ComposerContextReferenceProvider { + return { + contextReferences, + dispose: vi.fn(), }; }