Add light and system themes to HTML exports
This commit is contained in:
@@ -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>`);
|
||||
|
||||
|
||||
@@ -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]");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user