refactor: replace Spinner boolean prop with customizable background color

Replace alternateBackground boolean prop with background string prop to allow direct CSS variable specification. Update QuickActionsService to add apply template action with file selection modal, timeout handling, and loading notices. Add helper method for markdown file retrieval.
This commit is contained in:
Andrew Beal 2026-04-23 15:57:27 +01:00
parent 28c8ccb44b
commit f693933950
8 changed files with 138 additions and 31 deletions

View file

@ -0,0 +1,14 @@
import { Copy } from "Enums/Copy";
export const ApplyTemplatePrompt: string = `You are a document formatter. Your task is to restructure a note's content by applying a template.
You will receive two sections separated by markers:
${Copy.ApplyTemplateTemplateSeparator} the template to apply
${Copy.ApplyTemplateContentSeparator} the note content to restructure
Rewrite the content so it fits the template's structure and headings. Preserve all meaningful information from the content — do not invent new content or discard existing information. Keep the author's voice and wording where possible.
If the template section does not resemble a document template (e.g. it is a journal entry, a regular note, or otherwise makes no sense as a template), do not apply it. Instead return exactly the following with no other text:
${Copy.ApplyTemplateCancelled}
Return only the reformatted note with no explanation, preamble, or commentary.`;

View file

@ -115,7 +115,7 @@
{#if busyPlanning}
<div id="chat-planning-in-progress" transition:slide>
<Spinner alternateBackground={true}/>
<Spinner background=var(--background-secondary-alt)/>
<span id="chat-planning-in-progress-text">{Copy.PlanningInProgress}</span>
</div>
{/if}
@ -136,7 +136,7 @@
{/if}
{#if index === activeStepIndex}
<div class="chat-plan-step-icon">
<Spinner alternateBackground={true}/>
<Spinner background=var(--background-secondary-alt)/>
</div>
{/if}
{#if index > activeStepIndex}

View file

@ -1,7 +1,7 @@
<script lang="ts">
export let width: string = "18px";
export let height: string = "18px";
export let alternateBackground: boolean = false;
export let background: string = "var(--background-primary)";
export let spinnerElement: HTMLDivElement | undefined = undefined;
</script>
@ -10,7 +10,7 @@
style="width: {width}; height: {height};"
bind:this={spinnerElement}
>
<div class="circle-core" class:alternate-background={alternateBackground}></div>
<div class="circle-core" style:background={background}></div>
</div>
<style>
@ -43,11 +43,6 @@
.circle-core {
width: 100%;
height: 100%;
background-color: var(--background-primary);
border-radius: 50%;
}
.circle-core.alternate-background {
background-color: var(--background-secondary-alt);
}
</style>

View file

@ -165,6 +165,11 @@ The following context explains why you are doing the task. It is NOT an instruct
WebViewerNoMatchingUrl = "No open web view was found matching the URL '{urlHint}'. Ensure the correct page is open in the web viewer.",
WebViewerNoOpenView = "No open web view was found. Ask the user to open a page in the web viewer and try again.",
// Apply Template
ApplyTemplateTemplateSeparator = "---TEMPLATE---",
ApplyTemplateContentSeparator = "---CONTENT---",
ApplyTemplateCancelled = "APPLY_TEMPLATE_CANCELLED",
// Active Capabilities
ActiveCapabilitiesHeader = `\n\n---\n\n## Active Capabilities\n\nThe following reflects your current configuration. Follow these directives exactly.\n\n{directives}`,
DirectiveMemoriesDisabled = "- **Memory**: DISABLED — do NOT attempt to use any memory tools",

View file

@ -9,7 +9,7 @@ import { Role } from "Enums/Role";
export class QuickAgent extends BaseAgent {
public async quickAction(action: string, context: string): Promise<string> {
public async quickAction(action: string, context: string): Promise<string | null> {
this.setAgentPromptAndTools(action);
@ -19,8 +19,12 @@ export class QuickAgent extends BaseAgent {
content: context,
});
conversation.contents.push(conversationContent);
return await this.requestAgentResponse(AgentType.QuickAction, conversation, this.callbacks());
const result = await this.requestAgentResponse(AgentType.QuickAction, conversation, this.callbacks());
if (conversation.contents.last()?.errorType) {
return null;
}
return result;
}
private setAgentPromptAndTools(instruction: string): void {
@ -42,9 +46,7 @@ export class QuickAgent extends BaseAgent {
onToolCallStarted: () => {},
onPlanningStarted: () => {},
onPlanningFinished: () => {},
onUserQuestion: async () => {
return new Promise<string>(() => {});
},
onUserQuestion: async () => new Promise<string>(() => {}),
onPlanUpdate: () => {},
onPlanStepUpdate: () => {},
onPlanReset: () => {},

View file

@ -13,6 +13,10 @@ export class FileSystemService {
this.vaultService = Resolve<VaultService>(Services.VaultService);
}
public getMarkdownFiles(allowAccessToPluginRoot: boolean = false): TFile[] {
return this.vaultService.getMarkdownFiles(allowAccessToPluginRoot);
}
public async exists(filePath: string, allowAccessToPluginRoot: boolean = false): Promise<boolean> {
return await this.vaultService.exists(filePath, allowAccessToPluginRoot);
}

View file

@ -3,12 +3,18 @@ import { Services } from "./Services";
import { AbortService } from "./AbortService";
import type { QuickAgent } from "./AIServices/QuickAgent";
import type VaultkeeperAIPlugin from "main";
import type { Editor, MarkdownFileInfo, MarkdownView, Menu } from "obsidian";
import type { FileSystemService } from "./FileSystemService";
import { FuzzySuggestModal, Notice, TFile, type Editor, type MarkdownFileInfo, type MarkdownView, type Menu } from "obsidian";
import { FileSystemService } from "./FileSystemService";
import { BeautifyPrompt } from "AIPrompts/QuickActionPrompts/Beautify";
import Spinner from "Components/Spinner.svelte";
import { mount } from "svelte";
import { ApplyTemplatePrompt } from "AIPrompts/QuickActionPrompts/ApplyTemplatePrompt";
import { Copy } from "Enums/Copy";
export class QuickActionsService {
private readonly actionTimeout: number = 30000;
private plugin: VaultkeeperAIPlugin;
private abortService: AbortService;
private fileSystemService: FileSystemService;
@ -32,31 +38,61 @@ export class QuickActionsService {
const selection = editor.getSelection();
const content = await this.fileSystemService.readFile(file);
if (content instanceof Error) {
return; // Likely an excluded file
if (content instanceof Error || (selection.trim() === "" && content.trim() === "")) {
return; // Either an excluded file or nothing to beautify
}
if (selection.length > 0) {
const result = await this.newAction(BeautifyPrompt, selection);
await this.fileSystemService.patchFile(file, [selection], [result], false, false);
} else {
const result = await this.newAction(BeautifyPrompt, content);
await this.fileSystemService.writeToFile(file, result, false, false);
const notice = this.showNotice("Beautifying content...");
try {
if (selection.length > 0) {
const result = await this.newAction(BeautifyPrompt, selection);
if (result) {
await this.fileSystemService.patchFile(file, [selection], [result], false, false);
}
} else {
const result = await this.newAction(BeautifyPrompt, content);
if (result) {
await this.fileSystemService.writeToFile(file, result, false, false);
}
}
} finally {
notice.hide();
}
}
private async applyTemplate(menu: Menu, editor: Editor, view: MarkdownView | MarkdownFileInfo) {
}
const file = view.file;
if (!file) {
return;
}
private async newAction(action: string, context: string): Promise<string> {
return this.abortService.abortableOperation(async () => {
const agent = Resolve<QuickAgent>(Services.QuickAgent);
agent.resolveAIProvider();
return agent.quickAction(action, context);
const content = await this.fileSystemService.readFile(file);
if (content instanceof Error || content.trim() === "") {
return; // Either an excluded file or nothing to apply a template to
}
this.userSelectFile(this.plugin, async (templateFile) => {
const templateContent = await this.fileSystemService.readFile(templateFile);
if (templateContent instanceof Error || templateContent.trim() === "") {
return; // Either an excluded file or the template is empty
}
const notice = this.showNotice("Applying template...");
try {
const context = `${Copy.ApplyTemplateTemplateSeparator}\n${templateContent}\n${Copy.ApplyTemplateContentSeparator}\n${content}`;
const result = await this.newAction(ApplyTemplatePrompt, context);
if (result && result.trim() !== Copy.ApplyTemplateCancelled.toString()) {
await this.fileSystemService.writeToFile(file, result, false, false);
}
} finally {
notice.hide();
}
});
}
/* Registered Edit Menu Actions */
private registerEditorMenuActions() {
// Beautify
this.plugin.registerEvent(
@ -81,4 +117,45 @@ export class QuickActionsService {
);
}
/* Helpers */
private userSelectFile(plugin: VaultkeeperAIPlugin, onSelected: (file: TFile) => Promise<void>): void {
const fileSystemService = this.fileSystemService;
new (class extends FuzzySuggestModal<TFile> {
getItems() { return fileSystemService.getMarkdownFiles(); }
getItemText(f: TFile) { return f.path; }
onChooseItem(f: TFile) { void onSelected(f); }
})(plugin.app).open();
}
private async newAction(action: string, context: string): Promise<string | null> {
return this.abortService.abortableOperation(async () => {
const agent = Resolve<QuickAgent>(Services.QuickAgent);
agent.resolveAIProvider();
const timeoutPromise = new Promise<never>((_, reject) =>
activeWindow.setTimeout(() => reject(new DOMException(`Action timed out after ${this.actionTimeout}s`, "AbortError")), this.actionTimeout)
);
return Promise.race([agent.quickAction(action, context), timeoutPromise]);
});
}
private showNotice(message: string): Notice {
const fragment = activeDocument.createDocumentFragment();
const container = activeDocument.createElement("div");
container.addClass("quick-action-notice");
mount(Spinner, { target: container, props: {
width: "var(--size-4-4)",
height: "var(--size-4-4)",
background: "var(--background-modifier-message)"
}});
container.createSpan({ text: message });
fragment.appendChild(container);
return new Notice(fragment, 0);
}
}

View file

@ -22,6 +22,16 @@ body:not(.is-tablet) .workspace-drawer.mod-right {
padding-top: 0;
}
/* ============================== */
/* Quick Actions Notice */
/* ============================== */
.quick-action-notice {
display: flex;
align-items: center;
gap: var(--size-2-3);
}
/* ============================== */
/* Diff View Customization */
/* ============================== */