murashit_codex-panel/src/features/chat/application/composer/suggestions.ts
2026-07-02 15:13:17 +09:00

582 lines
22 KiB
TypeScript

import { prepareFuzzySearch, type SearchResult, sortSearchResults } from "obsidian";
import type { ModelMetadata, SkillMetadata } from "../../../../domain/catalog/metadata";
import { findModelMetadataByIdOrName, sortedModelMetadata, supportedEffortsForModelMetadata } from "../../../../domain/catalog/metadata";
import type { RuntimePermissionProfileSummary } from "../../../../domain/runtime/permissions";
import { shortThreadId } from "../../../../domain/threads/id";
import type { Thread } from "../../../../domain/threads/model";
import { threadDisplayTitle } from "../../../../domain/threads/title";
import {
type ActiveNoteContextReference,
activeNoteContextReferenceMarker,
type ComposerContextReferences,
formatComposerContextRange,
type SelectionContextReference,
selectionContextReferenceMarker,
} from "./context-references";
import { SLASH_COMMANDS, type SlashCommandName, slashCommandSubcommands } from "./slash-commands";
export interface ComposerSuggestion {
display: string;
detail: string;
replacement: string;
start: number;
appendSpaceOnInsert?: boolean;
tabCursorOffset?: number;
suffixOnInsert?: string;
activeNoteContext?: ActiveNoteContextReference;
selectionContext?: SelectionContextReference;
}
export interface ComposerSuggestionOptions {
activeThreadId?: string | null;
contextReferences?: ComposerContextReferences;
permissionProfiles?: readonly RuntimePermissionProfileSummary[];
}
export interface NoteCandidate {
basename: string;
displayName: string;
path: string;
mtime: number;
linktext: string;
headings: NoteHeadingCandidate[];
recentIndex: number | null;
}
interface NoteHeadingCandidate {
heading: string;
linkHeading: string;
level: number;
}
interface NoteCandidateMatch {
file: NoteCandidate;
match: SearchResult;
mtime: number;
basename: string;
path: string;
}
const THREAD_SUGGESTION_COMMANDS = ["resume", "refer", "archive", "rename"] as const;
type ThreadSuggestionCommand = (typeof THREAD_SUGGESTION_COMMANDS)[number];
interface ThreadCommandSuggestionPolicy {
excludeActiveThread: boolean;
prioritizeActiveThreadForEmptyQuery: boolean;
}
const THREAD_COMMAND_SUGGESTION_POLICIES: Record<ThreadSuggestionCommand, ThreadCommandSuggestionPolicy> = {
resume: {
excludeActiveThread: true,
prioritizeActiveThreadForEmptyQuery: false,
},
refer: {
excludeActiveThread: true,
prioritizeActiveThreadForEmptyQuery: false,
},
archive: {
excludeActiveThread: false,
prioritizeActiveThreadForEmptyQuery: true,
},
rename: {
excludeActiveThread: false,
prioritizeActiveThreadForEmptyQuery: true,
},
};
const THREAD_SUGGESTION_COMMAND_PATTERN = new RegExp(`^/(${THREAD_SUGGESTION_COMMANDS.join("|")})\\s+([^\\s\\n]{0,120})$`);
export function parseSlashCommand(text: string): { command: SlashCommandName; args: string } | null {
const match = /^\/([A-Za-z-]+)(?:\s+([\s\S]*))?$/.exec(text);
if (!match) return null;
const command = match[1] as SlashCommandName;
if (!SLASH_COMMANDS.some((item) => item.command === `/${command}`)) return null;
return { command, args: match.at(2)?.trim() ?? "" };
}
export function activeComposerSuggestions(
beforeCursor: string,
notes: NoteCandidate[],
skills: readonly SkillMetadata[],
threads: readonly Thread[] = [],
models: readonly ModelMetadata[] = [],
currentModel: string | null = null,
options: ComposerSuggestionOptions = {},
): ComposerSuggestion[] {
return (
activeWikiLinkSuggestions(beforeCursor, notes) ??
activeContextReferenceSuggestions(beforeCursor, options.contextReferences) ??
activeSlashSubcommandSuggestions(beforeCursor) ??
activeThreadCommandSuggestions(beforeCursor, threads, options.activeThreadId ?? null) ??
modelOverrideSuggestions(beforeCursor, models) ??
reasoningEffortOverrideSuggestions(beforeCursor, models, currentModel) ??
permissionProfileOverrideSuggestions(beforeCursor, options.permissionProfiles ?? []) ??
activeSlashCommandSuggestions(beforeCursor) ??
activeSkillSuggestions(beforeCursor, skills) ??
[]
);
}
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: activeNoteContextReferenceMarker(references.activeNote),
start,
activeNoteContext: references.activeNote,
});
}
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,
suggestion: ComposerSuggestion,
options: { activation?: "enter" | "tab" } = {},
): { value: string; cursor: number } {
const suffix = value.slice(cursor);
const appendSpace = suggestion.appendSpaceOnInsert === true && !suggestion.replacement.endsWith(" ") && !/^\s/.test(suffix);
const replacement = `${suggestion.replacement}${appendSpace ? " " : ""}`;
const suffixStart =
cursor + (suggestion.suffixOnInsert && suffix.startsWith(suggestion.suffixOnInsert) ? suggestion.suffixOnInsert.length : 0);
const nextValue = `${value.slice(0, suggestion.start)}${replacement}${value.slice(suffixStart)}`;
const cursorOffset = options.activation === "tab" ? (suggestion.tabCursorOffset ?? 0) : 0;
return { value: nextValue, cursor: suggestion.start + replacement.length + cursorOffset };
}
export function composerSuggestionNavigationDirection(event: Pick<KeyboardEvent, "key" | "ctrlKey" | "metaKey" | "altKey">): 1 | -1 | null {
if (event.ctrlKey && !event.metaKey && !event.altKey && event.key.toLowerCase() === "n") return 1;
if (event.ctrlKey && !event.metaKey && !event.altKey && event.key.toLowerCase() === "p") return -1;
if (event.metaKey || event.ctrlKey || event.altKey) return null;
if (event.key === "ArrowDown") return 1;
if (event.key === "ArrowUp") return -1;
return null;
}
export function nextComposerSuggestionIndex(current: number, length: number, direction: 1 | -1): number {
if (length <= 0) return 0;
return (current + direction + length) % length;
}
export function composerSuggestionSignature(value: string, cursor: number): string {
return `${value}\u0000${String(cursor)}`;
}
function activeWikiLinkSuggestions(beforeCursor: string, notes: NoteCandidate[]): ComposerSuggestion[] | null {
const start = beforeCursor.lastIndexOf("[[");
if (start === -1) return null;
const queryText = beforeCursor.slice(start + 2);
if (queryText.includes("]]") || queryText.includes("\n") || queryText.length > 120) return null;
return findWikiLinkSuggestions(queryText, start, notes);
}
function findWikiLinkSuggestions(queryText: string, start: number, notes: NoteCandidate[]): ComposerSuggestion[] {
const headingCompletion = wikiLinkHeadingCompletion(queryText, start, notes);
if (headingCompletion) return headingCompletion;
const query = queryText.toLowerCase().trim();
const suggestions = query.length === 0 ? emptyWikiLinkSuggestions(notes) : fuzzyWikiLinkSuggestions(query, notes);
return suggestions.slice(0, 8).map(({ file }) => ({
display: file.displayName,
detail: file.path,
replacement: `[[${file.linktext}]]`,
start,
tabCursorOffset: -2,
}));
}
function wikiLinkHeadingCompletion(queryText: string, start: number, notes: NoteCandidate[]): ComposerSuggestion[] | null {
const blockIndex = queryText.indexOf("^");
if (blockIndex !== -1) return [];
const headingSeparator = queryText.indexOf("#");
if (headingSeparator === -1) return null;
const target = queryText.slice(0, headingSeparator).trim();
if (!target) return [];
const note = noteForWikiLinkTarget(target, notes);
if (!note) return [];
const query = queryText
.slice(headingSeparator + 1)
.trim()
.toLowerCase();
const headingMatches = query.length === 0 ? note.headings : fuzzyHeadingSuggestions(query, note.headings).map((item) => item.heading);
return headingMatches.slice(0, 8).map((heading) => ({
display: heading.heading,
detail: `${"#".repeat(heading.level)} ${note.path}`,
replacement: `[[${note.linktext}#${heading.linkHeading}]]`,
start,
suffixOnInsert: "]]",
}));
}
function noteForWikiLinkTarget(target: string, notes: NoteCandidate[]): NoteCandidate | null {
const normalized = target.toLowerCase();
return notes.find((note) => wikiLinkTargetAliases(note).some((alias) => alias.toLowerCase() === normalized)) ?? null;
}
function wikiLinkTargetAliases(note: NoteCandidate): string[] {
const aliases = [note.linktext, note.path, note.basename, note.displayName];
if (note.path.toLowerCase().endsWith(".md")) aliases.push(note.path.slice(0, -3));
return aliases;
}
function fuzzyHeadingSuggestions(
query: string,
headings: readonly NoteHeadingCandidate[],
): { heading: NoteHeadingCandidate; match: SearchResult }[] {
const search = prepareFuzzySearch(query);
const results = headings
.map((heading) => {
const match = search(heading.heading);
return match ? { heading, match } : null;
})
.filter((item): item is { heading: NoteHeadingCandidate; match: SearchResult } => item !== null);
sortSearchResults(results);
return results;
}
function emptyWikiLinkSuggestions(notes: NoteCandidate[]): NoteCandidateMatch[] {
return notes
.filter((file) => file.recentIndex !== null)
.map((file) => ({
file,
match: { score: 0, matches: [] },
mtime: -(file.recentIndex ?? 0),
basename: file.basename,
path: file.path,
}))
.sort(compareWikiLinkSuggestionTiebreakers);
}
function fuzzyWikiLinkSuggestions(query: string, notes: NoteCandidate[]): NoteCandidateMatch[] {
const search = prepareFuzzySearch(query);
const results = notes
.map((file) => {
const basenameMatch = search(file.basename);
const pathMatch = search(file.path);
const match = bestSearchResult(basenameMatch, pathMatch);
return match ? { file, match, mtime: file.mtime, basename: file.basename, path: file.path } : null;
})
.filter((item): item is NoteCandidateMatch => item !== null);
sortSearchResults(results);
return results.sort(compareWikiLinkSuggestionTiebreakers);
}
function bestSearchResult(a: SearchResult | null, b: SearchResult | null): SearchResult | null {
if (!a) return b;
if (!b) return a;
const ranked = [{ match: a }, { match: b }];
sortSearchResults(ranked);
return ranked[0]?.match ?? a;
}
function compareWikiLinkSuggestionTiebreakers(a: NoteCandidateMatch, b: NoteCandidateMatch): number {
if (a.match.score !== b.match.score) return 0;
return b.mtime - a.mtime || a.basename.localeCompare(b.basename) || a.path.localeCompare(b.path);
}
function activeSlashCommandSuggestions(beforeCursor: string): ComposerSuggestion[] | null {
const match = /^(\/[A-Za-z-]*)$/.exec(beforeCursor);
if (match?.index === undefined) return null;
const rawQuery = match[1];
if (rawQuery === undefined) return null;
const query = rawQuery.toLowerCase();
if (SLASH_COMMANDS.some((item) => item.command.toLowerCase() === query)) return null;
const start = match.index + match[0].lastIndexOf("/");
return SLASH_COMMANDS.filter((item) => item.command.toLowerCase().startsWith(query))
.slice(0, 8)
.map((item) => ({
display: item.command,
detail: `${item.usage} - ${item.detail}`,
replacement: item.command,
start,
appendSpaceOnInsert: true,
}));
}
function activeSlashSubcommandSuggestions(beforeCursor: string): ComposerSuggestion[] | null {
const match = /^\/([A-Za-z-]+)\s+([A-Za-z-]{0,120})$/.exec(beforeCursor);
if (!match) return null;
const command = match[1] as SlashCommandName | undefined;
const rawQuery = match[2];
if (!command || rawQuery === undefined) return null;
if (!SLASH_COMMANDS.some((item) => item.command === `/${command}`)) return null;
const query = rawQuery.toLowerCase();
const subcommands = slashCommandSubcommands(command);
if (subcommands.length === 0) return null;
if (subcommands.some((item) => item.subcommand.toLowerCase() === query)) return null;
const start = beforeCursor.length - rawQuery.length;
return subcommands
.filter((item) => item.subcommand.toLowerCase().startsWith(query))
.slice(0, 8)
.map((item) => ({
display: item.subcommand,
detail: `${item.usage} - ${item.detail}`,
replacement: item.subcommand,
start,
appendSpaceOnInsert: true,
}));
}
function activeThreadCommandSuggestions(
beforeCursor: string,
threads: readonly Thread[],
activeThreadId: string | null,
): ComposerSuggestion[] | null {
const completion = activeThreadCommandCompletionQuery(beforeCursor);
if (!completion) return null;
const { command, query, start } = completion;
const policy = THREAD_COMMAND_SUGGESTION_POLICIES[command];
if (threads.some((thread) => thread.id.toLowerCase() === query)) return null;
return threads
.filter((thread) => !shouldExcludeActiveThreadSuggestion(policy, thread.id, activeThreadId))
.map((thread, index) => {
const title = threadDisplayTitle(thread);
const id = thread.id.toLowerCase();
const normalizedTitle = title.toLowerCase();
const score = query.length === 0 ? 2 : id.startsWith(query) ? 0 : normalizedTitle.includes(query) ? 1 : id.includes(query) ? 2 : -1;
const activePriority = activeThreadSuggestionPriority(policy, query, thread.id, activeThreadId);
return { thread, title, score, activePriority, index };
})
.filter((item) => item.score !== -1)
.sort((a, b) => a.activePriority - b.activePriority || a.score - b.score || a.index - b.index || a.title.localeCompare(b.title))
.slice(0, 8)
.map(({ thread, title }) => ({
display: title,
detail: shortThreadId(thread.id),
replacement: thread.id,
start,
appendSpaceOnInsert: true,
}));
}
function shouldExcludeActiveThreadSuggestion(
policy: ThreadCommandSuggestionPolicy,
threadId: string,
activeThreadId: string | null,
): boolean {
return policy.excludeActiveThread && activeThreadId !== null && threadId === activeThreadId;
}
function activeThreadSuggestionPriority(
policy: ThreadCommandSuggestionPolicy,
query: string,
threadId: string,
activeThreadId: string | null,
): number {
return policy.prioritizeActiveThreadForEmptyQuery && query.length === 0 && activeThreadId !== null && threadId === activeThreadId ? 0 : 1;
}
function activeThreadCommandCompletionQuery(
beforeCursor: string,
): { command: ThreadSuggestionCommand; query: string; start: number } | null {
const match = THREAD_SUGGESTION_COMMAND_PATTERN.exec(beforeCursor);
if (!match) return null;
const command = threadSuggestionCommand(match[1]);
const rawQuery = match[2];
if (!command || rawQuery === undefined) return null;
const query = rawQuery.trim().toLowerCase();
if (query.length > 0 && /\s$/.test(rawQuery)) return null;
return { command, query, start: beforeCursor.length - rawQuery.length };
}
function threadSuggestionCommand(value: string | undefined): ThreadSuggestionCommand | null {
return THREAD_SUGGESTION_COMMANDS.find((command) => command === value) ?? null;
}
function modelOverrideSuggestions(beforeCursor: string, models: readonly ModelMetadata[]): ComposerSuggestion[] | null {
const completion = activeCommandArgumentCompletionQuery(beforeCursor, /^\/model\s+([^\n]{0,120})$/);
if (!completion) return null;
const { query, start } = completion;
if (query === "default") return null;
if (models.some((model) => !model.hidden && model.model.toLowerCase() === query)) return null;
const suggestions = [
{
display: "default",
detail: "Reset model override",
replacement: "default",
start,
appendSpaceOnInsert: true,
},
...sortedModelMetadata(models)
.map((model, index) => {
const id = model.id.toLowerCase();
const name = model.model.toLowerCase();
const displayName = model.displayName.toLowerCase();
const score = query.length === 0 ? 2 : name.startsWith(query) ? 0 : displayName.includes(query) ? 1 : id.includes(query) ? 2 : -1;
return { model, score, index };
})
.filter((item) => item.score !== -1)
.sort((a, b) => a.score - b.score || a.index - b.index || a.model.model.localeCompare(b.model.model))
.map(({ model }) => ({
display: model.model,
detail: model.displayName || model.description,
replacement: model.model,
start,
appendSpaceOnInsert: true,
})),
];
return suggestions
.filter((item) => query.length === 0 || item.display.toLowerCase().startsWith(query) || item.detail.toLowerCase().includes(query))
.slice(0, 8);
}
function reasoningEffortOverrideSuggestions(
beforeCursor: string,
models: readonly ModelMetadata[],
currentModel: string | null,
): ComposerSuggestion[] | null {
const completion = activeCommandArgumentCompletionQuery(beforeCursor, /^\/reasoning\s+([^\n]{0,120})$/);
if (!completion) return null;
const { query, start } = completion;
const model = findModelMetadataByIdOrName(models, currentModel);
const efforts = model ? supportedEffortsForModelMetadata(model) : [];
if (query === "default" || efforts.includes(query)) return null;
const modelDetail = model ? `Supported by ${model.model}` : "Supported reasoning effort";
const suggestions = [
{
display: "default",
detail: "Reset effort override",
replacement: "default",
start,
appendSpaceOnInsert: true,
},
...efforts.map((effort) => ({
display: effort,
detail: modelDetail,
replacement: effort,
start,
appendSpaceOnInsert: true,
})),
];
return suggestions.filter((item) => item.display.toLowerCase().startsWith(query)).slice(0, 8);
}
function permissionProfileOverrideSuggestions(
beforeCursor: string,
profiles: readonly RuntimePermissionProfileSummary[],
): ComposerSuggestion[] | null {
const completion = activeCommandArgumentCompletionQuery(beforeCursor, /^\/permissions\s+([^\n]{0,120})$/);
if (!completion) return null;
const { query, rawQuery, start } = completion;
const allowedProfiles = profiles.filter((profile) => profile.allowed);
if (query === "default" || allowedProfiles.some((profile) => profile.id === rawQuery)) return null;
const suggestions = [
{
display: "default",
detail: "Reset permission profile",
replacement: "default",
start,
appendSpaceOnInsert: true,
},
...allowedProfiles.map((profile) => ({
display: profile.id,
detail: profile.description ?? "Permission profile",
replacement: profile.id,
start,
appendSpaceOnInsert: true,
})),
];
return permissionProfileSuggestionsForQuery(suggestions, query).slice(0, 8);
}
function permissionProfileSuggestionsForQuery(suggestions: ComposerSuggestion[], query: string): ComposerSuggestion[] {
if (query.length === 0) return suggestions;
return suggestions
.map((suggestion) => ({ suggestion, score: permissionProfileSuggestionScore(suggestion, query) }))
.filter((item): item is { suggestion: ComposerSuggestion; score: number } => item.score !== null)
.sort((left, right) => left.score - right.score)
.map((item) => item.suggestion);
}
function permissionProfileSuggestionScore(suggestion: ComposerSuggestion, query: string): number | null {
if (suggestion.display.toLowerCase().startsWith(query)) return 0;
if (suggestion.display === "default") return null;
return suggestion.detail.toLowerCase().includes(query) ? 1 : null;
}
function activeCommandArgumentCompletionQuery(
beforeCursor: string,
pattern: RegExp,
): { query: string; rawQuery: string; start: number } | null {
const match = pattern.exec(beforeCursor);
if (!match) return null;
const rawQuery = match[1];
if (rawQuery === undefined) return null;
const trimmedQuery = rawQuery.trim();
const query = trimmedQuery.toLowerCase();
if (query.length > 0 && /\s$/.test(rawQuery)) return null;
return { query, rawQuery: trimmedQuery, start: beforeCursor.length - rawQuery.length };
}
function activeSkillSuggestions(beforeCursor: string, skills: readonly SkillMetadata[]): 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 = rawQuery.toLowerCase();
if (skills.some((skill) => skill.name.toLowerCase() === query)) return null;
const start = match.index + prefix.length;
return skills
.filter((skill) => skill.name.toLowerCase().includes(query))
.sort((a, b) => {
const aName = a.name.toLowerCase();
const bName = b.name.toLowerCase();
const aScore = aName.startsWith(query) ? 0 : 1;
const bScore = bName.startsWith(query) ? 0 : 1;
return aScore - bScore || a.name.localeCompare(b.name);
})
.slice(0, 8)
.map((skill) => ({
display: `$${skill.name}`,
detail: skill.shortDescription ?? skill.interfaceShortDescription ?? skill.description,
replacement: `$${skill.name}`,
start,
appendSpaceOnInsert: true,
}));
}