pi-soly 1.11.1 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +60 -14
  2. package/artifact/index.ts +241 -0
  3. package/artifact/render.ts +239 -0
  4. package/artifact/server.ts +384 -0
  5. package/artifact/session.ts +368 -0
  6. package/ask/README.md +20 -8
  7. package/ask/index.ts +100 -36
  8. package/ask/picker.ts +345 -29
  9. package/commands.ts +246 -30
  10. package/config.ts +124 -8
  11. package/core.ts +48 -236
  12. package/deck/deck.ts +386 -0
  13. package/deck/index.ts +113 -0
  14. package/index.ts +166 -37
  15. package/iteration.ts +1 -9
  16. package/mcp/commands.ts +12 -0
  17. package/mcp/direct-tools.ts +19 -2
  18. package/mcp/index.ts +144 -2
  19. package/mcp/init.ts +11 -0
  20. package/mcp/lifecycle.ts +9 -2
  21. package/mcp/server-manager.ts +30 -18
  22. package/mcp/state.ts +7 -0
  23. package/mcp/tool-cache.ts +135 -0
  24. package/nudge.ts +20 -3
  25. package/package.json +10 -3
  26. package/skills/soly-framework/SKILL.md +64 -37
  27. package/tool-hints.ts +62 -0
  28. package/tools.ts +28 -100
  29. package/util.ts +250 -0
  30. package/visual/chrome.ts +166 -0
  31. package/visual/colors.ts +24 -0
  32. package/visual/data.ts +62 -0
  33. package/visual/footer.ts +129 -0
  34. package/visual/format.ts +73 -0
  35. package/visual/glyphs.ts +35 -0
  36. package/visual/gradient.ts +122 -0
  37. package/visual/index.ts +18 -0
  38. package/visual/list-panel.ts +207 -0
  39. package/visual/segments.ts +136 -0
  40. package/visual/style.ts +34 -0
  41. package/visual/topbar.ts +55 -0
  42. package/visual/welcome.ts +265 -0
  43. package/visual/working.ts +66 -0
  44. package/workflows/execute.ts +17 -7
  45. package/workflows/index.ts +91 -44
  46. package/workflows/inspect.ts +23 -26
  47. package/workflows/migrate.ts +85 -0
  48. package/workflows/parser.ts +4 -2
  49. package/workflows/planning.ts +9 -8
  50. package/workflows/verify.ts +194 -0
  51. package/workflows-data/discuss-phase.md +10 -6
  52. package/agents-install.ts +0 -106
  53. package/ask/prompt.ts +0 -41
  54. package/ask/tests/prompt.test.ts +0 -54
package/README.md CHANGED
@@ -27,12 +27,17 @@ pi install npm:pi-soly
27
27
  Restart pi (`/reload`), and you have:
28
28
 
29
29
  - **Project management** — plans, state, phases, decisions
30
- - **Workflow engine** — `/plan`, `/execute`, `/resume`, `/inspect`, `/discuss`, `/quick`
30
+ - **Workflow engine** — plain-text verbs: `soly discuss` · `plan` · `execute` · `verify` · `pause`/`resume`
31
+ - **Self-review loop** — `soly verify` re-reviews the work until "No issues found."
32
+ - **Visual chrome** — native footer, equalizer working spinner with live telemetry, gradient welcome banner
33
+ - **Rules & docs modal** — `/rules` and `/docs` open a fuzzy list + preview panel (no chat dumps)
31
34
  - **Mandatory rules** — strict-mode directives injected every turn
32
- - **Multi-question picker** — `ask_pro` tool for the LLM
35
+ - **Multi-question picker** — `ask_pro` tool for the LLM (single/multi-select, free-text, skip)
36
+ - **Decision deck** — `decision_deck` tool: full-screen TUI cards for comparing design options by code shape
37
+ - **HTML artifacts** — `html_artifact` tool serves self-contained HTML from a per-session browser gallery (live-updating, one stable URL)
33
38
  - **Skill-based execution** — LLM reads the `soly-framework` skill on demand
34
39
 
35
- No agents, no rotors, no mode cycling. The LLM is the executor. You focus on the work.
40
+ The LLM drives execution; `plan`/`execute` delegate to a `worker` subagent when one is available (via pi-subagents), with first-party delegation on the roadmap. You focus on the work.
36
41
 
37
42
  ---
38
43
 
@@ -50,15 +55,15 @@ No agents, no rotors, no mode cycling. The LLM is the executor. You focus on the
50
55
 
