Add Obsidian tag composer completions

This commit is contained in:
murashit 2026-07-07 19:57:31 +09:00
parent eeece5e14d
commit 4f6732da5d
12 changed files with 312 additions and 6 deletions

View file

@ -48,7 +48,7 @@ The composer treats Obsidian content as prompt context. It suggests vault files
For context outside the current composer, use `/refer <thread> <message>` to send a message with recent turns from another Codex thread, or `/clip <url> [message]` to save a readable Markdown clipping from a web page and send a wikilink to the saved note.
Completions cover slash commands, `$skill-name` skills, recent threads, models, reasoning levels, and permission profiles. Use `/help` for the current command list.
Completions cover slash commands, `$skill-name` skills, Obsidian tags, recent threads, models, reasoning levels, and permission profiles. Use `/help` for the current command list.
While a turn is running, the panel streams the conversation, reasoning, tool activity, file changes, plans, and agent activity. You can answer Codex questions, approve commands, respond to requests, steer or interrupt the turn, and manage active goals above the message stream.

View file

@ -7,6 +7,7 @@ export interface WikiLinkMention {
export interface NoteCandidateProvider {
candidates(sourcePath: string): readonly NoteCandidate[];
tags(): readonly string[];
resolveMention(target: string, sourcePath: string): WikiLinkMention | null;
dispose(): void;
}

View file

@ -31,6 +31,7 @@ export interface ComposerSuggestionOptions {
activeThreadId?: string | null;
contextReferences?: ComposerContextReferences;
permissionProfiles?: readonly RuntimePermissionProfileSummary[];
tagCandidates?: readonly string[] | (() => readonly string[]);
}
export interface NoteCandidate {
@ -107,6 +108,7 @@ export function activeComposerSuggestions(
return (
activeWikiLinkSuggestions(beforeCursor, notes) ??
activeContextReferenceSuggestions(beforeCursor, options.contextReferences) ??
activeTagSuggestions(beforeCursor, options.tagCandidates ?? []) ??
activeSlashSubcommandSuggestions(beforeCursor) ??
activeThreadCommandSuggestions(beforeCursor, threads, options.activeThreadId ?? null) ??
modelOverrideSuggestions(beforeCursor, models) ??
@ -194,6 +196,61 @@ function activeWikiLinkSuggestions(beforeCursor: string, notes: NoteCandidate[])
return findWikiLinkSuggestions(queryText, start, notes);
}
function activeTagSuggestions(
beforeCursor: string,
tagCandidates: readonly string[] | (() => readonly string[]),
): ComposerSuggestion[] | null {
const match = /(^|[\s[{])#([^\s\]})#]{0,120})$/.exec(beforeCursor);
if (match?.index === undefined) return null;
const prefix = match[1];
const rawQuery = match[2];
if (prefix === undefined || rawQuery === undefined) return null;
const query = normalizeTag(rawQuery).toLowerCase();
const normalizedTags = normalizedUniqueTags(tagCandidateList(tagCandidates));
if (query.length > 0 && normalizedTags.some((tag) => tag.toLowerCase() === query)) return null;
const start = match.index + prefix.length;
return normalizedTags
.map((tag) => ({ tag, lower: tag.toLowerCase() }))
.filter(({ lower }) => query.length === 0 || lower.startsWith(query) || lower.includes(query))
.sort((a, b) => tagSuggestionScore(a.lower, query) - tagSuggestionScore(b.lower, query) || a.tag.localeCompare(b.tag))
.slice(0, 8)
.map(({ tag }) => ({
display: `#${tag}`,
detail: "Obsidian tag",
replacement: `#${tag}`,
start,
appendSpaceOnInsert: true,
}));
}
function tagCandidateList(tagCandidates: readonly string[] | (() => readonly string[])): readonly string[] {
return typeof tagCandidates === "function" ? tagCandidates() : tagCandidates;
}
function normalizedUniqueTags(tags: readonly string[]): string[] {
const seen = new Set<string>();
const normalizedTags: string[] = [];
for (const tag of tags) {
const normalized = normalizeTag(tag);
const key = normalized.toLowerCase();
if (!normalized || seen.has(key)) continue;
seen.add(key);
normalizedTags.push(normalized);
}
return normalizedTags.sort((a, b) => a.localeCompare(b));
}
function normalizeTag(tag: string): string {
return tag.trim().replace(/^#+/, "");
}
function tagSuggestionScore(tag: string, query: string): number {
if (query.length === 0) return 0;
return tag.startsWith(query) ? 0 : 1;
}
function findWikiLinkSuggestions(queryText: string, start: number, notes: NoteCandidate[]): ComposerSuggestion[] {
const headingCompletion = wikiLinkHeadingCompletion(queryText, start, notes);
if (headingCompletion) return headingCompletion;

View file

@ -1,5 +1,5 @@
import type { App, EventRef } from "obsidian";
import { stripHeadingForLink, TFile } from "obsidian";
import { getAllTags, stripHeadingForLink, TFile } from "obsidian";
import type { NoteCandidateProvider, WikiLinkMention } from "../../application/composer/note-context";
import type { NoteCandidate } from "../../application/composer/suggestions";
@ -21,6 +21,7 @@ interface EventSource {
export class VaultNoteCandidateProvider implements NoteCandidateProvider {
private readonly unregisterEvents: (() => void)[] = [];
private fileCandidatesCache: FileCandidate[] | null = null;
private tagCandidatesCache: string[] | null = null;
private readonly projectedCandidatesBySourcePath = new Map<string, NoteCandidate[]>();
constructor(private readonly app: App) {
@ -53,6 +54,21 @@ export class VaultNoteCandidateProvider implements NoteCandidateProvider {
return candidates;
}
tags(): readonly string[] {
this.tagCandidatesCache ??= this.readTags();
return this.tagCandidatesCache;
}
private readTags(): string[] {
const tags: string[] = [];
for (const file of this.app.vault.getMarkdownFiles()) {
const cache = this.app.metadataCache.getFileCache(file);
if (!cache) continue;
tags.push(...(getAllTags(cache) ?? []));
}
return normalizedTags(tags);
}
resolveMention(target: string, sourcePath: string): WikiLinkMention | null {
const linkedFile = this.app.metadataCache.getFirstLinkpathDest(target, sourcePath);
if (linkedFile?.path) return { name: linkedFile.basename, path: linkedFile.path };
@ -78,6 +94,7 @@ export class VaultNoteCandidateProvider implements NoteCandidateProvider {
private invalidate(): void {
this.fileCandidatesCache = null;
this.tagCandidatesCache = null;
this.projectedCandidatesBySourcePath.clear();
}
@ -94,6 +111,19 @@ export class VaultNoteCandidateProvider implements NoteCandidateProvider {
}
}
function normalizedTags(tags: readonly string[]): string[] {
const seen = new Set<string>();
const normalizedTags: string[] = [];
for (const tag of tags) {
const normalized = tag.trim().replace(/^#+/, "");
const key = normalized.toLowerCase();
if (!normalized || seen.has(key)) continue;
seen.add(key);
normalizedTags.push(normalized);
}
return normalizedTags.sort((a, b) => a.localeCompare(b));
}
function noteHeadings(app: App, file: TFile): NoteCandidate["headings"] {
return (app.metadataCache.getFileCache(file)?.headings ?? []).map((heading) => ({
heading: heading.heading,

View file

@ -247,6 +247,7 @@ export class ChatComposerController {
activeThreadId: state.activeThread.id,
contextReferences: this.contextReferences(),
permissionProfiles: state.connection.availablePermissionProfiles,
tagCandidates: () => this.options.noteCandidateProvider.tags(),
},
);
@ -296,6 +297,7 @@ export class ChatComposerController {
activeThreadId: state.activeThread.id,
contextReferences: this.contextReferences(),
permissionProfiles: state.connection.availablePermissionProfiles,
tagCandidates: () => this.options.noteCandidateProvider.tags(),
},
);
return {

View file

@ -31,6 +31,7 @@ export class MarkdownMessageRenderer {
parent.replaceChildren(...Array.from(staging.childNodes));
bindRenderedWikiLinks(parent, sourcePath, this.options);
bindRenderedMarkdownFileLinks(parent, sourcePath, this.options);
bindRenderedTags(parent, this.options);
notifyMessageContentRendered(parent);
});
}
@ -83,6 +84,39 @@ function bindRenderedMarkdownFileLinks(parent: HTMLElement, sourcePath: string,
});
}
function bindRenderedTags(parent: HTMLElement, context: RenderedMarkdownLinkContext): void {
parent.querySelectorAll<HTMLAnchorElement>("a.tag").forEach((link) => {
link.onclick = (event) => {
event.preventDefault();
const tag = renderedTagName(link);
if (!tag) return;
void openTagSearch(context.app, tag);
};
});
}
function renderedTagName(link: HTMLAnchorElement): string | null {
const text = link.textContent.trim();
const value = text || link.getAttribute("data-tag") || link.getAttribute("href");
if (!value) return null;
const normalized = value.trim().replace(/^#*/, "");
return normalized.length > 0 ? normalized : null;
}
async function openTagSearch(app: App, tag: string): Promise<void> {
const leaf = app.workspace.getLeftLeaf(false);
if (!leaf) {
new Notice("Cannot open Obsidian search.");
return;
}
await leaf.setViewState({
type: "search",
active: true,
state: { query: `tag:#${tag}` },
});
await app.workspace.revealLeaf(leaf);
}
function bindStreamMarkdownFileLinks(parent: HTMLElement, context: StreamMarkdownRenderContext): void {
const sourcePath = context.app.workspace.getActiveFile()?.path ?? "";
parent.querySelectorAll<HTMLAnchorElement>("a[href]").forEach((link) => {

View file

@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import type { ModelMetadata, ReasoningEffort, SkillMetadata } from "../../../../../src/domain/catalog/metadata";
import type { Thread } from "../../../../../src/domain/threads/model";
import { emptyComposerContextReferences } from "../../../../../src/features/chat/application/composer/context-references";
@ -281,6 +281,49 @@ describe("composer suggestions", () => {
expect(activeComposerSuggestions("/help", notes, [])).toEqual([]);
});
it("suggests Obsidian tags after a hash trigger", () => {
const options = {
tagCandidates: ["project/codex", "project/obsidian", "daily-note", "web"],
};
expect(activeComposerSuggestions("#pro", notes, [], [], [], null, options)[0]).toMatchObject({
display: "#project/codex",
detail: "Obsidian tag",
replacement: "#project/codex",
appendSpaceOnInsert: true,
});
expect(suggestionReplacements(activeComposerSuggestions("please tag #project/o", notes, [], [], [], null, options))).toEqual([
"#project/obsidian",
]);
expect(suggestionReplacements(activeComposerSuggestions("#", notes, [], [], [], null, options))).toEqual([
"#daily-note",
"#project/codex",
"#project/obsidian",
"#web",
]);
expect(activeComposerSuggestions("#project/codex", notes, [], [], [], null, options)).toEqual([]);
expect(activeComposerSuggestions("https://example.com/#pro", notes, [], [], [], null, options)).toEqual([]);
expect(activeComposerSuggestions("[Section](#pro", notes, [], [], [], null, options)).toEqual([]);
expect(
applyComposerSuggestionInsertion("#pro", 4, expectPresent(activeComposerSuggestions("#pro", notes, [], [], [], null, options)[0])),
).toEqual({
value: "#project/codex ",
cursor: 15,
});
});
it("loads Obsidian tags lazily only after a hash trigger", () => {
const tagCandidates = vi.fn(() => ["project/codex"]);
expect(activeComposerSuggestions("plain text", notes, [], [], [], null, { tagCandidates })).toEqual([]);
expect(tagCandidates).not.toHaveBeenCalled();
expect(activeComposerSuggestions("#pro", notes, [], [], [], null, { tagCandidates })[0]).toMatchObject({
replacement: "#project/codex",
});
expect(tagCandidates).toHaveBeenCalledOnce();
});
it("limits slash command suggestions to the start of the composer", () => {
const threads = [thread({ id: "019abcde-0000-7000-8000-000000000001", name: "Codex Panel実装" })];
const models = [model("gpt-5.5", ["low", "medium", "high"])];

View file

@ -102,6 +102,22 @@ describe("VaultNoteCandidateProvider", () => {
expect(fileToLinktext).toHaveBeenCalledTimes(2);
});
it("returns normalized Obsidian tag candidates from file metadata", () => {
const app = appFixture({
files: [
{ basename: "Alpha", path: "Alpha.md", stat: { mtime: 1 } },
{ basename: "Beta", path: "Beta.md", stat: { mtime: 2 } },
],
tags: new Map([
["Alpha.md", { inline: ["#project/codex"], frontmatter: ["daily"] }],
["Beta.md", ["#web", "#project/codex"]],
]),
});
const provider = new VaultNoteCandidateProvider(app);
expect(provider.tags()).toEqual(["daily", "project/codex", "web"]);
});
it("invalidates cached candidates when the vault changes", () => {
const files = [{ basename: "Alpha", path: "notes/Alpha.md", stat: { mtime: 100 } }];
const app = appFixture({ files });
@ -213,6 +229,7 @@ function appFixture(
linktexts?: Map<string, string>;
fileToLinktext?: (file: TFile, sourcePath: string, omitMdExtension?: boolean) => string;
headings?: Map<string, { heading: string; level: number }[]>;
tags?: Map<string, string[] | { inline?: string[]; frontmatter?: string[] }>;
activeFile?: TFile | null;
activeView?: unknown;
} = {},
@ -251,7 +268,11 @@ function appFixture(
options.fileToLinktext ??
((file: TFile, _sourcePath: string, omitMdExtension?: boolean) =>
options.linktexts?.get(file.path) ?? (omitMdExtension === true ? file.path.replace(/\.md$/i, "") : file.path)),
getFileCache: (file: TFile) => ({ headings: options.headings?.get(file.path) ?? [] }),
getFileCache: (file: TFile) => ({
headings: options.headings?.get(file.path) ?? [],
tags: tagFixtureForPath(options.tags, file.path).inline.map((tag) => ({ tag })),
frontmatter: { tags: tagFixtureForPath(options.tags, file.path).frontmatter },
}),
},
vault: {
on: on("vault"),
@ -283,6 +304,15 @@ function tFile(path: string, basename: string): TFile {
return Object.assign(new TFile(), { path, basename, extension, name });
}
function tagFixtureForPath(
tags: Map<string, string[] | { inline?: string[]; frontmatter?: string[] }> | undefined,
path: string,
): { inline: string[]; frontmatter: string[] } {
const value = tags?.get(path);
if (!value) return { inline: [], frontmatter: [] };
return Array.isArray(value) ? { inline: value, frontmatter: [] } : { inline: value.inline ?? [], frontmatter: value.frontmatter ?? [] };
}
function vaultFiles(files: { basename: string; path: string; stat: { mtime: number } }[]): TFile[] {
return files.map((file) => Object.assign(tFile(file.path, file.basename), { stat: file.stat }));
}

View file

@ -122,6 +122,42 @@ describe("ChatComposerController", () => {
expect(parent.querySelector(".codex-panel__composer-suggestion")?.textContent).toContain("/");
});
it("updates Obsidian tag suggestions when the input changes", () => {
const { controller, parent, stateStore } = composerControllerFixture({
controller: {
noteCandidateProvider: noteProvider({ tags: () => ["project/codex"] }),
},
});
renderComposerController(parent, controller, stateStore);
setTextAreaValue(composer(parent), "#pro");
composer(parent).setSelectionRange(4, 4);
composer(parent).dispatchEvent(new Event("input", { bubbles: true }));
expect(stateStore.getState().composer.suggestions[0]).toMatchObject({
display: "#project/codex",
replacement: "#project/codex",
});
expect(parent.querySelector(".codex-panel__composer-suggestion")?.textContent).toContain("#project/codex");
});
it("does not read Obsidian tags for non-tag suggestions", () => {
const tags = vi.fn(() => ["project/codex"]);
const { controller, parent, stateStore } = composerControllerFixture({
controller: {
noteCandidateProvider: noteProvider({ tags }),
},
});
renderComposerController(parent, controller, stateStore);
setTextAreaValue(composer(parent), "/");
composer(parent).setSelectionRange(1, 1);
composer(parent).dispatchEvent(new Event("input", { bubbles: true }));
expect(stateStore.getState().composer.suggestions.length).toBeGreaterThan(0);
expect(tags).not.toHaveBeenCalled();
});
it("keeps Tab wikilink insertion before closing brackets while Enter lands after them", () => {
const stateStore = createChatStateStore();
const parent = document.createElement("div");
@ -819,6 +855,7 @@ describe("ChatComposerController", () => {
function noteProvider(overrides: Partial<NoteCandidateProvider> = {}): NoteCandidateProvider {
return {
candidates: () => [],
tags: () => [],
resolveMention: () => null,
dispose: vi.fn(),
...overrides,

View file

@ -501,6 +501,7 @@ function setTextAreaValue(textarea: HTMLTextAreaElement, value: string): void {
function noteProvider(overrides: Partial<NoteCandidateProvider> = {}): NoteCandidateProvider {
return {
candidates: () => [],
tags: () => [],
resolveMention: () => null,
dispose: vi.fn(),
...overrides,

View file

@ -198,6 +198,33 @@ describe("MessageStreamPresenter scroll pinning", () => {
cleanup();
});
it("opens Obsidian search when rendered tags are clicked", async () => {
const searchLeaf = {
setViewState: vi.fn().mockResolvedValue(undefined),
};
const revealLeaf = vi.fn().mockResolvedValue(undefined);
const context = markdownLinkContext();
context.app.workspace.getLeftLeaf = vi.fn(() => searchLeaf);
context.app.workspace.revealLeaf = revealLeaf;
const { link, cleanup } = await renderedTag(context, {
cls: "tag",
text: "#project/codex",
attr: { href: "#project/codex" },
});
link.click();
await Promise.resolve();
expect(searchLeaf.setViewState).toHaveBeenCalledWith({
type: "search",
active: true,
state: { query: "tag:#project/codex" },
});
expect(revealLeaf).toHaveBeenCalledWith(searchLeaf);
expect(context.app.workspace.getLeftLeaf).toHaveBeenCalledWith(false);
cleanup();
});
it("pins to the scroll container bottom without aligning the last message element", async () => {
let state = chatStateFixture();
state = chatStateWith(state, { activeThread: { id: "thread" } });
@ -565,12 +592,14 @@ function markdownLinkContext(openLinkText = vi.fn(), vaultPath = "/vault", vault
workspace: {
getActiveFile: vi.fn(() => tFile("Inbox.md")),
openLinkText,
getLeftLeaf: vi.fn((): unknown | null => null),
revealLeaf: vi.fn().mockResolvedValue(undefined),
},
vault: {
configDir: "vault-config",
getAbstractFileByPath: (path: string) => files.get(path) ?? null,
},
} as never,
},
vaultPath,
};
}
@ -586,7 +615,7 @@ async function renderedInternalLink(
return Promise.resolve();
});
const markdownRenderer = new MarkdownMessageRenderer({
app: context.app,
app: context.app as never,
owner: {} as never,
vaultPath: context.vaultPath,
});
@ -606,6 +635,37 @@ async function renderedInternalLink(
};
}
async function renderedTag(
context: ReturnType<typeof markdownLinkContext>,
linkOptions: Parameters<HTMLElement["createEl"]>[1],
): Promise<{ link: HTMLAnchorElement; cleanup: () => void }> {
const parent = document.createElement("div");
document.body.appendChild(parent);
const renderMarkdown = vi.spyOn(MarkdownRenderer, "render").mockImplementationOnce((_app, _text, staging) => {
staging.createEl("a", linkOptions);
return Promise.resolve();
});
const markdownRenderer = new MarkdownMessageRenderer({
app: context.app as never,
owner: {} as never,
vaultPath: context.vaultPath,
});
markdownRenderer.renderObsidianMarkdown(parent, "#tag");
await Promise.resolve();
await Promise.resolve();
renderMarkdown.mockRestore();
const link = parent.querySelector<HTMLAnchorElement>("a.tag");
if (!link) throw new Error("Expected rendered tag");
return {
link,
cleanup: () => {
parent.remove();
},
};
}
interface TestMessageStreamPresenter {
presenter: MessageStreamPresenter;
scrollPortBinding: ChatMessageStreamScrollBinding;

View file

@ -89,6 +89,17 @@ export function stripHeadingForLink(heading: string): string {
return heading.trim();
}
export function getAllTags(cache: { tags?: { tag: string }[]; frontmatter?: { tags?: unknown } }): string[] | null {
const tags = [...(cache.tags?.map((tag) => tag.tag) ?? []), ...frontmatterTags(cache.frontmatter?.tags)];
return tags.length > 0 ? tags : null;
}
function frontmatterTags(value: unknown): string[] {
if (typeof value === "string") return [value];
if (!Array.isArray(value)) return [];
return value.filter((tag): tag is string => typeof tag === "string");
}
export function parseLinktext(linktext: string): { path: string; subpath: string } {
const headingIndex = linktext.indexOf("#");
const blockIndex = linktext.indexOf("^");