From e7d8cdbd68954034d2a4f6979a233b216212967b Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Thu, 25 Jun 2026 10:55:51 +0900 Subject: feat: workspaces shell + cwd-lsp rename + mcp/settings/system-prompt features + app wiring - workspaces: URL-driven conversation grouping (home listing at /, routing, store, http adapter, WorkspaceCard) wired into the App.svelte shell - rename features/workspace -> features/cwd-lsp (the cwd/lsp status feature) - new features: mcp (status view), settings (chat-limit field), system-prompt (prompt builder), all rendered via the generic surface host - chat: store + ChatView updates - tabs: tabs-store updates - app wiring: ErrorModal (full-screen error surface), app/App.svelte + store.svelte This commit makes HEAD typecheck clean for the first time: the prior HEAD (dfe1711) imported features/settings from app/App.svelte but never committed the feature, so only the full working tree was green. --- src/features/system-prompt/index.ts | 17 ++ .../system-prompt/logic/view-model.test.ts | 90 ++++++++ src/features/system-prompt/logic/view-model.ts | 106 +++++++++ .../system-prompt/ui/SystemPromptBuilder.svelte | 242 +++++++++++++++++++++ 4 files changed, 455 insertions(+) create mode 100644 src/features/system-prompt/index.ts create mode 100644 src/features/system-prompt/logic/view-model.test.ts create mode 100644 src/features/system-prompt/logic/view-model.ts create mode 100644 src/features/system-prompt/ui/SystemPromptBuilder.svelte (limited to 'src/features/system-prompt') diff --git a/src/features/system-prompt/index.ts b/src/features/system-prompt/index.ts new file mode 100644 index 0000000..7c77675 --- /dev/null +++ b/src/features/system-prompt/index.ts @@ -0,0 +1,17 @@ +export type { + LoadSystemPrompt, + LoadSystemPromptVariables, + SaveSystemPrompt, + SystemPromptLoadResult, + SystemPromptSaveResult, + SystemPromptVariablesResult, + VariableGroup, +} from "./logic/view-model"; +export { buildTag, groupVariables, insertTag } 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", +} as const; 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..a0736cc --- /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..6924208 --- /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; + +export type SystemPromptSaveResult = + | { readonly ok: true; readonly template: string } + | { readonly ok: false; readonly error: string }; + +export type SaveSystemPrompt = (template: string) => Promise; + +export type SystemPromptVariablesResult = + | { readonly ok: true; readonly variables: readonly SystemPromptVariable[] } + | { readonly ok: false; readonly error: string }; + +export type LoadSystemPromptVariables = () => Promise; + +// ── 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(); + 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:`). */ +export function isDynamicVariable(variable: SystemPromptVariable): boolean { + return variable.dynamic === true; +} diff --git a/src/features/system-prompt/ui/SystemPromptBuilder.svelte b/src/features/system-prompt/ui/SystemPromptBuilder.svelte new file mode 100644 index 0000000..4e07317 --- /dev/null +++ b/src/features/system-prompt/ui/SystemPromptBuilder.svelte @@ -0,0 +1,242 @@ + + + + + + -- cgit v1.2.3