diff options
Diffstat (limited to 'src/features')
| -rw-r--r-- | src/features/heartbeat/index.ts | 3 | ||||
| -rw-r--r-- | src/features/heartbeat/logic/view-model.test.ts | 63 | ||||
| -rw-r--r-- | src/features/heartbeat/logic/view-model.ts | 41 | ||||
| -rw-r--r-- | src/features/heartbeat/ui/HeartbeatView.svelte | 103 | ||||
| -rw-r--r-- | src/features/heartbeat/ui/PromptEditor.svelte | 307 | ||||
| -rw-r--r-- | src/features/system-prompt/index.ts | 2 |
6 files changed, 469 insertions, 50 deletions
diff --git a/src/features/heartbeat/index.ts b/src/features/heartbeat/index.ts index 00c3eeb..a54a448 100644 --- a/src/features/heartbeat/index.ts +++ b/src/features/heartbeat/index.ts @@ -20,16 +20,19 @@ export { formatRunTime, formDiffers, formFromConfig, + joinInterval, normalizeHeartbeatConfig, normalizeHeartbeatRuns, normalizeInterval, patchFromForm, relativeLabel, + splitInterval, statusLabelFor, viewRun, viewRuns, } from "./logic/view-model"; export { default as HeartbeatView } from "./ui/HeartbeatView.svelte"; +export { default as PromptEditor } from "./ui/PromptEditor.svelte"; export { default as RunModal } from "./ui/RunModal.svelte"; /** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ diff --git a/src/features/heartbeat/logic/view-model.test.ts b/src/features/heartbeat/logic/view-model.test.ts index fc43112..fe97496 100644 --- a/src/features/heartbeat/logic/view-model.test.ts +++ b/src/features/heartbeat/logic/view-model.test.ts @@ -9,11 +9,13 @@ import { formatRunTime, formDiffers, formFromConfig, + joinInterval, normalizeHeartbeatConfig, normalizeHeartbeatRuns, normalizeInterval, patchFromForm, relativeLabel, + splitInterval, statusLabelFor, viewRun, viewRuns, @@ -120,10 +122,12 @@ describe("viewRun / viewRuns", () => { }); describe("config form", () => { - it("emptyForm has defaults (disabled, default interval, default effort)", () => { + it("emptyForm has defaults (disabled, default interval split, default effort)", () => { const f = emptyForm(); expect(f.enabled).toBe(false); - expect(f.intervalMinutes).toBe(DEFAULT_INTERVAL_MINUTES); + // 30 min → 0h 30m + expect(f.intervalHours).toBe(0); + expect(f.intervalMinutes).toBe(30); expect(f.reasoningEffort).toBe("high"); // DEFAULT_REASONING_EFFORT expect(f.systemPrompt).toBe(""); expect(f.model).toBe(""); @@ -139,6 +143,25 @@ describe("config form", () => { expect(f.reasoningEffort).toBe("max"); }); + it("formFromConfig splits intervalMinutes into hours + minutes (0–59)", () => { + expect(formFromConfig(config({ intervalMinutes: 90 }))).toMatchObject({ + intervalHours: 1, + intervalMinutes: 30, + }); + expect(formFromConfig(config({ intervalMinutes: 60 }))).toMatchObject({ + intervalHours: 1, + intervalMinutes: 0, + }); + expect(formFromConfig(config({ intervalMinutes: 59 }))).toMatchObject({ + intervalHours: 0, + intervalMinutes: 59, + }); + expect(formFromConfig(config({ intervalMinutes: 1440 }))).toMatchObject({ + intervalHours: 24, + intervalMinutes: 0, + }); + }); + it("formFromConfig coerces malformed fields safely", () => { const f = formFromConfig( config({ @@ -149,7 +172,9 @@ describe("config form", () => { }), ); expect(f.enabled).toBe(false); // non-true → false - expect(f.intervalMinutes).toBe(1); // clamped + // -5 clamps to 1 → 0h 1m + expect(f.intervalHours).toBe(0); + expect(f.intervalMinutes).toBe(1); expect(f.model).toBe(""); // non-string → "" expect(f.systemPrompt).toBe(""); // undefined → "" }); @@ -164,8 +189,24 @@ describe("config form", () => { expect(normalizeInterval(undefined)).toBe(DEFAULT_INTERVAL_MINUTES); }); - it("patchFromForm clamps interval + carries every field", () => { + it("splitInterval / joinInterval round-trip (and clamp)", () => { + expect(splitInterval(90)).toEqual({ hours: 1, minutes: 30 }); + expect(splitInterval(0)).toEqual({ hours: 0, minutes: 1 }); // 0 → clamps to 1 + expect(splitInterval(1440)).toEqual({ hours: 24, minutes: 0 }); + expect(splitInterval(2000)).toEqual({ hours: 24, minutes: 0 }); // clamped + // join recomputes + clamps + expect(joinInterval(1, 30)).toBe(90); + expect(joinInterval(0, 0)).toBe(1); // 0 → clamps to 1 + expect(joinInterval(25, 0)).toBe(1440); // 1500 → clamps to 1440 + expect(joinInterval(-1, 30)).toBe(30); // negatives floored to 0 + expect(joinInterval("x" as unknown as number, 15)).toBe(15); // non-finite → 0h + }); + + it("patchFromForm recombines hours+minutes into intervalMinutes + carries every field", () => { const f = formFromConfig(config({ intervalMinutes: 2000 })); + // 2000 clamps to 1440 → 24h 0m in the form + expect(f.intervalHours).toBe(24); + expect(f.intervalMinutes).toBe(0); const patch = patchFromForm(f); expect(patch.intervalMinutes).toBe(1440); expect(patch.enabled).toBe(false); @@ -175,6 +216,13 @@ describe("config form", () => { expect(patch.taskPrompt).toBe("check status"); }); + it("patchFromForm recombines an arbitrary hours/minutes edit", () => { + const f = formFromConfig(config({ intervalMinutes: 15 })); + f.intervalHours = 2; + f.intervalMinutes = 45; + expect(patchFromForm(f).intervalMinutes).toBe(165); + }); + it("formDiffers is false for a form seeded from the config (no edits)", () => { const c = config({ reasoningEffort: "medium" }); const f = formFromConfig(c); @@ -188,6 +236,13 @@ describe("config form", () => { expect(formDiffers(f, c)).toBe(true); }); + it("formDiffers is true after an interval edit (hours or minutes)", () => { + const c = config({ intervalMinutes: 90 }); + const f = formFromConfig(c); + f.intervalMinutes = 45; // 1h45m vs 1h30m + expect(formDiffers(f, c)).toBe(true); + }); + it("formDiffers treats null config effort as the default (matches the resolved form)", () => { const c = config({ reasoningEffort: null }); const f = formFromConfig(c); diff --git a/src/features/heartbeat/logic/view-model.ts b/src/features/heartbeat/logic/view-model.ts index f5b9f96..b79fff6 100644 --- a/src/features/heartbeat/logic/view-model.ts +++ b/src/features/heartbeat/logic/view-model.ts @@ -159,11 +159,16 @@ function dateLabel(epochMs: number): string { * `HeartbeatConfig` that the inputs bind to. `reasoningEffort` is resolved to * an effective level for the `<select>` (null ⇒ default `high`), exactly like * the per-conversation selector. + * + * The interval is split into `intervalHours` + `intervalMinutes` (0–59) for the + * UI (two inputs), and recombined to a total-minutes value at the patch seam + * (`patchFromForm`); the backend stores a single `intervalMinutes`. */ export interface HeartbeatFormState { enabled: boolean; systemPrompt: string; taskPrompt: string; + intervalHours: number; intervalMinutes: number; model: string; reasoningEffort: ReasoningEffort; @@ -172,16 +177,33 @@ export interface HeartbeatFormState { /** The default interval (minutes) shown for an empty/unset config. */ export const DEFAULT_INTERVAL_MINUTES = 30; +/** Split a total-minutes value into { hours, minutes (0–59) }. Pure. */ +export function splitInterval(totalMinutes: number): { hours: number; minutes: number } { + const total = normalizeInterval(totalMinutes); + const hours = Math.floor(total / 60); + const minutes = total - hours * 60; + return { hours, minutes }; +} + +/** Recombine hours + minutes into a clamped total-minutes value. Pure. */ +export function joinInterval(hours: number, minutes: number): number { + const h = Number.isFinite(hours) ? Math.max(0, Math.floor(hours)) : 0; + const m = Number.isFinite(minutes) ? Math.max(0, Math.floor(minutes)) : 0; + return normalizeInterval(h * 60 + m); +} + /** * Seed the editable form state from a loaded config, applying safe defaults for * any malformed/absent backend field so the inputs are never `undefined`. */ export function formFromConfig(config: HeartbeatConfig): HeartbeatFormState { + const { hours, minutes } = splitInterval(config.intervalMinutes); return { enabled: config.enabled === true, systemPrompt: config.systemPrompt ?? "", taskPrompt: config.taskPrompt ?? "", - intervalMinutes: normalizeInterval(config.intervalMinutes), + intervalHours: hours, + intervalMinutes: minutes, model: typeof config.model === "string" ? config.model : "", reasoningEffort: effectiveEffort(config.reasoningEffort ?? null), }; @@ -189,11 +211,13 @@ export function formFromConfig(config: HeartbeatConfig): HeartbeatFormState { /** An empty form (before the config loads). */ export function emptyForm(): HeartbeatFormState { + const { hours, minutes } = splitInterval(DEFAULT_INTERVAL_MINUTES); return { enabled: false, systemPrompt: "", taskPrompt: "", - intervalMinutes: DEFAULT_INTERVAL_MINUTES, + intervalHours: hours, + intervalMinutes: minutes, model: "", reasoningEffort: DEFAULT_REASONING_EFFORT, }; @@ -209,16 +233,17 @@ export function normalizeInterval(value: unknown): number { } /** - * The patch to PUT when persisting the form. Only `intervalMinutes` is clamped; - * text fields are sent verbatim. `reasoningEffort` is always present (a resolved - * level) since the heartbeat has no per-run override — it persists the level. + * The patch to PUT when persisting the form. The split hours+minutes are + * recombined into a single `intervalMinutes` (clamped); text fields are sent + * verbatim. `reasoningEffort` is always present (a resolved level) since the + * heartbeat has no per-run override — it persists the level. */ export function patchFromForm(form: HeartbeatFormState): HeartbeatConfigPatch { return { enabled: form.enabled, systemPrompt: form.systemPrompt, taskPrompt: form.taskPrompt, - intervalMinutes: normalizeInterval(form.intervalMinutes), + intervalMinutes: joinInterval(form.intervalHours, form.intervalMinutes), model: form.model, reasoningEffort: form.reasoningEffort, }; @@ -226,11 +251,13 @@ export function patchFromForm(form: HeartbeatFormState): HeartbeatConfigPatch { /** Whether the form differs from the loaded config (drives the Save button). */ export function formDiffers(form: HeartbeatFormState, config: HeartbeatConfig): boolean { + const { hours, minutes } = splitInterval(config.intervalMinutes); return ( form.enabled !== config.enabled || form.systemPrompt !== (config.systemPrompt ?? "") || form.taskPrompt !== (config.taskPrompt ?? "") || - form.intervalMinutes !== normalizeInterval(config.intervalMinutes) || + form.intervalHours !== hours || + form.intervalMinutes !== minutes || form.model !== (typeof config.model === "string" ? config.model : "") || form.reasoningEffort !== effectiveEffort(config.reasoningEffort ?? null) ); diff --git a/src/features/heartbeat/ui/HeartbeatView.svelte b/src/features/heartbeat/ui/HeartbeatView.svelte index 7e0e7b7..ba61759 100644 --- a/src/features/heartbeat/ui/HeartbeatView.svelte +++ b/src/features/heartbeat/ui/HeartbeatView.svelte @@ -5,12 +5,10 @@ import { badgeForStatus, type Badge, - DEFAULT_INTERVAL_MINUTES, emptyForm, effortOptions, formDiffers, formFromConfig, - normalizeInterval, patchFromForm, viewRuns, type HeartbeatFormState, @@ -22,6 +20,8 @@ SaveHeartbeatConfig, StopHeartbeatRun, } from "../logic/types"; + import type { LoadSystemPromptVariables } from "../../system-prompt"; + import PromptEditor from "./PromptEditor.svelte"; let { models, @@ -29,6 +29,7 @@ saveConfig, loadRuns, stopRun, + loadVariables, onOpenRun, }: { /** The available model names (for the config's model dropdown). */ @@ -37,6 +38,8 @@ saveConfig: SaveHeartbeatConfig; loadRuns: LoadHeartbeatRuns; stopRun: StopHeartbeatRun; + /** Load the available system-prompt variables (palette in the prompt editor). */ + loadVariables: LoadSystemPromptVariables; /** Open a run's chat in the fullscreen modal (composition-root wires the live watch). */ onOpenRun: (run: HeartbeatRunView) => void; } = $props(); @@ -60,6 +63,7 @@ let saveError = $state<string | null>(null); let justSaved = $state(false); let hasConfig = $state(false); + let promptEditorOpen = $state(false); const hasChanges = $derived(formDiffers(form, loadedConfig) && hasConfig); @@ -230,30 +234,20 @@ {#if configError} <p class="text-xs text-error">{configError}</p> {:else} - <!-- System prompt --> + <!-- Prompts (open the full-page editor) --> <section class="flex flex-col gap-1"> - <span class="text-xs font-semibold uppercase opacity-60">System prompt</span> - <textarea - class="textarea textarea-bordered textarea-sm h-20 w-full font-mono text-xs" - placeholder="You are an autonomous agent…" - value={form.systemPrompt} - disabled={saving || configLoading} - oninput={(e) => (form = { ...form, systemPrompt: e.currentTarget.value })} - aria-label="Heartbeat system prompt" - ></textarea> - </section> - - <!-- Task prompt --> - <section class="flex flex-col gap-1"> - <span class="text-xs font-semibold uppercase opacity-60">Task prompt</span> - <textarea - class="textarea textarea-bordered textarea-sm h-20 w-full font-mono text-xs" - placeholder="Check the system status and report…" - value={form.taskPrompt} + <span class="text-xs font-semibold uppercase opacity-60">Prompts</span> + <button + type="button" + class="btn btn-sm btn-outline" disabled={saving || configLoading} - oninput={(e) => (form = { ...form, taskPrompt: e.currentTarget.value })} - aria-label="Heartbeat task prompt" - ></textarea> + onclick={() => (promptEditorOpen = true)} + > + Edit prompts + </button> + <p class="text-xs opacity-50"> + Open the editor for the system + task prompts (with a variable palette). + </p> </section> <!-- Model + reasoning effort --> @@ -297,32 +291,48 @@ </div> </section> - <!-- Interval --> + <!-- Interval (hours + minutes) --> <section class="flex flex-col gap-1"> - <span class="text-xs font-semibold uppercase opacity-60">Interval (minutes)</span> + <span class="text-xs font-semibold uppercase opacity-60">Interval</span> <div class="flex items-center gap-2"> <input type="number" - class="input input-bordered input-sm w-24" - min="1" - max="1440" - placeholder={String(DEFAULT_INTERVAL_MINUTES)} + class="input input-bordered input-sm w-20" + min="0" + max="24" + value={form.intervalHours} + disabled={saving || configLoading} + oninput={(e) => { + const n = Number.parseInt(e.currentTarget.value, 10); + form = { ...form, intervalHours: Number.isNaN(n) ? 0 : n }; + }} + onchange={(e) => { + const clamped = Math.max(0, Math.min(24, form.intervalHours)); + form = { ...form, intervalHours: clamped }; + e.currentTarget.value = String(clamped); + }} + aria-label="Heartbeat interval hours" + /> + <span class="text-xs opacity-60">h</span> + <input + type="number" + class="input input-bordered input-sm w-20" + min="0" + max="59" value={form.intervalMinutes} disabled={saving || configLoading} oninput={(e) => { const n = Number.parseInt(e.currentTarget.value, 10); - form = { - ...form, - intervalMinutes: Number.isNaN(n) ? DEFAULT_INTERVAL_MINUTES : n, - }; + form = { ...form, intervalMinutes: Number.isNaN(n) ? 0 : n }; }} onchange={(e) => { - form = { ...form, intervalMinutes: normalizeInterval(form.intervalMinutes) }; - e.currentTarget.value = String(form.intervalMinutes); + const clamped = Math.max(0, Math.min(59, form.intervalMinutes)); + form = { ...form, intervalMinutes: clamped }; + e.currentTarget.value = String(clamped); }} - aria-label="Heartbeat interval in minutes" + aria-label="Heartbeat interval minutes" /> - <span class="text-xs opacity-60">min between runs</span> + <span class="text-xs opacity-60">m between runs</span> </div> </section> @@ -419,3 +429,20 @@ {/if} </section> </div> + +{#if promptEditorOpen} + <PromptEditor + systemPrompt={form.systemPrompt} + taskPrompt={form.taskPrompt} + {loadVariables} + {saveConfig} + onSaved={(systemPrompt, taskPrompt) => { + // Sync the form + the diff baseline so the main Save button + formDiffers + // stay accurate (the editor persisted the prompts already). + form = { ...form, systemPrompt, taskPrompt }; + loadedConfig = { ...loadedConfig, systemPrompt, taskPrompt }; + justSaved = true; + }} + onClose={() => (promptEditorOpen = false)} + /> +{/if} diff --git a/src/features/heartbeat/ui/PromptEditor.svelte b/src/features/heartbeat/ui/PromptEditor.svelte new file mode 100644 index 0000000..2377428 --- /dev/null +++ b/src/features/heartbeat/ui/PromptEditor.svelte @@ -0,0 +1,307 @@ +<script lang="ts"> + import type { SystemPromptVariable } from "@dispatch/transport-contract"; + import { tick, untrack } from "svelte"; + import { + buildTag, + groupVariables, + insertTag, + isDynamicVariable, + type LoadSystemPromptVariables, + } from "../../system-prompt"; + import type { SaveHeartbeatConfig } from "../logic/types"; + + let { + systemPrompt, + taskPrompt, + loadVariables, + saveConfig, + onSaved, + onClose, + }: { + /** The current system prompt (seeded from the loaded config). */ + systemPrompt: string; + /** The current task prompt (seeded from the loaded config). */ + taskPrompt: string; + /** Load the available variables (`GET /system-prompt/variables`). */ + loadVariables: LoadSystemPromptVariables; + /** Persist both prompts via a partial heartbeat config PUT. */ + saveConfig: SaveHeartbeatConfig; + /** Called after a successful save with the persisted prompts, so the parent + * can sync its form (the editor edits local copies). */ + onSaved: (systemPrompt: string, taskPrompt: string) => void; + onClose: () => void; + } = $props(); + + // Local editable copies (the modal edits in isolation; Save commits both). + // Seeded ONCE from the props via `untrack` — the modal is re-mounted per open + // (keyed by the parent), so it captures the initial prompts, not a live view. + let system = $state(untrack(() => systemPrompt)); + let task = $state(untrack(() => taskPrompt)); + /** Snapshots at open, to diff against (drives Save + Reset). */ + let loadedSystem = $state(untrack(() => systemPrompt)); + let loadedTask = $state(untrack(() => taskPrompt)); + + let variables = $state<readonly SystemPromptVariable[]>([]); + let varsLoading = $state(false); + let varsError = $state<string | null>(null); + + let saving = $state(false); + let saveError = $state<string | null>(null); + let justSaved = $state(false); + + // The textarea currently focused — variable insertion targets THIS one. + type Field = "system" | "task"; + let activeField = $state<Field>("system"); + let systemEl = $state<HTMLTextAreaElement | null>(null); + let taskEl = $state<HTMLTextAreaElement | null>(null); + + const groups = $derived(groupVariables(variables)); + const hasChanges = $derived(system !== loadedSystem || task !== loadedTask); + + async function loadVars(): Promise<void> { + untrack(() => { + varsLoading = true; + varsError = null; + }); + const result = await loadVariables(); + varsLoading = false; + if (result.ok) { + variables = result.variables; + } else { + varsError = result.error; + } + } + + async function save(): Promise<void> { + if (saving || !hasChanges) return; + saving = true; + saveError = null; + justSaved = false; + const result = await saveConfig({ systemPrompt: system, taskPrompt: task }); + saving = false; + if (result === null) return; + if (result.ok) { + loadedSystem = system; + loadedTask = task; + justSaved = true; + onSaved(system, task); + } else { + saveError = result.error; + } + } + + function reset(): void { + system = loadedSystem; + task = loadedTask; + saveError = null; + justSaved = false; + } + + /** + * Insert a variable tag into the ACTIVE textarea at its cursor. The active + * field is tracked via focus handlers; insertion uses that field's element + + * its own text (so a tag never lands in the wrong box). + */ + async function insertAtActive(tag: string): Promise<void> { + const el = activeField === "system" ? systemEl : taskEl; + if (el === null) return; + const start = el.selectionStart; + const end = el.selectionEnd; + if (activeField === "system") { + const ins = insertTag(system, tag, start, end); + system = ins.template; + await tick(); + el.focus(); + el.setSelectionRange(ins.cursor, ins.cursor); + } else { + const ins = insertTag(task, tag, start, end); + task = ins.template; + await tick(); + el.focus(); + el.setSelectionRange(ins.cursor, ins.cursor); + } + } + + /** Dynamic (file:<path>) variable: build the tag from the input + insert. */ + async function insertDynamic(type: string, path: string): Promise<void> { + const trimmed = path.trim(); + if (trimmed.length === 0) return; + await insertAtActive(buildTag(type, trimmed)); + } + + function onKeydown(e: KeyboardEvent): void { + if (e.key === "Escape") onClose(); + } + + // Load the variable palette once on open. + $effect(() => { + void loadVars(); + }); +</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="Heartbeat prompt editor" + 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">Heartbeat Prompts</h2> + {#if varsLoading} + <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 prompt editor" + > + ✕ + </button> + </div> + + <!-- Body: half editor (two boxes) / half variables --> + <div class="flex min-h-0 flex-1"> + <!-- Left: two text editors (system top, task bottom) --> + <div class="flex w-1/2 min-w-0 flex-col gap-2 border-r border-base-300 p-4"> + <div class="flex min-h-0 flex-1 flex-col gap-1"> + <span class="shrink-0 text-xs font-semibold uppercase opacity-60">System prompt</span> + <textarea + bind:this={systemEl} + bind:value={system} + onfocus={() => (activeField = "system")} + class="textarea textarea-bordered min-h-0 w-full flex-1 resize-none font-mono text-xs" + placeholder="You are an autonomous agent…" + disabled={saving} + aria-label="Heartbeat system prompt" + ></textarea> + </div> + + <div class="flex min-h-0 flex-1 flex-col gap-1"> + <span class="shrink-0 text-xs font-semibold uppercase opacity-60">Task prompt</span> + <textarea + bind:this={taskEl} + bind:value={task} + onfocus={() => (activeField = "task")} + class="textarea textarea-bordered min-h-0 w-full flex-1 resize-none font-mono text-xs" + placeholder="Check the system status and report…" + disabled={saving} + aria-label="Heartbeat task prompt" + ></textarea> + </div> + + <div class="flex shrink-0 flex-wrap items-center gap-2"> + <button + type="button" + class="btn btn-primary btn-sm" + disabled={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={!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 saveError} + <p class="shrink-0 text-xs text-error">{saveError}</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> + <p class="mb-3 shrink-0 text-xs opacity-50"> + Click a variable to insert it into the focused prompt box. + </p> + {#if varsError} + <p class="text-xs text-error">{varsError}</p> + {:else if groups.length === 0 && !varsLoading} + <p class="text-xs opacity-60">No variables available.</p> + {:else} + <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)} + <!-- Dynamic (file:<path>) variable: a path input + Insert button. --> + <div class="flex items-center gap-1"> + <input + type="text" + class="input input-bordered input-xs w-32 font-mono" + placeholder={variable.name} + onkeydown={(e) => { + if (e.key === "Enter") { + const v = e.currentTarget.value; + void insertDynamic(variable.type, v); + e.currentTarget.value = ""; + } + }} + /> + <button + type="button" + class="btn btn-xs" + onclick={(e) => { + const input = (e.currentTarget as HTMLButtonElement) + .previousElementSibling as HTMLInputElement | null; + if (input !== null) { + void insertDynamic(variable.type, input.value); + input.value = ""; + } + }} + > + Insert + </button> + </div> + {:else} + <button + type="button" + class="btn btn-xs" + title={variable.description} + onclick={() => + void insertAtActive(buildTag(variable.type, variable.name))} + > + {variable.name} + </button> + {/if} + {/each} + </div> + </div> + {/each} + </div> + {/if} + </div> + </div> + </div> +</div> diff --git a/src/features/system-prompt/index.ts b/src/features/system-prompt/index.ts index 7c77675..142d4b1 100644 --- a/src/features/system-prompt/index.ts +++ b/src/features/system-prompt/index.ts @@ -7,7 +7,7 @@ export type { SystemPromptVariablesResult, VariableGroup, } from "./logic/view-model"; -export { buildTag, groupVariables, insertTag } 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. */ |
