summaryrefslogtreecommitdiffhomepage
path: root/src/features/system-prompt/logic
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/system-prompt/logic')
-rw-r--r--src/features/system-prompt/logic/view-model.test.ts90
-rw-r--r--src/features/system-prompt/logic/view-model.ts106
2 files changed, 196 insertions, 0 deletions
diff --git a/src/features/system-prompt/logic/view-model.test.ts b/src/features/system-prompt/logic/view-model.test.ts
new file mode 100644
index 0000000..223327b
--- /dev/null
+++ b/src/features/system-prompt/logic/view-model.test.ts
@@ -0,0 +1,90 @@
+import type { SystemPromptVariable } from "@dispatch/transport-contract";
+import { describe, expect, it } from "vitest";
+import {
+ 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("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("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("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"]);
+ });
+
+ 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);
+ });
+
+ 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
new file mode 100644
index 0000000..e202ee3
--- /dev/null
+++ b/src/features/system-prompt/logic/view-model.ts
@@ -0,0 +1,106 @@
+import type { SystemPromptVariable } from "@dispatch/transport-contract";
+
+/**
+ * Pure core for the system prompt builder — zero DOM, zero effects, zero Svelte.
+ *
+ * The system prompt is a global template, stored on the backend, resolved once
+ * per conversation at first turn (then persisted for prompt-cache safety). The
+ * frontend builder exposes the template text plus a palette of available
+ * variables; clicking a variable inserts `[type:name]` at the cursor. This module
+ * holds the pure logic: tag construction, insertion into a template, and
+ * grouping of variables. The HTTP edge is injected via the ports defined below.
+ */
+
+// ── 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 };
+
+export type LoadSystemPrompt = () => Promise<SystemPromptLoadResult>;
+
+export type SystemPromptSaveResult =
+ | { 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 };
+
+export type LoadSystemPromptVariables = () => Promise<SystemPromptVariablesResult>;
+
+// ── Template helpers ──────────────────────────────────────────────────────────
+
+/** Build the literal placeholder `[type:name]` for a variable. */
+export function buildTag(type: string, name: string): string {
+ 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}]`;
+}
+
+/** Build the literal placeholder `[if !type:name]` for a negated conditional block. */
+export function buildIfNotTag(type: string, name: string): string {
+ return `[if !${type}:${name}]`;
+}
+
+export interface Insertion {
+ /** Template text after insertion. */
+ template: string;
+ /** New cursor position (caret index) after insertion. */
+ cursor: number;
+}
+
+/**
+ * Insert `tag` into `template` at the current selection range. Replaces any
+ * selected text. Returns both the new template and the new cursor position so
+ * the caller can restore the caret after the inserted tag.
+ */
+export function insertTag(
+ 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 };
+}
+
+// ── Variable grouping ─────────────────────────────────────────────────────────
+
+export interface VariableGroup {
+ /** Variable type (e.g. `"system"`, `"file"`, `"prompt"`, `"git"`). */
+ readonly type: string;
+ /** Variables of this type. */
+ readonly variables: readonly SystemPromptVariable[];
+}
+
+/**
+ * Group variables by type, preserving the order of first appearance within each
+ * group and the order of first-appearing types.
+ */
+export function groupVariables(
+ 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) ?? [] }));
+}
+
+/** Whether a variable is "dynamic" (any name is valid, e.g. `file:<path>`). */
+export function isDynamicVariable(variable: SystemPromptVariable): boolean {
+ return variable.dynamic === true;
+}