Prototype Obsidian context references

This commit is contained in:
murashit 2026-06-28 18:25:59 +09:00
parent 7c86d89810
commit 25bda423cc
27 changed files with 747 additions and 61 deletions

View file

@ -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.

View file

@ -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) {

View file

@ -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);
},

View file

@ -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)}`;
}

View file

@ -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,

View file

@ -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<string>();
@ -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<string>();
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<string>();
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<string>();
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<string>();

View file

@ -14,6 +14,7 @@ export interface ComposerSubmitActionsHost {
composer: {
readonly trimmedDraft: string;
setDraft(text: string, options?: { clearSuggestions?: boolean; focus?: boolean }): void;
withPreservedContextReferences<T>(operation: () => Promise<T>): Promise<T>;
};
slashCommandExecutor: {
execute(command: SlashCommandName, args: string): Promise<SlashCommandExecutionResult | undefined>;
@ -55,15 +56,17 @@ async function sendMessage(host: ComposerSubmitActionsHost): Promise<void> {
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;
}

View file

@ -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: <T>(operation: () => Promise<T>) => Promise<T>;
};
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),

View file

@ -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) {

View file

@ -24,7 +24,7 @@ export interface TurnSubmissionActionsHost {
notifyActiveThreadIdentityChanged: () => void;
resetThreadTurnPresence: (hadTurns: boolean) => void;
applyPendingThreadSettings: () => Promise<boolean>;
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<void> {
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<TurnSubmissionPlan, { kind: "steer" }>,
text: string,
codexInputOverride?: CodexInput,
prepared: { text: string; input: CodexInput },
referencedThread?: ReferencedThreadMetadata,
): Promise<void> {
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);

View file

@ -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>): ChatState {
return patchObject(state, patch);
}

View file

@ -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,

View file

@ -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: () => {

View file

@ -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<Pick<App["workspace"], "getActiveViewOfType">>;
return workspace.getActiveViewOfType?.(MarkdownView) ?? null;
}
private activeFile(): TFile | null {
const workspace = this.app.workspace as Partial<Pick<App["workspace"], "getActiveFile">>;
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 },
};
}

View file

@ -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,

View file

@ -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;
}

View file

@ -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<T>(operation: () => Promise<T>): Promise<T> {
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) => {

View file

@ -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).");
});

View file

@ -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");

View file

@ -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",
});
});
});

View file

@ -31,6 +31,7 @@ function createHost(draft: string) {
return draft;
},
setDraft,
withPreservedContextReferences: <T>(operation: () => Promise<T>) => 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);

View file

@ -16,6 +16,7 @@ function context(overrides: Partial<SlashCommandExecutionContext> = {}): 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);

View file

@ -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");
});

View file

@ -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);

View file

@ -91,6 +91,8 @@ function turnBundleFixture(options: { stateStore?: ReturnType<typeof createChatS
},
setDraft: vi.fn(),
codexInput: vi.fn(),
preparedInput: vi.fn(),
withPreservedContextReferences: vi.fn((operation) => operation()),
hasFocus: vi.fn(() => false),
focusComposer: vi.fn(),
},

View file

@ -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<string, string>;
fileToLinktext?: (file: TFile, sourcePath: string, omitMdExtension?: boolean) => string;
headings?: Map<string, { heading: string; level: number }[]>;
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(".");

View file

@ -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> = {}): NoteCandidateProvider {
return {
candidates: () => [],
resolveMention: () => null,
dispose: vi.fn(),
...overrides,
};
}
function contextProvider(
contextReferences: ComposerContextReferenceProvider["contextReferences"] = () => ({ activeNote: null, selection: null }),
): ComposerContextReferenceProvider {
return {
contextReferences,
dispose: vi.fn(),
};
}