diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md
index 536e7fe87..d19ffadf0 100644
--- a/packages/coding-agent/CHANGELOG.md
+++ b/packages/coding-agent/CHANGELOG.md
@@ -572,6 +572,9 @@
- Fixed `--reasoning-slide-plan` silently ending the run with no code written when the model answered with a text-only reply.
- Fixed launch tool rendering issues, including stacked pending headers and confusing start/wait results when readiness timed out.
- Fixed the in-process `stat` and other GNU-flavored shell builtins (such as `date`, `sed`, `mktemp`, `tail`, `find`, `base64`, and `ln`) mangling or failing on macOS/BSD-style invocations.
+### Added
+
+- Added system-aware light and dark themes to HTML session exports, with a `/export --themes` option to bundle the user's selected TUI themes.
## [16.5.1] - 2026-07-14
diff --git a/packages/coding-agent/scripts/generate-share-viewer.ts b/packages/coding-agent/scripts/generate-share-viewer.ts
index e941c7f49..185223df3 100755
--- a/packages/coding-agent/scripts/generate-share-viewer.ts
+++ b/packages/coding-agent/scripts/generate-share-viewer.ts
@@ -10,7 +10,7 @@
* The relay repo's build script runs this and embeds the output via go:embed.
*/
import * as path from "node:path";
-import { generateThemeVars, getTemplate } from "../src/export/html";
+import { generateThemeStyles, getTemplate } from "../src/export/html";
const outPath = process.argv[2];
if (!outPath) {
@@ -19,13 +19,11 @@ if (!outPath) {
}
const loaderJs = await Bun.file(new URL("../src/export/html/share-loader.js", import.meta.url).pathname).text();
-// Pin the omp brand palette (collab-web pink/purple identity) — the viewer is
-// a public artifact matching the live my.omp.sh client, not a per-user export
-// that should mirror the host's terminal theme.
-const themeVars = await generateThemeVars("web");
+// Public artifacts use the bundled omp web themes rather than TUI themes.
+const themeStyles = await generateThemeStyles("web");
const html = getTemplate()
- .replace("", () => ``)
+ .replace("", () => ``)
.replace("
Session Export", () => "omp session")
.replace("{{SESSION_DATA}}", () => `\n `);
diff --git a/packages/coding-agent/src/export/html/index.ts b/packages/coding-agent/src/export/html/index.ts
index 5b7b06e03..b23fad364 100644
--- a/packages/coding-agent/src/export/html/index.ts
+++ b/packages/coding-agent/src/export/html/index.ts
@@ -35,22 +35,28 @@ export function getTemplate(): string {
return cachedTemplate;
}
+export interface ExportThemeNames {
+ dark: string;
+ light: string;
+}
+
+/** Parse `/export [--themes] [path]`; paths containing spaces were never supported. */
+export function parseExportArgs(args: string): { outputPath?: string; useUserThemes: boolean } {
+ const parts = args.trim().split(/\s+/).filter(Boolean);
+ const useUserThemes = parts.includes("--themes");
+ const paths = parts.filter(part => part !== "--themes");
+ if (paths.length > 1) throw new Error("Usage: /export [--themes] [path]");
+ return { outputPath: paths[0], useUserThemes };
+}
+
export interface ExportOptions {
outputPath?: string;
- /**
- * Which color palette the export ships with.
- * - `"web"` (default) — the omp brand identity (collab-web pink/purple),
- * so public HTML exports and the `/s/` share viewer match the live
- * `my.omp.sh` client. See `web-palette.ts`.
- * - `"theme"` — derive from `themeName` (or the active TUI theme), preserving
- * the pre-15.12 behavior where an export mirrored the user's terminal.
- */
+ /** `"web"` bundles the omp web themes; `"theme"` bundles TUI themes. */
palette?: "web" | "theme";
- /**
- * TUI theme to derive colors from when `palette: "theme"`. Ignored for the
- * default `"web"` palette. Resolves to the active TUI theme when omitted.
- */
+ /** Legacy single TUI theme name. Prefer `themeNames` for dual-theme exports. */
themeName?: string;
+ /** Dark and light TUI themes to bundle when `palette` is `"theme"`. */
+ themeNames?: ExportThemeNames;
/** Embed subagent session transcripts found next to the session file (default true). */
includeSubSessions?: boolean;
}
@@ -116,50 +122,49 @@ function deriveExportColors(baseColor: string): { pageBg: string; cardBg: string
}
/**
- * Generate CSS custom properties for the export `:root`.
+ * Generate CSS custom properties for one export theme.
*
- * Two call shapes:
- * • `generateThemeVars("web" | "theme", themeName?)` — explicit palette.
- * `"web"` (the default for public artifacts) returns the fixed omp brand
- * palette from `web-palette.ts` — collab-web pink/purple identity, shared
- * with the live `my.omp.sh` client, so exports and the share viewer render
- * identically to it. `"theme"` derives from the TUI theme via
- * `getResolvedThemeColors(themeName)` plus the three
- * `export.{pageBg,cardBg,infoBg}` surface overrides.
- * • `generateThemeVars(themeName)` — legacy single-arg form: derive from the
- * named TUI theme. Kept so existing callers (and the theme-islight test)
- * keep working; equivalent to `generateThemeVars("theme", themeName)`.
- *
- * Exported for the share-viewer build script.
+ * The single-argument theme-name form remains available to callers that need
+ * one TUI palette. Standalone HTML uses `generateThemeStyles()` below.
*/
export async function generateThemeVars(
palette: "web" | "theme" | (string & {}) = "web",
themeName?: string,
): Promise {
- // Legacy single-arg form: `generateThemeVars("my-theme")` — the first arg
- // is a theme name, not a palette. Route it to the themed path.
- if (palette !== "web" && palette !== "theme") {
- return generateThemeVars("theme", palette);
- }
- if (palette === "web") return webExportThemeVars();
+ if (palette !== "web" && palette !== "theme") return generateThemeVars("theme", palette);
+ if (palette === "web") return webExportThemeVars("dark");
const colors = await getResolvedThemeColors(themeName);
- const lines: string[] = [];
- for (const key in colors) {
- lines.push(`--${key}: ${colors[key]};`);
- }
-
+ const lines = Object.entries(colors).map(([key, value]) => `--${key}: ${value};`);
const themeExport = await getThemeExportColors(themeName);
- const userMessageBg = colors.userMessageBg || "#343541";
- const derived = deriveExportColors(userMessageBg);
+ const derived = deriveExportColors(colors.userMessageBg || "#343541");
lines.push(`--body-bg: ${themeExport.pageBg ?? derived.pageBg};`);
lines.push(`--container-bg: ${themeExport.cardBg ?? derived.cardBg};`);
lines.push(`--info-bg: ${themeExport.infoBg ?? derived.infoBg};`);
-
return lines.join(" ");
}
+/** Generate dark, light, and system-following CSS rules for a standalone viewer. */
+export async function generateThemeStyles(
+ palette: "web" | "theme",
+ themeNames?: ExportThemeNames,
+ legacyThemeName?: string,
+): Promise {
+ const [dark, light] =
+ palette === "web"
+ ? [webExportThemeVars("dark"), webExportThemeVars("light")]
+ : await Promise.all([
+ generateThemeVars("theme", themeNames?.dark ?? legacyThemeName ?? "dark"),
+ generateThemeVars("theme", themeNames?.light ?? legacyThemeName ?? "light"),
+ ]);
+ return [
+ `:root, :root[data-theme="dark"] { color-scheme: dark; ${dark} }`,
+ `:root[data-theme="light"] { color-scheme: light; ${light} }`,
+ `@media (prefers-color-scheme: light) { :root:not([data-theme]) { color-scheme: light; ${light} } }`,
+ ].join("\n");
+}
+
/** Embedded subagent session transcript, keyed by slash-joined agent path in `SessionData.subSessions`. */
export interface SubSession {
/** Bare agent id (session file stem), e.g. "ToolAsk". */
@@ -240,15 +245,19 @@ async function collectSubSessionsFromDir(
}
/** Generate HTML from bundled template with runtime substitutions. */
-async function generateHtml(sessionData: SessionData, palette: "web" | "theme", themeName?: string): Promise {
- const themeVars = await generateThemeVars(palette, themeName);
+async function generateHtml(
+ sessionData: SessionData,
+ palette: "web" | "theme",
+ themeNames?: ExportThemeNames,
+ themeName?: string,
+): Promise {
+ const themeStyles = await generateThemeStyles(palette, themeNames, themeName);
const sessionDataBase64 = Buffer.from(JSON.stringify(sessionData)).toBase64();
// Use function replacements so `$'`, `$&`, `$$`, `$n`, etc. in the
- // substituted CSS/base64 are not interpreted as substitution patterns
- // (see https://mdn.io/String.replace).
+ // substituted CSS/base64 are not interpreted as substitution patterns.
return getTemplate()
- .replace("", () => ``)
+ .replace("", () => ``)
.replace("{{SESSION_DATA}}", () => sessionDataBase64);
}
@@ -270,7 +279,7 @@ export async function exportSessionToHtml(
}
const palette = opts.palette ?? (opts.themeName ? "theme" : "web");
- const html = await generateHtml(sessionData, palette, opts.themeName);
+ const html = await generateHtml(sessionData, palette, opts.themeNames, opts.themeName);
const outputPath = opts.outputPath || `${APP_NAME}-session-${path.basename(sessionFile, ".jsonl")}.html`;
await Bun.write(outputPath, html);
@@ -300,7 +309,7 @@ export async function exportFromFile(inputPath: string, options?: ExportOptions
}
const palette = opts.palette ?? (opts.themeName ? "theme" : "web");
- const html = await generateHtml(sessionData, palette, opts.themeName);
+ const html = await generateHtml(sessionData, palette, opts.themeNames, opts.themeName);
const outputPath = opts.outputPath || `${APP_NAME}-session-${path.basename(inputPath, ".jsonl")}.html`;
await Bun.write(outputPath, html);
diff --git a/packages/coding-agent/src/export/html/template.css b/packages/coding-agent/src/export/html/template.css
index 4f491b222..2e1fefb83 100644
--- a/packages/coding-agent/src/export/html/template.css
+++ b/packages/coding-agent/src/export/html/template.css
@@ -104,11 +104,14 @@
}
.sidebar-controls {
+ display: flex;
+ gap: 6px;
padding: 0 0 6px;
}
.sidebar-search {
- width: 100%;
+ flex: 1;
+ min-width: 0;
box-sizing: border-box;
padding: 5px 9px;
font-size: 11px;
@@ -138,6 +141,21 @@
color: var(--muted);
}
+ .theme-select {
+ padding: 5px 8px;
+ font: inherit;
+ font-size: 10px;
+ background: var(--body-bg);
+ color: var(--text);
+ border: 1px solid var(--hairline);
+ border-radius: var(--radius-ctl);
+ cursor: pointer;
+ }
+
+ .theme-select:hover {
+ border-color: var(--hairline-strong);
+ }
+
.sidebar-filters {
display: flex;
gap: 4px;
diff --git a/packages/coding-agent/src/export/html/template.html b/packages/coding-agent/src/export/html/template.html
index a07ae1be8..adf752f65 100644
--- a/packages/coding-agent/src/export/html/template.html
+++ b/packages/coding-agent/src/export/html/template.html
@@ -8,6 +8,7 @@
Session Export
+
@@ -17,6 +18,11 @@
+
diff --git a/packages/coding-agent/src/export/html/template.js b/packages/coding-agent/src/export/html/template.js
index 6c2998f86..1cd1f1073 100644
--- a/packages/coding-agent/src/export/html/template.js
+++ b/packages/coding-agent/src/export/html/template.js
@@ -1,6 +1,27 @@
(function() {
'use strict';
+ const THEME_STORAGE_KEY = 'omp-export-theme';
+ const themeSelect = document.getElementById('theme-select');
+ let themePreference = 'system';
+ try {
+ const stored = localStorage.getItem(THEME_STORAGE_KEY);
+ if (stored === 'light' || stored === 'dark' || stored === 'system') themePreference = stored;
+ } catch {}
+
+ function applyThemePreference(next) {
+ themePreference = next;
+ if (next === 'light' || next === 'dark') document.documentElement.dataset.theme = next;
+ else delete document.documentElement.dataset.theme;
+ if (themeSelect) themeSelect.value = next;
+ try {
+ localStorage.setItem(THEME_STORAGE_KEY, next);
+ } catch {}
+ }
+
+ applyThemePreference(themePreference);
+ if (themeSelect) themeSelect.addEventListener('change', () => applyThemePreference(themeSelect.value));
+
// ============================================================
// BOOT
// ============================================================
diff --git a/packages/coding-agent/src/export/html/web-palette.ts b/packages/coding-agent/src/export/html/web-palette.ts
index ce79e994b..8ead1d814 100644
--- a/packages/coding-agent/src/export/html/web-palette.ts
+++ b/packages/coding-agent/src/export/html/web-palette.ts
@@ -1,142 +1,124 @@
-/**
- * Web/export palette — the omp brand identity shared by the collab-web live
- * client (`my.omp.sh/`) and every public HTML export / share viewer (`/s/`).
- *
- * Why this exists separately from `modes/theme/dark.json`: the `dark` theme is
- * the **default TUI theme** — its amber accent (`#febc38`) drives the terminal
- * status line, syntax highlighting, thinking levels, and bash/python mode
- * colors for every omp user. The public web artifacts want the collab-web
- * pink/purple identity instead, so they pin this palette rather than inheriting
- * the TUI's. Editing `dark.json` to repurpose it for the web would repaint
- * every terminal; this file keeps the two surfaces decoupled.
- *
- * Token layout — emitted as CSS custom properties on `:root`:
- * • Legacy export names consumed by `template.css` / `template.js`
- * (`--text`, `--body-bg`, `--container-bg`, `--info-bg`, `--accent`,
- * `--border`, `--success`, `--error`, `--warning`, `--muted`, `--dim`,
- * `--borderAccent`, `--selectedBg`, `--userMessageBg`, `--customMessageBg`,
- * `--customMessageLabel`, `--mdHeading`, `--mdLink`, `--mdCode`,
- * `--mdListBullet`, `--toolOutput`, `--thinkingText`, syntax*, …).
- * • collab-web-native aliases consumed by the `tv-` tool-render bridge
- * (`tool-render.css`: `var(--bg-inset, …)`, `var(--fg, …)`, …) so embedded
- * tool cards resolve to the *real* collab-web tokens and render
- * pixel-identical to the live client.
- *
- * Alpha-bearing tokens (`--border`, `--ring`, `--accent-muted`, …) keep their
- * `oklch(… / N%)` form — flattening them to opaque hex would produce harsh
- * white borders and non-matching translucent focus rings. Opaque surfaces are
- * sRGB hex (the collab-web `tokens.css` OKLCH dark-theme tokens converted via
- * the standard OKLab→linear-sRGB→gamma path); if the live client palette
- * changes, regenerate those from there.
- */
-export const WEB_EXPORT_PALETTE = {
- // --- collab-web-native aliases (tv- bridge) ---
- "--bg": "#0f0b14",
- "--bg-raised": "#16111c",
- "--bg-inset": "#09060c",
- "--bg-overlay": "#211b28",
- "--fg": "#e6e3ea",
- "--fg-muted": "#a49faa",
- "--fg-faint": "#6e6974",
- "--accent": "#ed4abf",
- "--accent-muted": "oklch(0.674 0.23 341 / 18%)",
- "--ok": "#68ca80",
- "--err": "#f05653",
- "--warn": "#e4b33f",
- "--ring": "oklch(0.817 0.112 205 / 70%)",
- "--font-mono": 'ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Mono", Menlo, Consolas, monospace',
+/** omp web palette bundled into standalone exports and the share viewer. */
+export type WebExportScheme = "dark" | "light";
- // --- legacy export names (template.css / template.js) ---
- // surfaces — map onto the collab-web purple ramp
- "--body-bg": "#0f0b14", // = --bg
- "--container-bg": "#16111c", // = --bg-raised
- "--info-bg": "#09060c", // = --bg-inset (recessed wells: code blocks, tool output)
- // text
- "--text": "#e6e3ea", // = --fg
- "--muted": "#a49faa", // = --fg-muted
- "--dim": "#6e6974", // = --fg-faint
- "--thinkingText": "#a49faa",
- // hairlines — white-alpha, matching collab-web's --border/--border-strong
- "--border": "oklch(1 0 0 / 9%)",
- "--borderMuted": "oklch(1 0 0 / 6%)",
- // accent + brand purple (the gradient's mid stop) for secondary highlights
+const COMMON_EXPORT_PALETTE = {
+ "--font-mono": 'ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Mono", Menlo, Consolas, monospace',
+ "--body-bg": "var(--bg)",
+ "--container-bg": "var(--bg-raised)",
+ "--info-bg": "var(--bg-inset)",
+ "--text": "var(--fg)",
+ "--muted": "var(--fg-muted)",
+ "--dim": "var(--fg-faint)",
+ "--thinkingText": "var(--fg-muted)",
+ "--borderMuted": "color-mix(in srgb, var(--border) 67%, transparent)",
"--borderAccent": "#945ff9",
- "--selectedBg": "#2d2535",
- // status — semantic, used sparingly (cancelled / exit-code / success dots)
- "--success": "#68ca80", // = --ok
- "--error": "#f05653", // = --err
- "--warning": "#e4b33f", // = --warn
- // message bubbles
- "--userMessageBg": "oklch(0.674 0.23 341 / 6%)", // accent-tinted, like template.css user-message
- "--userMessageText": "#e6e3ea",
- "--customMessageBg": "#211b28", // = --bg-overlay
- "--customMessageText": "#a49faa", // = --fg-muted
- "--customMessageLabel": "#b281d6", // lilac, matching dark.json's label hue
- // tool surfaces
- "--toolPendingBg": "#16111c",
- "--toolSuccessBg": "#09060c",
- "--toolErrorBg": "oklch(0.66 0.19 25 / 14%)",
- "--toolTitle": "#e6e3ea",
- "--toolOutput": "#a49faa", // = --fg-muted
- // markdown
- "--mdHeading": "#ed4abf", // accent — headings carry the brand
- "--mdLink": "#5ad8e5", // ring cyan — links distinct from the pink accent
- "--mdLinkUrl": "#6e6974", // = --fg-faint
- "--mdCode": "#e6e3ea",
- "--mdCodeBlock": "#e6e3ea",
- "--mdCodeBlockBorder": "oklch(1 0 0 / 9%)",
- "--mdQuote": "#a49faa",
- "--mdQuoteBorder": "oklch(1 0 0 / 13%)",
- "--mdHr": "oklch(1 0 0 / 9%)",
- "--mdListBullet": "#ed4abf", // accent — bullets carry the brand
- // diff
- "--toolDiffAdded": "#68ca80",
- "--toolDiffRemoved": "#f05653",
- "--toolDiffContext": "#6e6974",
- // syntax — cool-neutral with pink/purple accents for keywords/types
- "--syntaxComment": "#6e6974", // = --fg-faint
- "--syntaxKeyword": "#945ff9", // brand purple
- "--syntaxFunction": "#e4b33f", // warn amber (analog of dark.json's DCDCAA)
- "--syntaxVariable": "#5ad8e5", // ring cyan
- "--syntaxString": "#68ca80", // ok green
- "--syntaxNumber": "#ed4abf", // accent
- "--syntaxType": "#b281d6", // lilac
- "--syntaxOperator": "#e6e3ea",
- "--syntaxPunctuation": "#a49faa",
- // thinking-level ramp — purple → lilac, matching the brand gradient
- "--thinkingOff": "#6e6974",
- "--thinkingMinimal": "#6e6974",
+ "--selectedBg": "var(--accent-muted)",
+ "--success": "var(--ok)",
+ "--error": "var(--err)",
+ "--warning": "var(--warn)",
+ "--userMessageBg": "var(--accent-muted)",
+ "--userMessageText": "var(--fg)",
+ "--customMessageBg": "var(--bg-overlay)",
+ "--customMessageText": "var(--fg-muted)",
+ "--customMessageLabel": "#945ff9",
+ "--toolPendingBg": "var(--bg-raised)",
+ "--toolSuccessBg": "var(--bg-inset)",
+ "--toolErrorBg": "color-mix(in srgb, var(--err) 14%, transparent)",
+ "--toolTitle": "var(--fg)",
+ "--toolOutput": "var(--fg-muted)",
+ "--mdHeading": "var(--accent)",
+ "--mdLink": "#168b9a",
+ "--mdLinkUrl": "var(--fg-faint)",
+ "--mdCode": "var(--fg)",
+ "--mdCodeBlock": "var(--fg)",
+ "--mdCodeBlockBorder": "var(--border)",
+ "--mdQuote": "var(--fg-muted)",
+ "--mdQuoteBorder": "var(--border-strong)",
+ "--mdHr": "var(--border)",
+ "--mdListBullet": "var(--accent)",
+ "--toolDiffAdded": "var(--ok)",
+ "--toolDiffRemoved": "var(--err)",
+ "--toolDiffContext": "var(--fg-faint)",
+ "--syntaxOperator": "var(--fg)",
+ "--syntaxPunctuation": "var(--fg-muted)",
+ "--thinkingOff": "var(--fg-faint)",
+ "--thinkingMinimal": "var(--fg-faint)",
"--thinkingLow": "#945ff9",
"--thinkingMedium": "#b281d6",
- "--thinkingHigh": "#ed4abf",
- "--thinkingXhigh": "#e4b33f",
- // mode tints (sidebar/role tags) — not surfaced in the export tree but
- // emitted for completeness so template.js role classes resolve cleanly
- "--bashMode": "#5ad8e5",
- "--pythonMode": "#e4b33f",
- // status-line tokens are TUI-only; not consumed by the export template, but
- // emitted so any future surface that reads them inherits the brand.
- "--statusLineBg": "#0f0b14",
- "--statusLineSep": "#6e6974",
- "--statusLineModel": "#ed4abf",
- "--statusLinePath": "#5ad8e5",
- "--statusLineGitClean": "#68ca80",
- "--statusLineGitDirty": "#e4b33f",
- "--statusLineContext": "#a49faa",
- "--statusLineSpend": "#5ad8e5",
- "--statusLineStaged": "#68ca80",
- "--statusLineDirty": "#e4b33f",
+ "--thinkingHigh": "var(--accent)",
+ "--thinkingXhigh": "var(--warn)",
+ "--bashMode": "#168b9a",
+ "--pythonMode": "var(--warn)",
+ "--statusLineBg": "var(--bg)",
+ "--statusLineSep": "var(--fg-faint)",
+ "--statusLineModel": "var(--accent)",
+ "--statusLinePath": "#168b9a",
+ "--statusLineGitClean": "var(--ok)",
+ "--statusLineGitDirty": "var(--warn)",
+ "--statusLineContext": "var(--fg-muted)",
+ "--statusLineSpend": "#168b9a",
+ "--statusLineStaged": "var(--ok)",
+ "--statusLineDirty": "var(--warn)",
"--statusLineUntracked": "#945ff9",
"--statusLineOutput": "#b281d6",
"--statusLineCost": "#b281d6",
- "--statusLineSubagents": "#ed4abf",
+ "--statusLineSubagents": "var(--accent)",
} as const satisfies Record;
-/** Serialize the palette as `--key: value;` declarations for `:root { … }`. */
-export function webExportThemeVars(): string {
- let out = "";
- for (const k in WEB_EXPORT_PALETTE) {
- out += `${k}: ${WEB_EXPORT_PALETTE[k as keyof typeof WEB_EXPORT_PALETTE]}; `;
- }
- return out.trimEnd();
+export const WEB_EXPORT_PALETTES = {
+ dark: {
+ "--bg": "#0f0b14",
+ "--bg-raised": "#16111c",
+ "--bg-inset": "#09060c",
+ "--bg-overlay": "#211b28",
+ "--fg": "#e6e3ea",
+ "--fg-muted": "#a49faa",
+ "--fg-faint": "#6e6974",
+ "--accent": "#ed4abf",
+ "--accent-muted": "oklch(0.674 0.23 341 / 18%)",
+ "--ok": "#68ca80",
+ "--err": "#f05653",
+ "--warn": "#e4b33f",
+ "--border": "oklch(1 0 0 / 9%)",
+ "--border-strong": "oklch(1 0 0 / 13%)",
+ "--ring": "oklch(0.817 0.112 205 / 70%)",
+ "--syntaxComment": "#6e6974",
+ "--syntaxKeyword": "#945ff9",
+ "--syntaxFunction": "#e4b33f",
+ "--syntaxVariable": "#5ad8e5",
+ "--syntaxString": "#68ca80",
+ "--syntaxNumber": "#ed4abf",
+ "--syntaxType": "#b281d6",
+ },
+ light: {
+ "--bg": "oklch(0.985 0.004 307)",
+ "--bg-raised": "oklch(1 0 0)",
+ "--bg-inset": "oklch(0.95 0.006 307)",
+ "--bg-overlay": "oklch(0.965 0.006 307)",
+ "--fg": "oklch(0.26 0.03 307)",
+ "--fg-muted": "oklch(0.46 0.03 307)",
+ "--fg-faint": "oklch(0.58 0.025 307)",
+ "--accent": "oklch(0.62 0.23 341)",
+ "--accent-muted": "oklch(0.62 0.23 341 / 14%)",
+ "--ok": "oklch(0.55 0.13 150)",
+ "--err": "oklch(0.55 0.19 25)",
+ "--warn": "oklch(0.6 0.13 85)",
+ "--border": "oklch(0 0 0 / 10%)",
+ "--border-strong": "oklch(0 0 0 / 15%)",
+ "--ring": "oklch(0.58 0.13 230 / 70%)",
+ "--syntaxComment": "#77717c",
+ "--syntaxKeyword": "#6d35c7",
+ "--syntaxFunction": "#8a6000",
+ "--syntaxVariable": "#087f8c",
+ "--syntaxString": "#247a3d",
+ "--syntaxNumber": "#b51f88",
+ "--syntaxType": "#7b3fa2",
+ },
+} as const satisfies Record>;
+
+/** Serialize one web color scheme as CSS custom-property declarations. */
+export function webExportThemeVars(scheme: WebExportScheme): string {
+ return Object.entries({ ...COMMON_EXPORT_PALETTE, ...WEB_EXPORT_PALETTES[scheme] })
+ .map(([key, value]) => `${key}: ${value};`)
+ .join(" ");
}
diff --git a/packages/coding-agent/src/modes/controllers/command-controller.ts b/packages/coding-agent/src/modes/controllers/command-controller.ts
index b9a98fba9..d74fc32f8 100644
--- a/packages/coding-agent/src/modes/controllers/command-controller.ts
+++ b/packages/coding-agent/src/modes/controllers/command-controller.ts
@@ -15,6 +15,7 @@ import { formatDuration, Snowflake, sanitizeText } from "@oh-my-pi/pi-utils";
import { shouldEnableAppendOnlyContext } from "../../config/append-only-context-mode";
import { type BashResult, isPersistentShellCdCommand } from "../../exec/bash-executor";
import { type LoadedCustomShare, loadCustomShare } from "../../export/custom-share";
+import { parseExportArgs } from "../../export/html";
import { shareSession } from "../../export/share";
import type { CompactOptions } from "../../extensibility/extensions/types";
import {
@@ -76,16 +77,14 @@ export class CommandController {
}
async handleExportCommand(text: string): Promise {
- const parts = text.split(/\s+/);
- const arg = parts.length > 1 ? parts[1] : undefined;
-
- if (arg === "--copy" || arg === "clipboard" || arg === "copy") {
- this.ctx.showWarning("Use /dump to copy the session to clipboard.");
- return;
- }
-
try {
- const filePath = await this.ctx.session.exportToHtml(arg);
+ const { outputPath, useUserThemes } = parseExportArgs(text.slice("/export".length));
+ if (outputPath === "--copy" || outputPath === "clipboard" || outputPath === "copy") {
+ this.ctx.showWarning("Use /dump to copy the session to clipboard.");
+ return;
+ }
+
+ const filePath = await this.ctx.session.exportToHtml(outputPath, useUserThemes);
this.ctx.showStatus(`Session exported to: ${filePath}`);
this.openInBrowser(filePath);
} catch (error: unknown) {
diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts
index 4ccc36c67..bf66e8cc0 100644
--- a/packages/coding-agent/src/session/agent-session.ts
+++ b/packages/coding-agent/src/session/agent-session.ts
@@ -219,6 +219,7 @@ import {
import { disposeRubyKernelSessionsByOwner } from "../eval/rb/executor";
import { defaultEvalSessionId } from "../eval/session-id";
import { type BashResult, executeBash as executeBashCommand } from "../exec/bash-executor";
+import { exportSessionToHtml } from "../export/html";
import type { TtsrManager, TtsrMatchContext } from "../export/ttsr";
import type { LoadedCustomCommand } from "../extensibility/custom-commands";
import type { CustomTool, CustomToolContext } from "../extensibility/custom-tools/types";
@@ -17983,16 +17984,20 @@ export class AgentSession {
/**
* Export session to HTML.
- * @param outputPath Optional output path (defaults to session directory)
- * @returns Path to exported file
+ * @param outputPath Optional output path
+ * @param useUserThemes Bundle the dark and light TUI themes selected in settings
*/
- async exportToHtml(outputPath?: string): Promise {
- // Public HTML export ships in the omp brand palette (collab-web
- // pink/purple), matching my.omp.sh — not the host's terminal theme.
- // Callers who want a themed export can pass `palette: "theme"` with
- // `themeName` directly to `exportSessionToHtml`.
- const { exportSessionToHtml } = await import("../export/html");
- return exportSessionToHtml(this.sessionManager, this.state, { outputPath, palette: "web" });
+ async exportToHtml(outputPath?: string, useUserThemes = false): Promise {
+ return exportSessionToHtml(this.sessionManager, this.state, {
+ outputPath,
+ palette: useUserThemes ? "theme" : "web",
+ themeNames: useUserThemes
+ ? {
+ dark: this.settings.get("theme.dark") ?? "titanium",
+ light: this.settings.get("theme.light") ?? "light",
+ }
+ : undefined,
+ });
}
// =========================================================================
diff --git a/packages/coding-agent/src/slash-commands/builtin-registry.ts b/packages/coding-agent/src/slash-commands/builtin-registry.ts
index b564b5471..0e686607a 100644
--- a/packages/coding-agent/src/slash-commands/builtin-registry.ts
+++ b/packages/coding-agent/src/slash-commands/builtin-registry.ts
@@ -16,6 +16,7 @@ import {
resolveActiveProjectRegistryPath,
resolveOrDefaultProjectRegistryPath,
} from "../discovery/helpers.js";
+import { parseExportArgs } from "../export/html";
import { shareSession } from "../export/share";
import { PluginManager } from "../extensibility/plugins";
import {
@@ -606,19 +607,15 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [
{
name: "export",
description: "Export session to HTML file",
- inlineHint: "[path]",
+ inlineHint: "[--themes] [path]",
allowArgs: true,
handle: async (command, runtime) => {
- const arg = command.args.trim();
- // Match the interactive `/export` behavior: clipboard aliases are not a
- // valid export target. Without this, the literal value (`copy`,
- // `--copy`, `clipboard`) is passed to `exportToHtml` and becomes the
- // output filename.
- if (arg === "--copy" || arg === "clipboard" || arg === "copy") {
- return usage("Use /dump to copy the session to clipboard.", runtime);
- }
try {
- const filePath = await runtime.session.exportToHtml(arg || undefined);
+ const { outputPath, useUserThemes } = parseExportArgs(command.args);
+ if (outputPath === "--copy" || outputPath === "clipboard" || outputPath === "copy") {
+ return usage("Use /dump to copy the session to clipboard.", runtime);
+ }
+ const filePath = await runtime.session.exportToHtml(outputPath, useUserThemes);
await runtime.output(`Session exported to: ${filePath}`);
return commandConsumed();
} catch (err) {
diff --git a/packages/coding-agent/test/export-html-themes.test.ts b/packages/coding-agent/test/export-html-themes.test.ts
new file mode 100644
index 000000000..c07117a34
--- /dev/null
+++ b/packages/coding-agent/test/export-html-themes.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, it } from "bun:test";
+import {
+ generateThemeStyles,
+ generateThemeVars,
+ getTemplate,
+ parseExportArgs,
+} from "@oh-my-pi/pi-coding-agent/export/html";
+
+describe("HTML export themes", () => {
+ it("bundles dark, light, and system-following web themes", async () => {
+ const styles = await generateThemeStyles("web");
+
+ expect(styles).toContain(':root, :root[data-theme="dark"] { color-scheme: dark;');
+ expect(styles).toContain(':root[data-theme="light"] { color-scheme: light;');
+ expect(styles).toContain("@media (prefers-color-scheme: light)");
+ expect(styles).toContain("--bg: #0f0b14;");
+ expect(styles).toContain("--bg: oklch(0.985 0.004 307);");
+ });
+
+ it("bundles independently selected dark and light TUI themes", async () => {
+ const [styles, dark, light] = await Promise.all([
+ generateThemeStyles("theme", { dark: "titanium", light: "light" }),
+ generateThemeVars("theme", "titanium"),
+ generateThemeVars("theme", "light"),
+ ]);
+
+ expect(styles).toContain(`:root, :root[data-theme="dark"] { color-scheme: dark; ${dark} }`);
+ expect(styles).toContain(`:root[data-theme="light"] { color-scheme: light; ${light} }`);
+ });
+
+ it("renders a system, light, and dark theme selector", () => {
+ const html = getTemplate();
+ expect(html).toContain('id="theme-select"');
+ expect(html).toContain('');
+ expect(html).toContain('');
+ expect(html).toContain('');
+ });
+
+ it("parses the optional user-theme flag before or after the output path", () => {
+ expect(parseExportArgs("--themes export.html")).toEqual({ outputPath: "export.html", useUserThemes: true });
+ expect(parseExportArgs("export.html --themes")).toEqual({ outputPath: "export.html", useUserThemes: true });
+ expect(parseExportArgs("")).toEqual({ outputPath: undefined, useUserThemes: false });
+ expect(() => parseExportArgs("one.html two.html")).toThrow("Usage: /export [--themes] [path]");
+ });
+});