Add light and system themes to HTML exports

This commit is contained in:
Anatoli Tsinovoy
2026-07-14 23:22:57 +03:00
parent 7504d4c24d
commit 159bdd8b42
11 changed files with 297 additions and 214 deletions
+3
View File
@@ -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
@@ -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("<theme-vars/>", () => `<style>:root { ${themeVars} }</style>`)
.replace("<theme-vars/>", () => `<style>${themeStyles}</style>`)
.replace("<title>Session Export</title>", () => "<title>omp session</title>")
.replace("{{SESSION_DATA}}</script>", () => `</script>\n <script>${loaderJs}</script>`);
+56 -47
View File
@@ -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/<id>` 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<string> {
// 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<string> {
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<string> {
const themeVars = await generateThemeVars(palette, themeName);
async function generateHtml(
sessionData: SessionData,
palette: "web" | "theme",
themeNames?: ExportThemeNames,
themeName?: string,
): Promise<string> {
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("<theme-vars/>", () => `<style>:root { ${themeVars} }</style>`)
.replace("<theme-vars/>", () => `<style>${themeStyles}</style>`)
.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);
@@ -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;
@@ -8,6 +8,7 @@
<title>Session Export</title>
<template-css/>
<theme-vars/>
<script>try{const t=localStorage.getItem("omp-export-theme");if(t==="light"||t==="dark")document.documentElement.dataset.theme=t}catch{}</script>
</head>
<body>
<button id="hamburger" title="Open sidebar" aria-label="Open sidebar"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true"><path d="M3 6h18M3 12h18M3 18h18"/></svg></button>
@@ -17,6 +18,11 @@
<div class="sidebar-header">
<div class="sidebar-controls">
<input type="text" class="sidebar-search" id="tree-search" placeholder="Search entries…" autocomplete="off" spellcheck="false">
<select class="theme-select" id="theme-select" title="Color theme" aria-label="Color theme">
<option value="system">System</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</div>
<div class="sidebar-filters">
<button class="filter-btn active" data-filter="default" title="Hide settings entries">Default</button>
@@ -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
// ============================================================
@@ -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/<id>`).
*
* 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<string, string>;
/** 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<WebExportScheme, Record<string, string>>;
/** 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(" ");
}
@@ -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<void> {
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) {
@@ -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<string> {
// 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<string> {
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,
});
}
// =========================================================================
@@ -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<SlashCommandSpec> = [
{
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) {
@@ -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('<option value="system">System</option>');
expect(html).toContain('<option value="light">Light</option>');
expect(html).toContain('<option value="dark">Dark</option>');
});
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]");
});
});