51
56
  ## 📋 Commands
52
57
 
53
- ### Workflow
58
+ ### Workflow — plain-text verbs (type `soly <verb>`, not slash)
54
59
 
55
60
  ```bash
56
- /plan # generate PLAN.md for the current phase
57
- /execute # execute plan directly (LLM does the work)
58
- /resume # pick up a paused session
59
- /inspect # show current state summary
60
- /discuss 3 # talk through decisions before planning phase 3
61
- /quick "fix typo" # one-shot task with HANDOFF-style context
61
+ soly discuss 3 # talk through decisions before planning phase 3
62
+ soly plan 3 # generate PLAN.md for phase 3
63
+ soly execute 3 # execute phase 3 (or `soly execute 3.02` for one plan)
64
+ soly verify # self-review loop until "No issues found." (`soly verify stop` to exit)
65
+ soly pause # save a handoff; `soly resume` to pick it back up
66
+ soly status # current position + progress (no LLM round-trip)
62
67
  ```
63
68
 
64
69
  ### State inspection (`/soly`)
@@ -178,6 +183,42 @@ ask_pro({
178
183
  ```
179
184
 
180
185
  The LLM calls `ask_pro` when it needs structured input. Tab through questions, pick ⭐ options, confirm.
186
+ Per-option `preview` shows a side panel (fenced code is syntax-highlighted); `freeText: true` makes a typed-answer question; multi-select takes `minSelect`/`maxSelect`; press `s` to skip a question.
187
+
188
+ ---
189
+
190
+ ## 🃏 Decision Deck
191
+
192
+ `decision_deck` tool for the LLM. A full-screen TUI deck — one framed card per option, each with a syntax-highlighted code snippet and pros/cons — for design/architecture forks where the choice hinges on seeing the concrete shape, not a label.
193
+
194
+ ```ts
195
+ decision_deck({
196
+ title: "State management",
197
+ prompt: "How should modules communicate?",
198
+ options: [
199
+ { title: "Direct calls", summary: "Call modules directly.", pros: ["simple"], cons: ["coupling"] },
200
+ { title: "Event bus", code: "const bus = new Bus()\nbus.emit('x')", lang: "ts",
201
+ pros: ["decoupled"], cons: ["harder to trace"], recommended: true }
202
+ ]
203
+ })
204
+ ```
205
+
206
+ Flip cards with ←/→ (or 1-N), choose with Enter, Esc to cancel. Native TUI — no browser, no server.
207
+
208
+ ---
209
+
210
+ ## 🖼 HTML Artifacts
211
+
212
+ `html_artifact` tool for the LLM — soly's local "artifacts". Renders HTML (a full document or just body content, themed light/dark) and serves it from a **per-session gallery SPA** — a sidebar of every artifact this session, an iframe viewer, a filter box, a light/dark toggle, and live SSE updates — on one stable localhost URL, opened in your browser. Pass `id` to update an artifact in place; pass `assets` to write sibling files (images/css/json) the HTML references; restyle everything via `.soly/artifact-theme.css`. (Falls back to opening the file directly if the server is disabled.)
213
+
214
+ ```ts
215
+ html_artifact({
216
+ title: "API examples",
217
+ html: "<h2>Usage</h2><pre><code>client.send(msg)</code></pre>"
218
+ })
219
+ ```
220
+
221
+ Use it when a visual, rendered result beats terminal text (example galleries, comparisons, diagrams). The gallery URL lives only while the pi session runs. **`/artifacts`** reopens the gallery anytime (modal: Enter opens an artifact, `g` the gallery, `x` delete, `/artifacts clear` clears); a `▦ N` footer indicator shows the live count. Config under `artifacts` (`open`, `dir`, `server`, `theme`, `retentionDays`).
181
222
 
182
223
  ---
183
224
 
@@ -237,11 +278,16 @@ State lives in `.soly/` — portable, git-friendly, human-readable. Migrate to v
237
278
  ├── rules/ # rules (glob-matched or always-on)
238
279
  └── phases/
239
280
  └── 01-foundation/
240
- ├── 01-CONTEXT.md # domain + decisions for this phase
241
- ├── 01-RESEARCH.md # what we looked up
242
- └── 01-PLAN.md # ordered steps to execute
281
+ ├── 01-CONTEXT.md # domain + decisions for this phase
282
+ ├── 01-RESEARCH.md # what we looked up
283
+ └── tasks/ # unified model: one dir per task
284
+ └── auth-login-a3f9/
285
+ ├── PLAN.md # frontmatter: id, kind, status, depends-on
286
+ └── SUMMARY.md
243
287
  ```
244
288
 
289
+ > Legacy projects (standalone `NN-MM-PLAN.md` / a `features/` dir) still work; run `soly migrate` to convert them to this layout.
290
+
245
291
  ---
246
292
 
247
293
  ## 📚 Events
@@ -274,7 +320,7 @@ bun install
274
320
  ### Test + typecheck
275
321
 
276
322
  ```bash
277
- bun test # 366 tests across 24 files
323
+ bun test # run the test suite
278
324
  bun run typecheck # tsc --noEmit
279
325
  bun run ci # both
280
326
  ```
@@ -0,0 +1,241 @@
1
+ // =============================================================================
2
+ // index.ts — pi-artifact extension entry point
3
+ // =============================================================================
4
+ //
5
+ // Registers one LLM tool: `html_artifact`. Renders LLM-supplied HTML to a
6
+ // self-contained file in a per-project dir and (by default) serves it from
7
+ // a session-scoped HTTP server with a live gallery of every artifact made this
8
+ // session — soly's local equivalent of Claude Code artifacts. Falls back to
9
+ // opening the file directly when the server is disabled or can't bind.
10
+ //
11
+ // The pure HTML builders live in render.ts; the server in server.ts; this module
12
+ // does the I/O wiring and teaches the LLM about the capability via the prompt.
13
+ // =============================================================================
14
+
15
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
16
+ import { platform } from "node:os";
17
+ import * as path from "node:path";
18
+ import * as fs from "node:fs";
19
+ import { Type } from "typebox";
20
+ import type { SolyConfig } from "../config.ts";
21
+ import { atomicWriteFileSync } from "../util.ts";
22
+ import { buildArtifactHtml, artifactFileName, artifactFileNameForId, DEFAULT_CSS } from "./render.ts";
23
+ import {
24
+ artifactDir,
25
+ resolveArtifactBase,
26
+ ensureArtifactServer,
27
+ disposeArtifactServer,
28
+ invalidateArtifactServer,
29
+ } from "./session.ts";
30
+
31
+ type ToolText = { content: { type: "text"; text: string }[]; details: Record<string, unknown> };
32
+ type Asset = { path: string; content: string; encoding?: string };
33
+
34
+ /** Load the artifact CSS theme: config override → .soly/artifact-theme.css →
35
+ * built-in DEFAULT_CSS. */
36
+ function loadCss(themeCfg: string, cwd: string): string {
37
+ const candidates: string[] = [];
38
+ const t = themeCfg.trim();
39
+ if (t) candidates.push(path.isAbsolute(t) ? t : path.join(cwd, t));
40
+ candidates.push(path.join(cwd, ".soly", "artifact-theme.css"));
41
+ for (const c of candidates) {
42
+ try {
43
+ return fs.readFileSync(c, "utf-8");
44
+ } catch {
45
+ // try next
46
+ }
47
+ }
48
+ return DEFAULT_CSS;
49
+ }
50
+
51
+ /** Delete session artifact dirs older than `days` under `baseDir`. Best-effort;
52
+ * skips when days <= 0. */
53
+ function pruneOldSessions(baseDir: string, days: number): void {
54
+ if (days <= 0) return;
55
+ const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
56
+ let entries: fs.Dirent[];
57
+ try {
58
+ entries = fs.readdirSync(baseDir, { withFileTypes: true });
59
+ } catch {
60
+ return; // base dir doesn't exist yet — nothing to prune
61
+ }
62
+ for (const e of entries) {
63
+ if (!e.isDirectory()) continue;
64
+ const p = path.join(baseDir, e.name);
65
+ try {
66
+ if (fs.statSync(p).mtimeMs < cutoff) fs.rmSync(p, { recursive: true, force: true });
67
+ } catch {
68
+ // best effort
69
+ }
70
+ }
71
+ }
72
+
73
+ /** Write sibling assets into the session dir (confined to it). Returns count. */
74
+ function writeAssets(dir: string, assets: Asset[]): number {
75
+ const root = path.resolve(dir);
76
+ let n = 0;
77
+ for (const a of assets) {
78
+ const target = path.resolve(root, a.path);
79
+ if (target !== root && !target.startsWith(root + path.sep)) continue; // skip traversal
80
+ try {
81
+ fs.mkdirSync(path.dirname(target), { recursive: true });
82
+ const buf = a.encoding === "base64" ? Buffer.from(a.content, "base64") : Buffer.from(a.content, "utf-8");
83
+ fs.writeFileSync(target, buf);
84
+ n++;
85
+ } catch {
86
+ // best effort
87
+ }
88
+ }
89
+ return n;
90
+ }
91
+
92
+ /** Open a file or URL with the OS default handler (browser for .html / http). */
93
+ async function openInBrowser(pi: ExtensionAPI, target: string): Promise<void> {
94
+ const o = platform();
95
+ const r =
96
+ o === "darwin"
97
+ ? await pi.exec("open", [target])
98
+ : o === "win32"
99
+ ? await pi.exec("cmd", ["/c", "start", "", target])
100
+ : await pi.exec("xdg-open", [target]);
101
+ if (r.code !== 0) throw new Error(r.stderr || `open failed (exit ${r.code})`);
102
+ }
103
+
104
+ /** Direct-file fallback: open the .html file itself (no server). */
105
+ async function fileMode(
106
+ pi: ExtensionAPI,
107
+ file: string,
108
+ bytes: number,
109
+ shouldOpen: boolean,
110
+ note?: string,
111
+ ): Promise<ToolText> {
112
+ let opened = false;
113
+ let openError: string | undefined;
114
+ if (shouldOpen) {
115
+ try {
116
+ await openInBrowser(pi, file);
117
+ opened = true;
118
+ } catch (err) {
119
+ openError = String(err);
120
+ }
121
+ }
122
+ const lines = [`Artifact written: ${file}`];
123
+ if (note) lines.push(`(${note})`);
124
+ if (opened) lines.push("Opened in the default browser.");
125
+ else if (shouldOpen) lines.push(`(could not auto-open: ${openError ?? "unknown"} — open the file manually)`);
126
+ else lines.push("(not opened — open the file manually or set artifacts.open)");
127
+ return { content: [{ type: "text", text: lines.join("\n") }], details: { path: file, opened, bytes } };
128
+ }
129
+
130
+ export default function piArtifactExtension(pi: ExtensionAPI, getConfig: () => SolyConfig) {
131
+ // A stale owner server may linger in the shared holder after /reload — stop
132
+ // it (and clear its registry) so its pinned port is freed; the next artifact
133
+ // (or /artifacts) re-resolves and re-binds it, restoring the per-project
134
+ // manifest. No-op when nothing is running (fresh process).
135
+ disposeArtifactServer();
136
+
137
+ // Usage guidance lives in the soly-framework skill + the main soly prompt
138
+ // pointer — not injected here.
139
+ pi.on("session_start", async (_event, ctx) => {
140
+ const cfg = getConfig().artifacts;
141
+ pruneOldSessions(resolveArtifactBase(cfg.dir, ctx.cwd), cfg.retentionDays);
142
+ });
143
+ pi.on("session_shutdown", async () => {
144
+ // Stop the server only if we own it; if we were a client reusing another
145
+ // window's server, leave it running.
146
+ disposeArtifactServer();
147
+ });
148
+
149
+ pi.registerTool({
150
+ name: "html_artifact",
151
+ label: "soly · html_artifact",
152
+ description:
153
+ "Render HTML to a self-contained file and serve it from a per-project gallery in the browser — soly's artifacts. `html` is a full document or a body fragment (wrapped in a styled light/dark skeleton; theme overridable via .soly/artifact-theme.css). Pass `id` to update an existing artifact in place (re-render). Pass `assets` to write sibling files (images/css/json) the HTML references via relative paths. Use when a visual rendered result beats terminal text. Self-contained otherwise — no external URLs. The gallery is shared across every pi window in this folder and survives restarts. Returns the localhost URL + gallery.",
154
+ parameters: Type.Object({
155
+ title: Type.String({ description: "Title (used for <title>, header, gallery, filename)." }),
156
+ html: Type.String({
157
+ description:
158
+ "Full document or body fragment. Code in <pre><code>…</code></pre>. May reference sibling `assets` by relative path; otherwise self-contained.",
159
+ }),
160
+ id: Type.Optional(
161
+ Type.String({ description: "Stable id — re-calling with the same id updates that artifact in place." }),
162
+ ),
163
+ assets: Type.Optional(
164
+ Type.Array(
165
+ Type.Object({
166
+ path: Type.String({ description: "Relative path within the artifact dir (e.g. 'data.json', 'img/logo.png')." }),
167
+ content: Type.String({ description: "File content." }),
168
+ encoding: Type.Optional(Type.String({ description: "'utf8' (default) or 'base64' for binary." })),
169
+ }),
170
+ { description: "Sibling files the HTML references by relative path." },
171
+ ),
172
+ ),
173
+ open: Type.Optional(
174
+ Type.Boolean({ description: "Open the gallery in the browser (default: artifacts.open)." }),
175
+ ),
176
+ }),
177
+ async execute(_id, params, _signal, _onUpdate, ctx): Promise<ToolText> {
178
+ const cfg = getConfig().artifacts;
179
+ const dir = artifactDir(cfg.dir, ctx.cwd);
180
+ try {
181
+ fs.mkdirSync(dir, { recursive: true });
182
+ } catch (err) {
183
+ return {
184
+ content: [{ type: "text", text: `html_artifact: could not create ${dir}: ${String(err)}` }],
185
+ details: { error: "mkdir_failed", dir },
186
+ };
187
+ }
188
+
189
+ if (params.assets?.length) writeAssets(dir, params.assets);
190
+
191
+ const css = loadCss(cfg.theme, ctx.cwd);
192
+ const name = params.id ? artifactFileNameForId(params.id) : artifactFileName(params.title, Date.now().toString(36));
193
+ const file = path.join(dir, name);
194
+ const html = buildArtifactHtml(params.title, params.html, css);
195
+ atomicWriteFileSync(file, html);
196
+ const bytes = Buffer.byteLength(html);
197
+ const shouldOpen = params.open ?? cfg.open;
198
+
199
+ if (!cfg.server) return fileMode(pi, file, bytes, shouldOpen);
200
+
201
+ let server: Awaited<ReturnType<typeof ensureArtifactServer>>;
202
+ try {
203
+ server = await ensureArtifactServer(dir, ctx.cwd);
204
+ } catch (err) {
205
+ return fileMode(pi, file, bytes, shouldOpen, `artifact server unavailable: ${String(err)}`);
206
+ }
207
+
208
+ // Register over the shared server. If the owning window died mid-call
209
+ // (client POST fails), re-resolve — probe finds nothing, so we start our
210
+ // own server on the same pinned port (same URL) — then retry once.
211
+ let url: string;
212
+ try {
213
+ url = await server.register(params.title, file, params.id);
214
+ } catch {
215
+ invalidateArtifactServer();
216
+ server = await ensureArtifactServer(dir, ctx.cwd);
217
+ url = await server.register(params.title, file, params.id);
218
+ }
219
+ const gallery = server.galleryUrl();
220
+ let opened = false;
221
+ let openError: string | undefined;
222
+ if (shouldOpen && server.consumeFirstOpen()) {
223
+ try {
224
+ await openInBrowser(pi, gallery);
225
+ opened = true;
226
+ } catch (err) {
227
+ openError = String(err);
228
+ }
229
+ }
230
+ // No mid-turn ctx.ui.notify here: firing one while the model is still
231
+ // streaming forces a repaint that can garble the TUI (the toast collided
232
+ // with the footer + duplicated streamed lines). The user still sees the
233
+ // artifact via auto-open, the `🖼 N` footer indicator, this tool result,
234
+ // and `/artifacts`.
235
+ const lines = [`Artifact: ${url}`, `Gallery (all project artifacts): ${gallery}`];
236
+ if (opened) lines.push("Opened the gallery in your browser.");
237
+ else if (openError) lines.push(`(could not auto-open: ${openError})`);
238
+ return { content: [{ type: "text", text: lines.join("\n") }], details: { url, gallery, path: file, bytes, opened } };
239
+ },
240
+ });
241
+ }
@@ -0,0 +1,239 @@
1
+ // =============================================================================
2
+ // render.ts — pure HTML artifact builder + gallery shell (no I/O)
3
+ // =============================================================================
4
+ //
5
+ // Turns LLM-supplied content into a nicely-styled HTML document and builds the
6
+ // session gallery shell — soly's local "artifacts". Pure: every function takes
7
+ // strings and returns strings; the stylesheet is passed in (loaded from a file
8
+ // or the built-in DEFAULT_CSS by the caller) so a project can restyle every
9
+ // artifact by dropping a `.soly/artifact-theme.css`.
10
+ //
11
+ // A body *fragment* is wrapped in the skeleton; a *full document* keeps its own
12
+ // markup but gets the theme injected as a base layer (its own styles win).
13
+ // =============================================================================
14
+
15
+ /** Escape text for safe interpolation into HTML (title, header). */
16
+ export function escapeHtml(s: string): string {
17
+ return s
18
+ .replace(/&/g, "&amp;")
19
+ .replace(/</g, "&lt;")
20
+ .replace(/>/g, "&gt;")
21
+ .replace(/"/g, "&quot;")
22
+ .replace(/'/g, "&#39;");
23
+ }
24
+
25
+ /** kebab-case slug for filenames/ids; falls back to "artifact" when empty. */
26
+ export function slugify(title: string): string {
27
+ const slug = title
28
+ .toLowerCase()
29
+ .replace(/[^a-z0-9]+/g, "-")
30
+ .replace(/^-+|-+$/g, "")
31
+ .slice(0, 48);
32
+ return slug || "artifact";
33
+ }
34
+
35
+ /** Deterministic artifact filename: `<slug>-<stamp>.html`. */
36
+ export function artifactFileName(title: string, stamp: string): string {
37
+ return `${slugify(title)}-${stamp}.html`;
38
+ }
39
+
40
+ /** Stable filename for an id-keyed artifact (update-in-place). */
41
+ export function artifactFileNameForId(id: string): string {
42
+ return `${slugify(id)}.html`;
43
+ }
44
+
45
+ /** True when content is already a complete HTML document. */
46
+ export function isFullDocument(content: string): boolean {
47
+ const head = content.trimStart().slice(0, 256).toLowerCase();
48
+ return head.startsWith("<!doctype") || head.startsWith("<html");
49
+ }
50
+
51
+ /** Inject soly's base stylesheet into a full document as a fallback layer
52
+ * (placed at the start of <head> so the document's own styles override it). */
53
+ export function injectStyle(doc: string, css: string): string {
54
+ const style = `<style data-soly>${css}</style>`;
55
+ if (/<head[^>]*>/i.test(doc)) return doc.replace(/<head[^>]*>/i, (m) => `${m}\n${style}`);
56
+ if (/<html[^>]*>/i.test(doc)) return doc.replace(/<html[^>]*>/i, (m) => `${m}\n<head>${style}</head>`);
57
+ return `${style}\n${doc}`;
58
+ }
59
+
60
+ /**
61
+ * Build the final HTML. Fragments are wrapped in a styled skeleton; full
62
+ * documents keep their markup but get the theme injected as a base layer.
63
+ */
64
+ export function buildArtifactHtml(title: string, content: string, css: string = DEFAULT_CSS): string {
65
+ if (isFullDocument(content)) return injectStyle(content, css);
66
+ const safeTitle = escapeHtml(title);
67
+ const header = title ? `<header><h1>${safeTitle}</h1></header>\n` : "";
68
+ return `<!doctype html>
69
+ <html lang="en">
70
+ <head>
71
+ <meta charset="utf-8">
72
+ <meta name="viewport" content="width=device-width, initial-scale=1">
73
+ <title>${safeTitle || "soly artifact"}</title>
74
+ <style>${css}</style>
75
+ </head>
76
+ <body>
77
+ <main>
78
+ ${header}${content}
79
+ </main>
80
+ </body>
81
+ </html>
82
+ `;
83
+ }
84
+
85
+ /** One artifact in the session gallery. */
86
+ export type GalleryEntry = { id: string; title: string; file: string; createdAt: number };
87
+
88
+ /**
89
+ * The session gallery SPA shell: a sidebar listing every artifact this session
90
+ * (fetched live from `/<token>/list`), a viewer iframe, filter box, light/dark
91
+ * toggle, and "open raw". An SSE stream re-fetches the list and refreshes the
92
+ * open artifact when something changes. Vanilla JS — no framework, no CDN.
93
+ */
94
+ export function buildGalleryShell(token: string): string {
95
+ const t = JSON.stringify(token);
96
+ return `<!doctype html>
97
+ <html lang="en">
98
+ <head>
99
+ <meta charset="utf-8">
100
+ <meta name="viewport" content="width=device-width, initial-scale=1">
101
+ <title>soly artifacts</title>
102
+ <style>${SHELL_CSS}</style>
103
+ </head>
104
+ <body>
105
+ <aside id="side">
106
+ <header><span>soly artifacts <span class="sub">this project</span></span><button id="theme" title="Toggle theme">◐</button></header>
107
+ <input id="q" placeholder="Filter…" autocomplete="off">
108
+ <ul id="list"></ul>
109
+ <p id="empty">No artifacts yet — they'll appear here as they're created.</p>
110
+ </aside>
111
+ <main>
112
+ <div id="bar"><span class="grow" id="cur">Select an artifact →</span><button id="raw">Open raw ↗</button></div>
113
+ <iframe id="view" title="artifact"></iframe>
114
+ </main>
115
+ <script>
116
+ (function(){
117
+ var T=${t};
118
+ var list=document.getElementById('list'),q=document.getElementById('q'),
119
+ view=document.getElementById('view'),cur=document.getElementById('cur'),
120
+ empty=document.getElementById('empty');
121
+ var entries=[],activeFile=null;
122
+ function fmt(ts){try{return new Date(ts).toLocaleString()}catch(e){return ''}}
123
+ function show(e){activeFile=e.file;view.src='/'+T+'/a/'+encodeURIComponent(e.file)+'?t='+Date.now();cur.textContent=e.title;render();}
124
+ function render(){
125
+ var f=(q.value||'').toLowerCase();
126
+ empty.style.display=entries.length?'none':'block';
127
+ list.innerHTML='';
128
+ entries.filter(function(e){return e.title.toLowerCase().indexOf(f)>=0}).forEach(function(e){
129
+ var li=document.createElement('li');
130
+ if(e.file===activeFile)li.className='active';
131
+ var t=document.createElement('span');t.className='t';t.textContent=e.title;
132
+ var ts=document.createElement('span');ts.className='ts';ts.textContent=fmt(e.createdAt);
133
+ li.appendChild(t);li.appendChild(ts);
134
+ li.onclick=function(){show(e)};
135
+ list.appendChild(li);
136
+ });
137
+ }
138
+ function load(cb){fetch('/'+T+'/list').then(function(r){return r.json()}).then(function(d){entries=d||[];render();if(cb)cb()}).catch(function(){});}
139
+ q.oninput=render;
140
+ document.getElementById('raw').onclick=function(){if(view.src)window.open(view.src,'_blank')};
141
+ var th=document.getElementById('theme');
142
+ function applyTheme(x){if(x)document.documentElement.setAttribute('data-theme',x)}
143
+ applyTheme(localStorage.getItem('soly-theme'));
144
+ th.onclick=function(){var c=document.documentElement.getAttribute('data-theme');var n=c==='dark'?'light':'dark';applyTheme(n);try{localStorage.setItem('soly-theme',n)}catch(e){}};
145
+ load(function(){if(entries.length)show(entries[0])});
146
+ try{var es=new EventSource('/'+T+'/events');es.onmessage=function(){load(function(){
147
+ if(activeFile&&entries.some(function(e){return e.file===activeFile}))show(entries.filter(function(e){return e.file===activeFile})[0]);
148
+ else if(entries.length)show(entries[0]);
149
+ })};}catch(e){}
150
+ })();
151
+ </script>
152
+ </body>
153
+ </html>
154
+ `;
155
+ }
156
+
157
+ // Self-contained artifact stylesheet — no external fonts, scripts, or CDN
158
+ // requests. Overridable per project via .soly/artifact-theme.css.
159
+ export const DEFAULT_CSS = `
160
+ :root{color-scheme:light dark}
161
+ *{box-sizing:border-box}
162
+ body{margin:0;font:16px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
163
+ color:#1c1c1e;background:#fbfbfd}
164
+ main{max-width:860px;margin:0 auto;padding:2.5rem 1.25rem 4rem}
165
+ header h1{margin:0 0 1.5rem;font-size:1.9rem;line-height:1.2;border-bottom:1px solid #e3e3e8;padding-bottom:.6rem}
166
+ h1,h2,h3{line-height:1.25;font-weight:650}
167
+ h2{margin-top:2rem;font-size:1.4rem}
168
+ h3{margin-top:1.5rem;font-size:1.15rem}
169
+ p{margin:.8rem 0}
170
+ a{color:#0a66c2;text-decoration:none}
171
+ a:hover{text-decoration:underline}
172
+ code{font-family:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace;font-size:.9em;
173
+ background:#f0f0f4;border-radius:4px;padding:.12em .35em}
174
+ pre{background:#f5f5f7;border:1px solid #e3e3e8;border-radius:10px;padding:1rem 1.1rem;overflow-x:auto;
175
+ line-height:1.5}
176
+ pre code{background:none;padding:0;font-size:.875rem}
177
+ table{border-collapse:collapse;width:100%;margin:1rem 0;font-size:.95rem}
178
+ th,td{border:1px solid #e3e3e8;padding:.5rem .7rem;text-align:left;vertical-align:top}
179
+ th{background:#f0f0f4;font-weight:650}
180
+ blockquote{margin:1rem 0;padding:.4rem 1rem;border-left:3px solid #c7c7cc;color:#48484a}
181
+ img{max-width:100%;height:auto}
182
+ hr{border:none;border-top:1px solid #e3e3e8;margin:2rem 0}
183
+ .cols{display:flex;gap:1rem;flex-wrap:wrap}
184
+ .cols>*{flex:1 1 280px;min-width:0}
185
+ @media (prefers-color-scheme:dark){
186
+ body{color:#e6e6eb;background:#161618}
187
+ header h1,h2,h3{border-color:#2c2c2e}
188
+ code{background:#2c2c2e}
189
+ pre{background:#1f1f22;border-color:#2c2c2e}
190
+ th{background:#222225}
191
+ th,td,hr{border-color:#2c2c2e}
192
+ a{color:#5aa9ff}
193
+ blockquote{border-color:#48484a;color:#aeaeb2}
194
+ }
195
+ `;
196
+
197
+ // Gallery app-shell chrome (distinct from artifact content styling above).
198
+ const SHELL_CSS = `
199
+ :root{color-scheme:light dark}
200
+ *{box-sizing:border-box}
201
+ body{margin:0;height:100vh;display:flex;font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;color:#1c1c1e;background:#fbfbfd}
202
+ #side{width:280px;flex:0 0 280px;display:flex;flex-direction:column;height:100vh;background:#f5f5f7;border-right:1px solid #e3e3e8}
203
+ #side header{padding:1rem 1rem .6rem;font-weight:650;display:flex;align-items:center;justify-content:space-between;gap:.5rem}
204
+ #side header .sub{font-size:.7rem;font-weight:400;color:#8a8a8e}
205
+ #theme{font:inherit;border:1px solid #d0d0d5;background:#fff;border-radius:7px;cursor:pointer;padding:.1rem .45rem}
206
+ #q{margin:.2rem 1rem .6rem;padding:.4rem .6rem;border:1px solid #d0d0d5;border-radius:8px;font:inherit}
207
+ #list{list-style:none;margin:0;padding:0;overflow-y:auto;flex:1}
208
+ #list li{padding:.55rem 1rem;border-bottom:1px solid #ececf0;cursor:pointer}
209
+ #list li:hover{background:#ececf0}
210
+ #list li.active{background:#e1e8ff}
211
+ #list .t{font-weight:600;font-size:.92rem;display:block}
212
+ #list .ts{color:#8a8a8e;font-size:.75rem}
213
+ #empty{padding:1rem;color:#8a8a8e}
214
+ main{flex:1;height:100vh;display:flex;flex-direction:column;min-width:0}
215
+ #bar{display:flex;gap:.5rem;align-items:center;padding:.4rem .8rem;border-bottom:1px solid #e3e3e8;font-size:.85rem}
216
+ #bar .grow{flex:1;color:#48484a;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
217
+ #bar button{font:inherit;padding:.25rem .6rem;border:1px solid #d0d0d5;border-radius:7px;background:#fff;cursor:pointer}
218
+ iframe{flex:1;border:0;width:100%;background:#fff}
219
+ html[data-theme=dark]{color-scheme:dark}
220
+ html[data-theme=light]{color-scheme:light}
221
+ html[data-theme=dark] body{color:#e6e6eb;background:#161618}
222
+ html[data-theme=dark] #side{background:#1c1c1e;border-color:#2c2c2e}
223
+ html[data-theme=dark] #q,html[data-theme=dark] #theme,html[data-theme=dark] #bar button{background:#222;color:#e6e6eb;border-color:#3a3a3c}
224
+ html[data-theme=dark] #list li{border-color:#242426}
225
+ html[data-theme=dark] #list li:hover{background:#242426}
226
+ html[data-theme=dark] #list li.active{background:#2a3358}
227
+ html[data-theme=dark] #bar{border-color:#2c2c2e}
228
+ html[data-theme=dark] iframe{background:#161618}
229
+ @media (prefers-color-scheme:dark){
230
+ body{color:#e6e6eb;background:#161618}
231
+ #side{background:#1c1c1e;border-color:#2c2c2e}
232
+ #q,#theme,#bar button{background:#222;color:#e6e6eb;border-color:#3a3a3c}
233
+ #list li{border-color:#242426}
234
+ #list li:hover{background:#242426}
235
+ #list li.active{background:#2a3358}
236
+ #bar{border-color:#2c2c2e}
237
+ iframe{background:#161618}
238
+ }
239
+ `;