WIP: Rewrite export-html with tree sidebar, client-side rendering

- Add tree sidebar with search and filter (Default/All/Labels)
- Client-side markdown/syntax highlighting via vendored marked.js + highlight.js
- Base64 encode session data to avoid escaping issues
- Reuse theme.ts color tokens via getResolvedThemeColors()
- Sticky sidebar, responsive mobile layout with overlay
- Click tree node to scroll to message
- Keyboard shortcuts: Esc to reset, Ctrl/Cmd+F to search
This commit is contained in:
Mario Zechner 2026-01-01 03:36:47 +01:00
parent a073477555
commit 256fa575fb
11 changed files with 3195 additions and 1446 deletions

View file

@ -3263,7 +3263,7 @@ export const MODELS = {
cacheWrite: 0,
},
contextWindow: 163840,
maxTokens: 163840,
maxTokens: 65536,
} satisfies Model<"openai-completions">,
"deepseek/deepseek-r1-distill-llama-70b": {
id: "deepseek/deepseek-r1-distill-llama-70b",
@ -3563,13 +3563,13 @@ export const MODELS = {
reasoning: false,
input: ["text", "image"],
cost: {
input: 0.04,
output: 0.15,
input: 0.036,
output: 0.064,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 96000,
maxTokens: 96000,
contextWindow: 131072,
maxTokens: 4096,
} satisfies Model<"openai-completions">,
"google/gemma-3-27b-it:free": {
id: "google/gemma-3-27b-it:free",
@ -5297,8 +5297,8 @@ export const MODELS = {
reasoning: true,
input: ["text"],
cost: {
input: 0.039,
output: 0.19,
input: 0.02,
output: 0.09999999999999999,
cacheRead: 0,
cacheWrite: 0,
},
@ -5348,8 +5348,8 @@ export const MODELS = {
reasoning: true,
input: ["text"],
cost: {
input: 0.03,
output: 0.14,
input: 0.016,
output: 0.06,
cacheRead: 0,
cacheWrite: 0,
},
@ -5994,8 +5994,8 @@ export const MODELS = {
reasoning: false,
input: ["text"],
cost: {
input: 0.09,
output: 1.1,
input: 0.06,
output: 0.6,
cacheRead: 0,
cacheWrite: 0,
},

View file

@ -32,8 +32,8 @@
"clean": "rm -rf dist",
"build": "tsgo -p tsconfig.build.json && chmod +x dist/cli.js && npm run copy-assets",
"build:binary": "npm run build && bun build --compile ./dist/cli.js --outfile dist/pi && npm run copy-binary-assets",
"copy-assets": "mkdir -p dist/modes/interactive/theme && cp src/modes/interactive/theme/*.json dist/modes/interactive/theme/",
"copy-binary-assets": "cp package.json dist/ && cp README.md dist/ && cp CHANGELOG.md dist/ && mkdir -p dist/theme && cp src/modes/interactive/theme/*.json dist/theme/ && cp -r docs dist/ && cp -r examples dist/",
"copy-assets": "mkdir -p dist/modes/interactive/theme && cp src/modes/interactive/theme/*.json dist/modes/interactive/theme/ && mkdir -p dist/core/export-html/vendor && cp src/core/export-html/template.html dist/core/export-html/ && cp src/core/export-html/vendor/*.js dist/core/export-html/vendor/",
"copy-binary-assets": "cp package.json dist/ && cp README.md dist/ && cp CHANGELOG.md dist/ && mkdir -p dist/theme && cp src/modes/interactive/theme/*.json dist/theme/ && mkdir -p dist/export-html/vendor && cp src/core/export-html/template.html dist/export-html/ && cp src/core/export-html/vendor/*.js dist/export-html/vendor/ && cp -r docs dist/ && cp -r examples dist/",
"test": "vitest --run",
"prepublishOnly": "npm run clean && npm run build"
},

View file

@ -60,6 +60,21 @@ export function getThemesDir(): string {
return join(packageDir, srcOrDist, "modes", "interactive", "theme");
}
/**
* Get path to HTML export template directory (shipped with package)
* - For Bun binary: export-html/ next to executable
* - For Node.js (dist/): dist/core/export-html/
* - For tsx (src/): src/core/export-html/
*/
export function getExportTemplateDir(): string {
if (isBunBinary) {
return join(dirname(process.execPath), "export-html");
}
const packageDir = getPackageDir();
const srcOrDist = existsSync(join(packageDir, "src")) ? "src" : "dist";
return join(packageDir, srcOrDist, "core", "export-html");
}
/** Get path to package.json */
export function getPackageJsonPath(): string {
return join(getPackageDir(), "package.json");

View file

@ -28,7 +28,7 @@ import {
shouldCompact,
} from "./compaction/index.js";
import type { CustomToolContext, CustomToolSessionEvent, LoadedCustomTool } from "./custom-tools/index.js";
import { exportSessionToHtml } from "./export-html.js";
import { exportSessionToHtml } from "./export-html/index.js";
import type {
HookContext,
HookRunner,

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,130 @@
import type { AgentState } from "@mariozechner/pi-agent-core";
import { existsSync, readFileSync, writeFileSync } from "fs";
import { basename, join } from "path";
import { APP_NAME, getExportTemplateDir, VERSION } from "../../config.js";
import { getResolvedThemeColors, isLightTheme } from "../../modes/interactive/theme/theme.js";
import { SessionManager } from "../session-manager.js";
export interface ExportOptions {
outputPath?: string;
themeName?: string;
}
/**
* Generate CSS custom property declarations from theme colors.
*/
function generateThemeVars(themeName?: string): string {
const colors = getResolvedThemeColors(themeName);
const lines: string[] = [];
for (const [key, value] of Object.entries(colors)) {
lines.push(`--${key}: ${value};`);
}
return lines.join("\n ");
}
interface SessionData {
header: ReturnType<SessionManager["getHeader"]>;
entries: ReturnType<SessionManager["getEntries"]>;
leafId: string | null;
systemPrompt?: string;
tools?: { name: string; description: string }[];
}
/**
* Core HTML generation logic shared by both export functions.
*/
function generateHtml(sessionData: SessionData, themeName?: string): string {
const templateDir = getExportTemplateDir();
const template = readFileSync(join(templateDir, "template.html"), "utf-8");
const markedJs = readFileSync(join(templateDir, "vendor", "marked.min.js"), "utf-8");
const hljsJs = readFileSync(join(templateDir, "vendor", "highlight.min.js"), "utf-8");
const themeVars = generateThemeVars(themeName);
const light = isLightTheme(themeName);
const bodyBg = light ? "#f8f8f8" : "#18181e";
const containerBg = light ? "#ffffff" : "#1e1e24";
const title = `Session ${sessionData.header?.id ?? "export"} - ${APP_NAME}`;
// Base64 encode session data to avoid escaping issues
const sessionDataBase64 = Buffer.from(JSON.stringify(sessionData)).toString("base64");
return template
.replace("{{TITLE}}", title)
.replace("{{THEME_VARS}}", themeVars)
.replace("{{BODY_BG}}", bodyBg)
.replace("{{CONTAINER_BG}}", containerBg)
.replace("{{SESSION_DATA}}", sessionDataBase64)
.replace("{{MARKED_JS}}", markedJs)
.replace("{{HIGHLIGHT_JS}}", hljsJs)
.replace("{{APP_NAME}}", `${APP_NAME} v${VERSION}`)
.replace("{{GENERATED_DATE}}", new Date().toLocaleString());
}
/**
* Export session to HTML using SessionManager and AgentState.
* Used by TUI's /export command.
*/
export function exportSessionToHtml(sm: SessionManager, state?: AgentState, options?: ExportOptions | string): string {
const opts: ExportOptions = typeof options === "string" ? { outputPath: options } : options || {};
const sessionFile = sm.getSessionFile();
if (!sessionFile) {
throw new Error("Cannot export in-memory session to HTML");
}
if (!existsSync(sessionFile)) {
throw new Error("Nothing to export yet - start a conversation first");
}
const sessionData: SessionData = {
header: sm.getHeader(),
entries: sm.getEntries(),
leafId: sm.getLeafId(),
systemPrompt: state?.systemPrompt,
tools: state?.tools?.map((t) => ({ name: t.name, description: t.description })),
};
const html = generateHtml(sessionData, opts.themeName);
let outputPath = opts.outputPath;
if (!outputPath) {
const sessionBasename = basename(sessionFile, ".jsonl");
outputPath = `${APP_NAME}-session-${sessionBasename}.html`;
}
writeFileSync(outputPath, html, "utf8");
return outputPath;
}
/**
* Export session file to HTML (standalone, without AgentState).
* Used by CLI for exporting arbitrary session files.
*/
export function exportFromFile(inputPath: string, options?: ExportOptions | string): string {
const opts: ExportOptions = typeof options === "string" ? { outputPath: options } : options || {};
if (!existsSync(inputPath)) {
throw new Error(`File not found: ${inputPath}`);
}
const sm = SessionManager.open(inputPath);
const sessionData: SessionData = {
header: sm.getHeader(),
entries: sm.getEntries(),
leafId: sm.getLeafId(),
systemPrompt: undefined,
tools: undefined,
};
const html = generateHtml(sessionData, opts.themeName);
let outputPath = opts.outputPath;
if (!outputPath) {
const inputBasename = basename(inputPath, ".jsonl");
outputPath = `${APP_NAME}-session-${inputBasename}.html`;
}
writeFileSync(outputPath, html, "utf8");
return outputPath;
}

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -17,7 +17,7 @@ import { CONFIG_DIR_NAME, getAgentDir, getModelsPath, VERSION } from "./config.j
import type { AgentSession } from "./core/agent-session.js";
import type { LoadedCustomTool } from "./core/custom-tools/index.js";
import { exportFromFile } from "./core/export-html.js";
import { exportFromFile } from "./core/export-html/index.js";
import type { HookUIContext } from "./core/index.js";
import type { ModelRegistry } from "./core/model-registry.js";
import { resolveModelScope, type ScopedModel } from "./core/model-resolver.js";

View file

@ -652,6 +652,91 @@ export function stopThemeWatcher(): void {
}
}
// ============================================================================
// HTML Export Helpers
// ============================================================================
/**
* Convert a 256-color index to hex string.
* Indices 0-15: basic colors (approximate)
* Indices 16-231: 6x6x6 color cube
* Indices 232-255: grayscale ramp
*/
function ansi256ToHex(index: number): string {
// Basic colors (0-15) - approximate common terminal values
const basicColors = [
"#000000",
"#800000",
"#008000",
"#808000",
"#000080",
"#800080",
"#008080",
"#c0c0c0",
"#808080",
"#ff0000",
"#00ff00",
"#ffff00",
"#0000ff",
"#ff00ff",
"#00ffff",
"#ffffff",
];
if (index < 16) {
return basicColors[index];
}
// Color cube (16-231): 6x6x6 = 216 colors
if (index < 232) {
const cubeIndex = index - 16;
const r = Math.floor(cubeIndex / 36);
const g = Math.floor((cubeIndex % 36) / 6);
const b = cubeIndex % 6;
const toHex = (n: number) => (n === 0 ? 0 : 55 + n * 40).toString(16).padStart(2, "0");
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
}
// Grayscale (232-255): 24 shades
const gray = 8 + (index - 232) * 10;
const grayHex = gray.toString(16).padStart(2, "0");
return `#${grayHex}${grayHex}${grayHex}`;
}
/**
* Get resolved theme colors as CSS-compatible hex strings.
* Used by HTML export to generate CSS custom properties.
*/
export function getResolvedThemeColors(themeName?: string): Record<string, string> {
const name = themeName ?? getDefaultTheme();
const isLight = name === "light";
const themeJson = loadThemeJson(name);
const resolved = resolveThemeColors(themeJson.colors, themeJson.vars);
// Default text color for empty values (terminal uses default fg color)
const defaultText = isLight ? "#000000" : "#e5e5e7";
const cssColors: Record<string, string> = {};
for (const [key, value] of Object.entries(resolved)) {
if (typeof value === "number") {
cssColors[key] = ansi256ToHex(value);
} else if (value === "") {
// Empty means default terminal color - use sensible fallback for HTML
cssColors[key] = defaultText;
} else {
cssColors[key] = value;
}
}
return cssColors;
}
/**
* Check if a theme is a "light" theme (for CSS that needs light/dark variants).
*/
export function isLightTheme(themeName?: string): boolean {
// Currently just check the name - could be extended to analyze colors
return themeName === "light";
}
// ============================================================================
// TUI Helpers
// ============================================================================