summaryrefslogtreecommitdiffhomepage
path: root/src/features/system-prompt
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/system-prompt')
-rw-r--r--src/features/system-prompt/index.ts18
-rw-r--r--src/features/system-prompt/logic/view-model.test.ts148
-rw-r--r--src/features/system-prompt/logic/view-model.ts74
-rw-r--r--src/features/system-prompt/ui/SystemPromptBuilder.svelte467
4 files changed, 354 insertions, 353 deletions
diff --git a/src/features/system-prompt/index.ts b/src/features/system-prompt/index.ts
index 142d4b1..50d9d21 100644
--- a/src/features/system-prompt/index.ts
+++ b/src/features/system-prompt/index.ts
@@ -1,17 +1,17 @@
export type {
- LoadSystemPrompt,
- LoadSystemPromptVariables,
- SaveSystemPrompt,
- SystemPromptLoadResult,
- SystemPromptSaveResult,
- SystemPromptVariablesResult,
- VariableGroup,
+ LoadSystemPrompt,
+ LoadSystemPromptVariables,
+ SaveSystemPrompt,
+ SystemPromptLoadResult,
+ SystemPromptSaveResult,
+ SystemPromptVariablesResult,
+ VariableGroup,
} from "./logic/view-model";
export { buildTag, groupVariables, insertTag, isDynamicVariable } from "./logic/view-model";
export { default as SystemPromptBuilder } from "./ui/SystemPromptBuilder.svelte";
/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */
export const manifest = {
- name: "system-prompt",
- description: "Global system prompt template builder with variable placeholders",
+ name: "system-prompt",
+ description: "Global system prompt template builder with variable placeholders",
} as const;
diff --git a/src/features/system-prompt/logic/view-model.test.ts b/src/features/system-prompt/logic/view-model.test.ts
index a0736cc..223327b 100644
--- a/src/features/system-prompt/logic/view-model.test.ts
+++ b/src/features/system-prompt/logic/view-model.test.ts
@@ -1,90 +1,90 @@
import type { SystemPromptVariable } from "@dispatch/transport-contract";
import { describe, expect, it } from "vitest";
import {
- buildIfNotTag,
- buildIfTag,
- buildTag,
- groupVariables,
- insertTag,
- isDynamicVariable,
+ buildIfNotTag,
+ buildIfTag,
+ buildTag,
+ groupVariables,
+ insertTag,
+ isDynamicVariable,
} from "./view-model";
describe("system-prompt view-model", () => {
- describe("buildTag", () => {
- it("builds a variable placeholder", () => {
- expect(buildTag("system", "time")).toBe("[system:time]");
- });
- });
+ describe("buildTag", () => {
+ it("builds a variable placeholder", () => {
+ expect(buildTag("system", "time")).toBe("[system:time]");
+ });
+ });
- describe("buildIfTag", () => {
- it("builds an opening conditional tag", () => {
- expect(buildIfTag("file", "AGENTS.md")).toBe("[if file:AGENTS.md]");
- });
- });
+ describe("buildIfTag", () => {
+ it("builds an opening conditional tag", () => {
+ expect(buildIfTag("file", "AGENTS.md")).toBe("[if file:AGENTS.md]");
+ });
+ });
- describe("buildIfNotTag", () => {
- it("builds a negated opening conditional tag", () => {
- expect(buildIfNotTag("prompt", "cwd")).toBe("[if !prompt:cwd]");
- });
- });
+ describe("buildIfNotTag", () => {
+ it("builds a negated opening conditional tag", () => {
+ expect(buildIfNotTag("prompt", "cwd")).toBe("[if !prompt:cwd]");
+ });
+ });
- describe("insertTag", () => {
- it("inserts a tag at the cursor position", () => {
- expect(insertTag("Hello world", "[system:time]", 5, 5)).toEqual({
- template: "Hello[system:time] world",
- cursor: 18,
- });
- });
+ describe("insertTag", () => {
+ it("inserts a tag at the cursor position", () => {
+ expect(insertTag("Hello world", "[system:time]", 5, 5)).toEqual({
+ template: "Hello[system:time] world",
+ cursor: 18,
+ });
+ });
- it("replaces the selected text", () => {
- const tag = buildTag("file", "README.md");
- expect(insertTag("Hello world", tag, 6, 11)).toEqual({
- template: `Hello ${tag}`,
- cursor: 6 + tag.length,
- });
- });
+ it("replaces the selected text", () => {
+ const tag = buildTag("file", "README.md");
+ expect(insertTag("Hello world", tag, 6, 11)).toEqual({
+ template: `Hello ${tag}`,
+ cursor: 6 + tag.length,
+ });
+ });
- it("inserts at the end", () => {
- const tag = buildTag("git", "branch");
- expect(insertTag("", tag, 0, 0)).toEqual({
- template: tag,
- cursor: tag.length,
- });
- });
- });
+ it("inserts at the end", () => {
+ const tag = buildTag("git", "branch");
+ expect(insertTag("", tag, 0, 0)).toEqual({
+ template: tag,
+ cursor: tag.length,
+ });
+ });
+ });
- describe("groupVariables", () => {
- it("groups by type in first-appearing order", () => {
- const variables: SystemPromptVariable[] = [
- { type: "system", name: "time", description: "" },
- { type: "prompt", name: "cwd", description: "" },
- { type: "system", name: "date", description: "" },
- { type: "git", name: "branch", description: "" },
- ];
- const groups = groupVariables(variables);
- expect(groups.map((g) => g.type)).toEqual(["system", "prompt", "git"]);
- expect(groups[0]?.variables.map((v) => v.name)).toEqual(["time", "date"]);
- expect(groups[1]?.variables.map((v) => v.name)).toEqual(["cwd"]);
- expect(groups[2]?.variables.map((v) => v.name)).toEqual(["branch"]);
- });
+ describe("groupVariables", () => {
+ it("groups by type in first-appearing order", () => {
+ const variables: SystemPromptVariable[] = [
+ { type: "system", name: "time", description: "" },
+ { type: "prompt", name: "cwd", description: "" },
+ { type: "system", name: "date", description: "" },
+ { type: "git", name: "branch", description: "" },
+ ];
+ const groups = groupVariables(variables);
+ expect(groups.map((g) => g.type)).toEqual(["system", "prompt", "git"]);
+ expect(groups[0]?.variables.map((v) => v.name)).toEqual(["time", "date"]);
+ expect(groups[1]?.variables.map((v) => v.name)).toEqual(["cwd"]);
+ expect(groups[2]?.variables.map((v) => v.name)).toEqual(["branch"]);
+ });
- it("returns an empty array when no variables", () => {
- expect(groupVariables([])).toEqual([]);
- });
- });
+ it("returns an empty array when no variables", () => {
+ expect(groupVariables([])).toEqual([]);
+ });
+ });
- describe("isDynamicVariable", () => {
- it("returns true when dynamic is true", () => {
- expect(
- isDynamicVariable({ type: "file", name: "path", description: "", dynamic: true }),
- ).toBe(true);
- });
+ describe("isDynamicVariable", () => {
+ it("returns true when dynamic is true", () => {
+ expect(
+ isDynamicVariable({ type: "file", name: "path", description: "", dynamic: true }),
+ ).toBe(true);
+ });
- it("returns false when dynamic is missing or false", () => {
- expect(isDynamicVariable({ type: "system", name: "time", description: "" })).toBe(false);
- expect(
- isDynamicVariable({ type: "system", name: "time", description: "", dynamic: false }),
- ).toBe(false);
- });
- });
+ it("returns false when dynamic is missing or false", () => {
+ expect(isDynamicVariable({ type: "system", name: "time", description: "" })).toBe(false);
+ expect(
+ isDynamicVariable({ type: "system", name: "time", description: "", dynamic: false }),
+ ).toBe(false);
+ });
+ });
});
diff --git a/src/features/system-prompt/logic/view-model.ts b/src/features/system-prompt/logic/view-model.ts
index 6924208..e202ee3 100644
--- a/src/features/system-prompt/logic/view-model.ts
+++ b/src/features/system-prompt/logic/view-model.ts
@@ -14,20 +14,20 @@ import type { SystemPromptVariable } from "@dispatch/transport-contract";
// ── Injected ports (composition root adapts the store to these shapes) ─────────
export type SystemPromptLoadResult =
- | { readonly ok: true; readonly template: string }
- | { readonly ok: false; readonly error: string };
+ | { readonly ok: true; readonly template: string }
+ | { readonly ok: false; readonly error: string };
export type LoadSystemPrompt = () => Promise<SystemPromptLoadResult>;
export type SystemPromptSaveResult =
- | { readonly ok: true; readonly template: string }
- | { readonly ok: false; readonly error: string };
+ | { readonly ok: true; readonly template: string }
+ | { readonly ok: false; readonly error: string };
export type SaveSystemPrompt = (template: string) => Promise<SystemPromptSaveResult>;
export type SystemPromptVariablesResult =
- | { readonly ok: true; readonly variables: readonly SystemPromptVariable[] }
- | { readonly ok: false; readonly error: string };
+ | { readonly ok: true; readonly variables: readonly SystemPromptVariable[] }
+ | { readonly ok: false; readonly error: string };
export type LoadSystemPromptVariables = () => Promise<SystemPromptVariablesResult>;
@@ -35,24 +35,24 @@ export type LoadSystemPromptVariables = () => Promise<SystemPromptVariablesResul
/** Build the literal placeholder `[type:name]` for a variable. */
export function buildTag(type: string, name: string): string {
- return `[${type}:${name}]`;
+ return `[${type}:${name}]`;
}
/** Build the literal placeholder `[if type:name]` for a conditional block. */
export function buildIfTag(type: string, name: string): string {
- return `[if ${type}:${name}]`;
+ return `[if ${type}:${name}]`;
}
/** Build the literal placeholder `[if !type:name]` for a negated conditional block. */
export function buildIfNotTag(type: string, name: string): string {
- return `[if !${type}:${name}]`;
+ return `[if !${type}:${name}]`;
}
export interface Insertion {
- /** Template text after insertion. */
- template: string;
- /** New cursor position (caret index) after insertion. */
- cursor: number;
+ /** Template text after insertion. */
+ template: string;
+ /** New cursor position (caret index) after insertion. */
+ cursor: number;
}
/**
@@ -61,24 +61,24 @@ export interface Insertion {
* the caller can restore the caret after the inserted tag.
*/
export function insertTag(
- template: string,
- tag: string,
- selectionStart: number,
- selectionEnd: number,
+ template: string,
+ tag: string,
+ selectionStart: number,
+ selectionEnd: number,
): Insertion {
- const before = template.slice(0, selectionStart);
- const after = template.slice(selectionEnd);
- const next = before + tag + after;
- return { template: next, cursor: selectionStart + tag.length };
+ const before = template.slice(0, selectionStart);
+ const after = template.slice(selectionEnd);
+ const next = before + tag + after;
+ return { template: next, cursor: selectionStart + tag.length };
}
// ── Variable grouping ─────────────────────────────────────────────────────────
export interface VariableGroup {
- /** Variable type (e.g. `"system"`, `"file"`, `"prompt"`, `"git"`). */
- readonly type: string;
- /** Variables of this type. */
- readonly variables: readonly SystemPromptVariable[];
+ /** Variable type (e.g. `"system"`, `"file"`, `"prompt"`, `"git"`). */
+ readonly type: string;
+ /** Variables of this type. */
+ readonly variables: readonly SystemPromptVariable[];
}
/**
@@ -86,21 +86,21 @@ export interface VariableGroup {
* group and the order of first-appearing types.
*/
export function groupVariables(
- variables: readonly SystemPromptVariable[],
+ variables: readonly SystemPromptVariable[],
): readonly VariableGroup[] {
- const order: string[] = [];
- const map = new Map<string, SystemPromptVariable[]>();
- for (const v of variables) {
- if (!map.has(v.type)) {
- map.set(v.type, []);
- order.push(v.type);
- }
- map.get(v.type)?.push(v);
- }
- return order.map((type) => ({ type, variables: map.get(type) ?? [] }));
+ const order: string[] = [];
+ const map = new Map<string, SystemPromptVariable[]>();
+ for (const v of variables) {
+ if (!map.has(v.type)) {
+ map.set(v.type, []);
+ order.push(v.type);
+ }
+ map.get(v.type)?.push(v);
+ }
+ return order.map((type) => ({ type, variables: map.get(type) ?? [] }));
}
/** Whether a variable is "dynamic" (any name is valid, e.g. `file:<path>`). */
export function isDynamicVariable(variable: SystemPromptVariable): boolean {
- return variable.dynamic === true;
+ return variable.dynamic === true;
}
diff --git a/src/features/system-prompt/ui/SystemPromptBuilder.svelte b/src/features/system-prompt/ui/SystemPromptBuilder.svelte
index 4e07317..2e92e26 100644
--- a/src/features/system-prompt/ui/SystemPromptBuilder.svelte
+++ b/src/features/system-prompt/ui/SystemPromptBuilder.svelte
@@ -1,242 +1,243 @@
<script lang="ts">
- import type { SystemPromptVariable } from "@dispatch/transport-contract";
- import { tick, untrack } from "svelte";
- import {
- buildTag,
- groupVariables,
- insertTag,
- isDynamicVariable,
- type LoadSystemPrompt,
- type LoadSystemPromptVariables,
- type SaveSystemPrompt,
- } from "../logic/view-model";
-
- let {
- loadPrompt,
- savePrompt,
- loadVariables,
- onClose,
- }: {
- loadPrompt: LoadSystemPrompt;
- savePrompt: SaveSystemPrompt;
- loadVariables: LoadSystemPromptVariables;
- onClose: () => void;
- } = $props();
-
- let value = $state("");
- let loadedTemplate = $state("");
- let loading = $state(false);
- let saving = $state(false);
- let error = $state<string | null>(null);
- let justSaved = $state(false);
- let variables = $state<readonly SystemPromptVariable[]>([]);
- let filePath = $state("");
- let textarea: HTMLTextAreaElement | null = null;
-
- const groups = $derived(groupVariables(variables));
- const hasChanges = $derived(value !== loadedTemplate);
-
- async function load() {
- untrack(() => {
- loading = true;
- error = null;
- });
-
- const [templateResult, variablesResult] = await Promise.all([loadPrompt(), loadVariables()]);
-
- loading = false;
-
- if (!templateResult.ok || !variablesResult.ok) {
- const parts: string[] = [];
- if (!templateResult.ok) parts.push(templateResult.error);
- if (!variablesResult.ok) parts.push(variablesResult.error);
- error = parts.join("; ");
- return;
- }
-
- value = templateResult.template;
- loadedTemplate = templateResult.template;
- variables = variablesResult.variables;
- }
-
- async function save() {
- if (saving || loading) return;
- saving = true;
- error = null;
- justSaved = false;
- const result = await savePrompt(value);
- saving = false;
- if (!result.ok) {
- error = result.error;
- return;
- }
- loadedTemplate = result.template;
- value = result.template;
- justSaved = true;
- }
-
- function reset() {
- value = loadedTemplate;
- error = null;
- justSaved = false;
- }
-
- async function insertTagAtCursor(tag: string) {
- if (textarea === null) return;
- const start = textarea.selectionStart;
- const end = textarea.selectionEnd;
- const insertion = insertTag(value, tag, start, end);
- value = insertion.template;
- await tick();
- textarea.focus();
- textarea.setSelectionRange(insertion.cursor, insertion.cursor);
- }
-
- async function insertFileVariable(type: string) {
- const path = filePath.trim();
- if (path.length === 0) return;
- await insertTagAtCursor(buildTag(type, path));
- filePath = "";
- }
-
- function onKeydown(e: KeyboardEvent) {
- if (e.key === "Escape") onClose();
- }
-
- // Load on mount once.
- $effect(() => {
- void load();
- });
+ import type { SystemPromptVariable } from "@dispatch/transport-contract";
+ import { tick, untrack } from "svelte";
+ import {
+ buildTag,
+ groupVariables,
+ insertTag,
+ isDynamicVariable,
+ type LoadSystemPrompt,
+ type LoadSystemPromptVariables,
+ type SaveSystemPrompt,
+ } from "../logic/view-model";
+
+ let {
+ loadPrompt,
+ savePrompt,
+ loadVariables,
+ onClose,
+ }: {
+ loadPrompt: LoadSystemPrompt;
+ savePrompt: SaveSystemPrompt;
+ loadVariables: LoadSystemPromptVariables;
+ onClose: () => void;
+ } = $props();
+
+ let value = $state("");
+ let loadedTemplate = $state("");
+ let loading = $state(false);
+ let saving = $state(false);
+ let error = $state<string | null>(null);
+ let justSaved = $state(false);
+ let variables = $state<readonly SystemPromptVariable[]>([]);
+ let filePath = $state("");
+ let textarea: HTMLTextAreaElement | null = null;
+
+ const groups = $derived(groupVariables(variables));
+ const hasChanges = $derived(value !== loadedTemplate);
+
+ async function load() {
+ untrack(() => {
+ loading = true;
+ error = null;
+ });
+
+ const [templateResult, variablesResult] = await Promise.all([loadPrompt(), loadVariables()]);
+
+ loading = false;
+
+ if (!templateResult.ok || !variablesResult.ok) {
+ const parts: string[] = [];
+ if (!templateResult.ok) parts.push(templateResult.error);
+ if (!variablesResult.ok) parts.push(variablesResult.error);
+ error = parts.join("; ");
+ return;
+ }
+
+ value = templateResult.template;
+ loadedTemplate = templateResult.template;
+ variables = variablesResult.variables;
+ }
+
+ async function save() {
+ if (saving || loading) return;
+ saving = true;
+ error = null;
+ justSaved = false;
+ const result = await savePrompt(value);
+ saving = false;
+ if (!result.ok) {
+ error = result.error;
+ return;
+ }
+ loadedTemplate = result.template;
+ value = result.template;
+ justSaved = true;
+ }
+
+ function reset() {
+ value = loadedTemplate;
+ error = null;
+ justSaved = false;
+ }
+
+ async function insertTagAtCursor(tag: string) {
+ if (textarea === null) return;
+ const start = textarea.selectionStart;
+ const end = textarea.selectionEnd;
+ const insertion = insertTag(value, tag, start, end);
+ value = insertion.template;
+ await tick();
+ textarea.focus();
+ textarea.setSelectionRange(insertion.cursor, insertion.cursor);
+ }
+
+ async function insertFileVariable(type: string) {
+ const path = filePath.trim();
+ if (path.length === 0) return;
+ await insertTagAtCursor(buildTag(type, path));
+ filePath = "";
+ }
+
+ function onKeydown(e: KeyboardEvent) {
+ if (e.key === "Escape") onClose();
+ }
+
+ // Load on mount once.
+ $effect(() => {
+ void load();
+ });
</script>
<svelte:window onkeydown={onKeydown} />
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
- class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
- role="dialog"
- aria-modal="true"
- aria-label="System prompt builder"
- tabindex="-1"
- onclick={onClose}
- onkeydown={onKeydown}
+ class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
+ role="dialog"
+ aria-modal="true"
+ aria-label="System prompt builder"
+ tabindex="-1"
+ onclick={onClose}
+ onkeydown={onKeydown}
>
- <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
- <div
- class="flex h-[85vh] w-full max-w-6xl flex-col overflow-hidden rounded-box bg-base-100 shadow-2xl"
- onclick={(e) => e.stopPropagation()}
- >
- <!-- Header -->
- <div class="flex shrink-0 items-center justify-between border-b border-base-300 px-4 py-3">
- <div class="flex items-center gap-2">
- <h2 class="text-sm font-semibold">System Prompt</h2>
- {#if loading}
- <span class="loading loading-spinner loading-xs"></span>
- {/if}
- </div>
- <button
- type="button"
- class="btn btn-ghost btn-sm btn-square"
- onclick={onClose}
- aria-label="Close system prompt builder"
- >
- ✕
- </button>
- </div>
-
- <!-- Body: half editor / half variables -->
- <div class="flex min-h-0 flex-1">
- <!-- Left: template editor -->
- <div class="flex w-1/2 min-w-0 flex-col gap-2 border-r border-base-300 p-4">
- <textarea
- bind:this={textarea}
- bind:value
- class="textarea textarea-bordered min-h-0 w-full flex-1 resize-none font-mono text-xs"
- placeholder={loading ? "Loading template..." : "Edit the global system prompt template..."}
- disabled={loading}
- aria-label="System prompt template"
- ></textarea>
-
- <div class="flex shrink-0 items-center gap-2">
- <button
- type="button"
- class="btn btn-primary btn-sm"
- disabled={loading || saving || !hasChanges}
- onclick={save}
- >
- {#if saving}
- <span class="loading loading-spinner loading-xs"></span>
- {:else}
- Save
- {/if}
- </button>
- <button
- type="button"
- class="btn btn-ghost btn-sm"
- disabled={loading || !hasChanges}
- onclick={reset}
- >
- Reset
- </button>
- {#if justSaved && !hasChanges}
- <span class="text-xs text-success">Saved.</span>
- {:else if hasChanges}
- <span class="text-xs opacity-60">Unsaved changes</span>
- {/if}
- </div>
-
- {#if error}
- <p class="shrink-0 text-xs text-error">{error}</p>
- {/if}
- </div>
-
- <!-- Right: variable palette -->
- <div class="flex w-1/2 min-w-0 flex-col overflow-y-auto p-4">
- <h3 class="mb-2 shrink-0 text-xs font-semibold uppercase opacity-60">Variables</h3>
- {#if groups.length === 0 && !loading}
- <p class="text-xs opacity-60">No variables available.</p>
- {/if}
- <div class="flex flex-col gap-3">
- {#each groups as group (group.type)}
- <div class="rounded-box bg-base-200 p-3">
- <span class="text-xs font-semibold uppercase opacity-70">{group.type}</span>
- <div class="mt-2 flex flex-wrap gap-1">
- {#each group.variables as variable (variable.type + variable.name)}
- {#if isDynamicVariable(variable)}
- <div class="flex items-center gap-1">
- <input
- type="text"
- class="input input-bordered input-xs w-32 font-mono"
- bind:value={filePath}
- placeholder={variable.name}
- onkeydown={(e) => {
- if (e.key === "Enter") void insertFileVariable(variable.type);
- }}
- />
- <button
- type="button"
- class="btn btn-xs"
- onclick={() => void insertFileVariable(variable.type)}
- >
- Insert
- </button>
- </div>
- {:else}
- <button
- type="button"
- class="btn btn-xs"
- title={variable.description}
- onclick={() => void insertTagAtCursor(buildTag(variable.type, variable.name))}
- >
- {variable.name}
- </button>
- {/if}
- {/each}
- </div>
- </div>
- {/each}
- </div>
- </div>
- </div>
- </div>
+ <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
+ <div
+ class="flex h-[85vh] w-full max-w-6xl flex-col overflow-hidden rounded-box bg-base-100 shadow-2xl"
+ onclick={(e) => e.stopPropagation()}
+ >
+ <!-- Header -->
+ <div class="flex shrink-0 items-center justify-between border-b border-base-300 px-4 py-3">
+ <div class="flex items-center gap-2">
+ <h2 class="text-sm font-semibold">System Prompt</h2>
+ {#if loading}
+ <span class="loading loading-spinner loading-xs"></span>
+ {/if}
+ </div>
+ <button
+ type="button"
+ class="btn btn-ghost btn-sm btn-square"
+ onclick={onClose}
+ aria-label="Close system prompt builder"
+ >
+ ✕
+ </button>
+ </div>
+
+ <!-- Body: half editor / half variables -->
+ <div class="flex min-h-0 flex-1">
+ <!-- Left: template editor -->
+ <div class="flex w-1/2 min-w-0 flex-col gap-2 border-r border-base-300 p-4">
+ <textarea
+ bind:this={textarea}
+ bind:value
+ class="textarea textarea-bordered min-h-0 w-full flex-1 resize-none font-mono text-xs"
+ placeholder={loading
+ ? "Loading template..."
+ : "Edit the global system prompt template..."}
+ disabled={loading}
+ aria-label="System prompt template"></textarea>
+
+ <div class="flex shrink-0 items-center gap-2">
+ <button
+ type="button"
+ class="btn btn-primary btn-sm"
+ disabled={loading || saving || !hasChanges}
+ onclick={save}
+ >
+ {#if saving}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else}
+ Save
+ {/if}
+ </button>
+ <button
+ type="button"
+ class="btn btn-ghost btn-sm"
+ disabled={loading || !hasChanges}
+ onclick={reset}
+ >
+ Reset
+ </button>
+ {#if justSaved && !hasChanges}
+ <span class="text-xs text-success">Saved.</span>
+ {:else if hasChanges}
+ <span class="text-xs opacity-60">Unsaved changes</span>
+ {/if}
+ </div>
+
+ {#if error}
+ <p class="shrink-0 text-xs text-error">{error}</p>
+ {/if}
+ </div>
+
+ <!-- Right: variable palette -->
+ <div class="flex w-1/2 min-w-0 flex-col overflow-y-auto p-4">
+ <h3 class="mb-2 shrink-0 text-xs font-semibold uppercase opacity-60">Variables</h3>
+ {#if groups.length === 0 && !loading}
+ <p class="text-xs opacity-60">No variables available.</p>
+ {/if}
+ <div class="flex flex-col gap-3">
+ {#each groups as group (group.type)}
+ <div class="rounded-box bg-base-200 p-3">
+ <span class="text-xs font-semibold uppercase opacity-70">{group.type}</span>
+ <div class="mt-2 flex flex-wrap gap-1">
+ {#each group.variables as variable (variable.type + variable.name)}
+ {#if isDynamicVariable(variable)}
+ <div class="flex items-center gap-1">
+ <input
+ type="text"
+ class="input input-bordered input-xs w-32 font-mono"
+ bind:value={filePath}
+ placeholder={variable.name}
+ onkeydown={(e) => {
+ if (e.key === "Enter") void insertFileVariable(variable.type);
+ }}
+ />
+ <button
+ type="button"
+ class="btn btn-xs"
+ onclick={() => void insertFileVariable(variable.type)}
+ >
+ Insert
+ </button>
+ </div>
+ {:else}
+ <button
+ type="button"
+ class="btn btn-xs"
+ title={variable.description}
+ onclick={() => void insertTagAtCursor(buildTag(variable.type, variable.name))}
+ >
+ {variable.name}
+ </button>
+ {/if}
+ {/each}
+ </div>
+ </div>
+ {/each}
+ </div>
+ </div>
+ </div>
+ </div>
</div>