mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-17 06:04:51 +00:00
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:
parent
a073477555
commit
256fa575fb
11 changed files with 3195 additions and 1446 deletions
|
|
@ -3263,7 +3263,7 @@ export const MODELS = {
|
||||||
cacheWrite: 0,
|
cacheWrite: 0,
|
||||||
},
|
},
|
||||||
contextWindow: 163840,
|
contextWindow: 163840,
|
||||||
maxTokens: 163840,
|
maxTokens: 65536,
|
||||||
} satisfies Model<"openai-completions">,
|
} satisfies Model<"openai-completions">,
|
||||||
"deepseek/deepseek-r1-distill-llama-70b": {
|
"deepseek/deepseek-r1-distill-llama-70b": {
|
||||||
id: "deepseek/deepseek-r1-distill-llama-70b",
|
id: "deepseek/deepseek-r1-distill-llama-70b",
|
||||||
|
|
@ -3563,13 +3563,13 @@ export const MODELS = {
|
||||||
reasoning: false,
|
reasoning: false,
|
||||||
input: ["text", "image"],
|
input: ["text", "image"],
|
||||||
cost: {
|
cost: {
|
||||||
input: 0.04,
|
input: 0.036,
|
||||||
output: 0.15,
|
output: 0.064,
|
||||||
cacheRead: 0,
|
cacheRead: 0,
|
||||||
cacheWrite: 0,
|
cacheWrite: 0,
|
||||||
},
|
},
|
||||||
contextWindow: 96000,
|
contextWindow: 131072,
|
||||||
maxTokens: 96000,
|
maxTokens: 4096,
|
||||||
} satisfies Model<"openai-completions">,
|
} satisfies Model<"openai-completions">,
|
||||||
"google/gemma-3-27b-it:free": {
|
"google/gemma-3-27b-it:free": {
|
||||||
id: "google/gemma-3-27b-it:free",
|
id: "google/gemma-3-27b-it:free",
|
||||||
|
|
@ -5297,8 +5297,8 @@ export const MODELS = {
|
||||||
reasoning: true,
|
reasoning: true,
|
||||||
input: ["text"],
|
input: ["text"],
|
||||||
cost: {
|
cost: {
|
||||||
input: 0.039,
|
input: 0.02,
|
||||||
output: 0.19,
|
output: 0.09999999999999999,
|
||||||
cacheRead: 0,
|
cacheRead: 0,
|
||||||
cacheWrite: 0,
|
cacheWrite: 0,
|
||||||
},
|
},
|
||||||
|
|
@ -5348,8 +5348,8 @@ export const MODELS = {
|
||||||
reasoning: true,
|
reasoning: true,
|
||||||
input: ["text"],
|
input: ["text"],
|
||||||
cost: {
|
cost: {
|
||||||
input: 0.03,
|
input: 0.016,
|
||||||
output: 0.14,
|
output: 0.06,
|
||||||
cacheRead: 0,
|
cacheRead: 0,
|
||||||
cacheWrite: 0,
|
cacheWrite: 0,
|
||||||
},
|
},
|
||||||
|
|
@ -5994,8 +5994,8 @@ export const MODELS = {
|
||||||
reasoning: false,
|
reasoning: false,
|
||||||
input: ["text"],
|
input: ["text"],
|
||||||
cost: {
|
cost: {
|
||||||
input: 0.09,
|
input: 0.06,
|
||||||
output: 1.1,
|
output: 0.6,
|
||||||
cacheRead: 0,
|
cacheRead: 0,
|
||||||
cacheWrite: 0,
|
cacheWrite: 0,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -32,8 +32,8 @@
|
||||||
"clean": "rm -rf dist",
|
"clean": "rm -rf dist",
|
||||||
"build": "tsgo -p tsconfig.build.json && chmod +x dist/cli.js && npm run copy-assets",
|
"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",
|
"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-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/ && cp -r docs dist/ && cp -r examples dist/",
|
"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",
|
"test": "vitest --run",
|
||||||
"prepublishOnly": "npm run clean && npm run build"
|
"prepublishOnly": "npm run clean && npm run build"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,21 @@ export function getThemesDir(): string {
|
||||||
return join(packageDir, srcOrDist, "modes", "interactive", "theme");
|
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 */
|
/** Get path to package.json */
|
||||||
export function getPackageJsonPath(): string {
|
export function getPackageJsonPath(): string {
|
||||||
return join(getPackageDir(), "package.json");
|
return join(getPackageDir(), "package.json");
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ import {
|
||||||
shouldCompact,
|
shouldCompact,
|
||||||
} from "./compaction/index.js";
|
} from "./compaction/index.js";
|
||||||
import type { CustomToolContext, CustomToolSessionEvent, LoadedCustomTool } from "./custom-tools/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 {
|
import type {
|
||||||
HookContext,
|
HookContext,
|
||||||
HookRunner,
|
HookRunner,
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
130
packages/coding-agent/src/core/export-html/index.ts
Normal file
130
packages/coding-agent/src/core/export-html/index.ts
Normal 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;
|
||||||
|
}
|
||||||
1731
packages/coding-agent/src/core/export-html/template.html
Normal file
1731
packages/coding-agent/src/core/export-html/template.html
Normal file
File diff suppressed because it is too large
Load diff
1213
packages/coding-agent/src/core/export-html/vendor/highlight.min.js
vendored
Normal file
1213
packages/coding-agent/src/core/export-html/vendor/highlight.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
6
packages/coding-agent/src/core/export-html/vendor/marked.min.js
vendored
Normal file
6
packages/coding-agent/src/core/export-html/vendor/marked.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -17,7 +17,7 @@ import { CONFIG_DIR_NAME, getAgentDir, getModelsPath, VERSION } from "./config.j
|
||||||
import type { AgentSession } from "./core/agent-session.js";
|
import type { AgentSession } from "./core/agent-session.js";
|
||||||
|
|
||||||
import type { LoadedCustomTool } from "./core/custom-tools/index.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 { HookUIContext } from "./core/index.js";
|
||||||
import type { ModelRegistry } from "./core/model-registry.js";
|
import type { ModelRegistry } from "./core/model-registry.js";
|
||||||
import { resolveModelScope, type ScopedModel } from "./core/model-resolver.js";
|
import { resolveModelScope, type ScopedModel } from "./core/model-resolver.js";
|
||||||
|
|
|
||||||
|
|
@ -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
|
// TUI Helpers
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue