merge(pi/main): integrate upstream changes through v0.31.1

Merged 53 commits from pi/main including:
- Export HTML rewrite with tree sidebar and /share command
- Hooks API enhancements (custom status, editor, session management)
- Edit diff preview before tool execution
- TUI fixes (Unicode format chars, OSC 8 hyperlinks, emoji optimization)
- Gemini CLI rate limit retry with server-provided delay
- Various bug fixes and documentation updates

Resolved conflicts by preserving Bun migration (spawn→Bun.spawn,
fs→Bun.file) while accepting upstream's new features and APIs.
This commit is contained in:
can1357
2026-01-02 14:34:27 +01:00
92 changed files with 6332 additions and 2520 deletions
@@ -8,8 +8,8 @@ export class BorderedLoader extends Container {
constructor(tui: TUI, theme: Theme, message: string) {
super();
this.addChild(new DynamicBorder());
this.addChild(new Spacer(1));
const borderColor = (s: string) => theme.fg("border", s);
this.addChild(new DynamicBorder(borderColor));
this.loader = new CancellableLoader(
tui,
(s) => theme.fg("accent", s),
@@ -20,7 +20,7 @@ export class BorderedLoader extends Container {
this.addChild(new Spacer(1));
this.addChild(new Text(theme.fg("muted", "esc cancel"), 1, 0));
this.addChild(new Spacer(1));
this.addChild(new DynamicBorder());
this.addChild(new DynamicBorder(borderColor));
}
get signal(): AbortSignal {
@@ -2,7 +2,11 @@ import type { Component } from "@mariozechner/pi-tui";
import { theme } from "../theme/theme.js";
/**
* Dynamic border component that adjusts to viewport width
* Dynamic border component that adjusts to viewport width.
*
* Note: When used from hooks loaded via jiti, the global `theme` may be undefined
* because jiti creates a separate module cache. Always pass an explicit color
* function when using DynamicBorder in components exported for hook use.
*/
export class DynamicBorder implements Component {
private color: (str: string) => string;
@@ -1,10 +1,22 @@
import type { AssistantMessage } from "@mariozechner/pi-ai";
import { type Component, visibleWidth } from "@mariozechner/pi-tui";
import { type Component, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
import { existsSync, type FSWatcher, readFileSync, watch } from "fs";
import { dirname, join } from "path";
import type { AgentSession } from "../../../core/agent-session.js";
import { theme } from "../theme/theme.js";
/**
* Sanitize text for display in a single-line status.
* Removes newlines, tabs, carriage returns, and other control characters.
*/
function sanitizeStatusText(text: string): string {
// Replace newlines, tabs, carriage returns with space, then collapse multiple spaces
return text
.replace(/[\r\n\t]/g, " ")
.replace(/ +/g, " ")
.trim();
}
/**
* Find the git root directory by walking up from cwd.
* Returns the path to .git/HEAD if found, null otherwise.
@@ -34,6 +46,7 @@ export class FooterComponent implements Component {
private gitWatcher: FSWatcher | null = null;
private onBranchChange: (() => void) | null = null;
private autoCompactEnabled: boolean = true;
private hookStatuses: Map<string, string> = new Map();
constructor(session: AgentSession) {
this.session = session;
@@ -43,6 +56,21 @@ export class FooterComponent implements Component {
this.autoCompactEnabled = enabled;
}
/**
* Set hook status text to display in the footer.
* Text is sanitized (newlines/tabs replaced with spaces) and truncated to terminal width.
* ANSI escape codes for styling are preserved.
* @param key - Unique key to identify this status
* @param text - Status text, or undefined to clear
*/
setHookStatus(key: string, text: string | undefined): void {
if (text === undefined) {
this.hookStatuses.delete(key);
} else {
this.hookStatuses.set(key, text);
}
}
/**
* Set up a file watcher on .git/HEAD to detect branch changes.
* Call the provided callback when branch changes.
@@ -279,6 +307,18 @@ export class FooterComponent implements Component {
const remainder = statsLine.slice(statsLeft.length); // padding + rightSide
const dimRemainder = theme.fg("dim", remainder);
return [theme.fg("dim", pwd), dimStatsLeft + dimRemainder];
const lines = [theme.fg("dim", pwd), dimStatsLeft + dimRemainder];
// Add hook statuses on a single line, sorted by key alphabetically
if (this.hookStatuses.size > 0) {
const sortedStatuses = Array.from(this.hookStatuses.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([, text]) => sanitizeStatusText(text));
const statusLine = sortedStatuses.join(" ");
// Truncate to terminal width with dim ellipsis for consistency with footer style
lines.push(truncateToWidth(statusLine, width, theme.fg("dim", "...")));
}
return lines;
}
}
@@ -0,0 +1,117 @@
/**
* Multi-line editor component for hooks.
* Supports Ctrl+G for external editor.
*/
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { Container, Editor, isCtrlG, isEscape, Spacer, Text, type TUI } from "@mariozechner/pi-tui";
import { getEditorTheme, theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
export class HookEditorComponent extends Container {
private editor: Editor;
private onSubmitCallback: (value: string) => void;
private onCancelCallback: () => void;
private tui: TUI;
constructor(
tui: TUI,
title: string,
prefill: string | undefined,
onSubmit: (value: string) => void,
onCancel: () => void,
) {
super();
this.tui = tui;
this.onSubmitCallback = onSubmit;
this.onCancelCallback = onCancel;
// Add top border
this.addChild(new DynamicBorder());
this.addChild(new Spacer(1));
// Add title
this.addChild(new Text(theme.fg("accent", title), 1, 0));
this.addChild(new Spacer(1));
// Create editor
this.editor = new Editor(getEditorTheme());
if (prefill) {
this.editor.setText(prefill);
}
this.addChild(this.editor);
this.addChild(new Spacer(1));
// Add hint
const hasExternalEditor = !!(process.env.VISUAL || process.env.EDITOR);
const hint = hasExternalEditor
? "ctrl+enter submit esc cancel ctrl+g external editor"
: "ctrl+enter submit esc cancel";
this.addChild(new Text(theme.fg("dim", hint), 1, 0));
this.addChild(new Spacer(1));
// Add bottom border
this.addChild(new DynamicBorder());
}
handleInput(keyData: string): void {
// Ctrl+Enter to submit
if (keyData === "\x1b[13;5u" || keyData === "\x1b[27;5;13~") {
this.onSubmitCallback(this.editor.getText());
return;
}
// Escape to cancel
if (isEscape(keyData)) {
this.onCancelCallback();
return;
}
// Ctrl+G for external editor
if (isCtrlG(keyData)) {
this.openExternalEditor();
return;
}
// Forward to editor
this.editor.handleInput(keyData);
}
private openExternalEditor(): void {
const editorCmd = process.env.VISUAL || process.env.EDITOR;
if (!editorCmd) {
return;
}
const currentText = this.editor.getText();
const tmpFile = path.join(os.tmpdir(), `pi-hook-editor-${Date.now()}.md`);
try {
fs.writeFileSync(tmpFile, currentText, "utf-8");
this.tui.stop();
const [editor, ...editorArgs] = editorCmd.split(" ");
const result = Bun.spawnSync([editor, ...editorArgs, tmpFile], {
stdio: ["inherit", "inherit", "inherit"],
});
if (result.exitCode === 0) {
const newContent = fs.readFileSync(tmpFile, "utf-8").replace(/\n$/, "");
this.editor.setText(newContent);
}
} finally {
try {
fs.unlinkSync(tmpFile);
} catch {
// Ignore cleanup errors
}
this.tui.start();
this.tui.requestRender();
}
}
}
@@ -152,6 +152,7 @@ export class ModelSelectorComponent extends Container {
this.allModels = models;
this.filteredModels = models;
this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, models.length - 1));
}
private filterModels(query: string): void {
@@ -216,11 +217,13 @@ export class ModelSelectorComponent extends Container {
handleInput(keyData: string): void {
// Up arrow - wrap to bottom when at top
if (isArrowUp(keyData)) {
if (this.filteredModels.length === 0) return;
this.selectedIndex = this.selectedIndex === 0 ? this.filteredModels.length - 1 : this.selectedIndex - 1;
this.updateList();
}
// Down arrow - wrap to top when at bottom
else if (isArrowDown(keyData)) {
if (this.filteredModels.length === 0) return;
this.selectedIndex = this.selectedIndex === this.filteredModels.length - 1 ? 0 : this.selectedIndex + 1;
this.updateList();
}
@@ -12,6 +12,7 @@ import {
} from "@mariozechner/pi-tui";
import stripAnsi from "strip-ansi";
import type { CustomTool } from "../../../core/custom-tools/types.js";
import { computeEditDiff, type EditDiffError, type EditDiffResult } from "../../../core/tools/edit-diff.js";
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize } from "../../../core/tools/truncate.js";
import { sanitizeBinaryOutput } from "../../../utils/shell.js";
import { getLanguageFromPath, highlightCode, theme } from "../theme/theme.js";
@@ -58,11 +59,15 @@ export class ToolExecutionComponent extends Container {
private isPartial = true;
private customTool?: CustomTool;
private ui: TUI;
private cwd: string;
private result?: {
content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
isError: boolean;
details?: any;
};
// Cached edit diff preview (computed when args arrive, before tool executes)
private editDiffPreview?: EditDiffResult | EditDiffError;
private editDiffArgsKey?: string; // Track which args the preview is for
constructor(
toolName: string,
@@ -70,6 +75,7 @@ export class ToolExecutionComponent extends Container {
options: ToolExecutionOptions = {},
customTool: CustomTool | undefined,
ui: TUI,
cwd: string = process.cwd(),
) {
super();
this.toolName = toolName;
@@ -77,6 +83,7 @@ export class ToolExecutionComponent extends Container {
this.showImages = options.showImages ?? true;
this.customTool = customTool;
this.ui = ui;
this.cwd = cwd;
this.addChild(new Spacer(1));
@@ -98,6 +105,47 @@ export class ToolExecutionComponent extends Container {
this.updateDisplay();
}
/**
* Signal that args are complete (tool is about to execute).
* This triggers diff computation for edit tool.
*/
setArgsComplete(): void {
this.maybeComputeEditDiff();
}
/**
* Compute edit diff preview when we have complete args.
* This runs async and updates display when done.
*/
private maybeComputeEditDiff(): void {
if (this.toolName !== "edit") return;
const path = this.args?.path;
const oldText = this.args?.oldText;
const newText = this.args?.newText;
// Need all three params to compute diff
if (!path || oldText === undefined || newText === undefined) return;
// Create a key to track which args this computation is for
const argsKey = JSON.stringify({ path, oldText, newText });
// Skip if we already computed for these exact args
if (this.editDiffArgsKey === argsKey) return;
this.editDiffArgsKey = argsKey;
// Compute diff async
computeEditDiff(path, oldText, newText, this.cwd).then((result) => {
// Only update if args haven't changed since we started
if (this.editDiffArgsKey === argsKey) {
this.editDiffPreview = result;
this.updateDisplay();
this.ui.requestRender();
}
});
}
updateResult(
result: {
content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
@@ -393,9 +441,6 @@ export class ToolExecutionComponent extends Container {
theme.fg("toolTitle", theme.bold("write")) +
" " +
(path ? theme.fg("accent", path) : theme.fg("toolOutput", "..."));
if (totalLines > 10) {
text += ` (${totalLines} lines)`;
}
if (fileContent) {
const maxLines = this.expanded ? lines.length : 10;
@@ -408,29 +453,38 @@ export class ToolExecutionComponent extends Container {
.map((line: string) => (lang ? replaceTabs(line) : theme.fg("toolOutput", replaceTabs(line))))
.join("\n");
if (remaining > 0) {
text += theme.fg("toolOutput", `\n... (${remaining} more lines)`);
text += theme.fg("toolOutput", `\n... (${remaining} more lines, ${totalLines} total)`);
}
}
} else if (this.toolName === "edit") {
const rawPath = this.args?.file_path || this.args?.path || "";
const path = shortenPath(rawPath);
// Build path display, appending :line if we have a successful result with line info
// Build path display, appending :line if we have diff info
let pathDisplay = path ? theme.fg("accent", path) : theme.fg("toolOutput", "...");
if (this.result && !this.result.isError && this.result.details?.firstChangedLine) {
pathDisplay += theme.fg("warning", `:${this.result.details.firstChangedLine}`);
const firstChangedLine =
(this.editDiffPreview && "firstChangedLine" in this.editDiffPreview
? this.editDiffPreview.firstChangedLine
: undefined) ||
(this.result && !this.result.isError ? this.result.details?.firstChangedLine : undefined);
if (firstChangedLine) {
pathDisplay += theme.fg("warning", `:${firstChangedLine}`);
}
text = `${theme.fg("toolTitle", theme.bold("edit"))} ${pathDisplay}`;
if (this.result) {
if (this.result.isError) {
const errorText = this.getTextOutput();
if (errorText) {
text += `\n\n${theme.fg("error", errorText)}`;
}
} else if (this.result.details?.diff) {
text += `\n\n${renderDiff(this.result.details.diff, { filePath: rawPath })}`;
if (this.result?.isError) {
// Show error from result
const errorText = this.getTextOutput();
if (errorText) {
text += `\n\n${theme.fg("error", errorText)}`;
}
} else if (this.editDiffPreview) {
// Use cached diff preview (works both before and after execution)
if ("error" in this.editDiffPreview) {
text += `\n\n${theme.fg("error", this.editDiffPreview.error)}`;
} else if (this.editDiffPreview.diff) {
text += `\n\n${renderDiff(this.editDiffPreview.diff, { filePath: rawPath })}`;
}
}
} else if (this.toolName === "ls") {
@@ -37,11 +37,13 @@ import { copyToClipboard } from "../../utils/clipboard.js";
import { ArminComponent } from "./components/armin.js";
import { AssistantMessageComponent } from "./components/assistant-message.js";
import { BashExecutionComponent } from "./components/bash-execution.js";
import { BorderedLoader } from "./components/bordered-loader.js";
import { BranchSummaryMessageComponent } from "./components/branch-summary-message.js";
import { CompactionSummaryMessageComponent } from "./components/compaction-summary-message.js";
import { CustomEditor } from "./components/custom-editor.js";
import { DynamicBorder } from "./components/dynamic-border.js";
import { FooterComponent } from "./components/footer.js";
import { HookEditorComponent } from "./components/hook-editor.js";
import { HookInputComponent } from "./components/hook-input.js";
import { HookMessageComponent } from "./components/hook-message.js";
import { HookSelectorComponent } from "./components/hook-selector.js";
@@ -130,6 +132,7 @@ export class InteractiveMode {
// Hook UI state
private hookSelector: HookSelectorComponent | undefined = undefined;
private hookInput: HookInputComponent | undefined = undefined;
private hookEditor: HookEditorComponent | undefined = undefined;
// Custom tools for custom rendering
private customTools: Map<string, LoadedCustomTool>;
@@ -172,6 +175,7 @@ export class InteractiveMode {
{ name: "settings", description: "Open settings menu" },
{ name: "model", description: "Select model (opens selector UI)" },
{ name: "export", description: "Export session to HTML file" },
{ name: "share", description: "Share session as a secret GitHub gist" },
{ name: "copy", description: "Copy last agent message to clipboard" },
{ name: "session", description: "Show session info and stats" },
{ name: "changelog", description: "Show changelog entries" },
@@ -368,9 +372,14 @@ export class InteractiveMode {
confirm: (title, message) => this.showHookConfirm(title, message),
input: (title, placeholder) => this.showHookInput(title, placeholder),
notify: (message, type) => this.showHookNotify(message, type),
setStatus: (key, text) => this.setHookStatus(key, text),
custom: (factory) => this.showHookCustom(factory),
setEditorText: (text) => this.editor.setText(text),
getEditorText: () => this.editor.getText(),
editor: (title, prefill) => this.showHookEditor(title, prefill),
get theme() {
return theme;
},
};
this.setToolUIContext(uiContext, true);
@@ -405,6 +414,74 @@ export class InteractiveMode {
appendEntryHandler: (customType, data) => {
this.sessionManager.appendCustomEntry(customType, data);
},
newSessionHandler: async (options) => {
// Stop any loading animation
if (this.loadingAnimation) {
this.loadingAnimation.stop();
this.loadingAnimation = undefined;
}
this.statusContainer.clear();
// Create new session
const success = await this.session.newSession({ parentSession: options?.parentSession });
if (!success) {
return { cancelled: true };
}
// Call setup callback if provided
if (options?.setup) {
await options.setup(this.sessionManager);
}
// Clear UI state
this.chatContainer.clear();
this.pendingMessagesContainer.clear();
this.streamingComponent = undefined;
this.streamingMessage = undefined;
this.pendingTools.clear();
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new Text(`${theme.fg("accent", "✓ New session started")}`, 1, 1));
this.ui.requestRender();
return { cancelled: false };
},
branchHandler: async (entryId) => {
const result = await this.session.branch(entryId);
if (result.cancelled) {
return { cancelled: true };
}
// Update UI
this.chatContainer.clear();
this.renderInitialMessages();
this.editor.setText(result.selectedText);
this.showStatus("Branched to new session");
return { cancelled: false };
},
navigateTreeHandler: async (targetId, options) => {
const result = await this.session.navigateTree(targetId, { summarize: options?.summarize });
if (result.cancelled) {
return { cancelled: true };
}
// Update UI
this.chatContainer.clear();
this.renderInitialMessages();
if (result.editorText) {
this.editor.setText(result.editorText);
}
this.showStatus("Navigated to selected point");
return { cancelled: false };
},
isIdle: () => !this.session.isStreaming,
waitForIdle: () => this.session.agent.waitForIdle(),
abort: () => {
this.session.abort();
},
hasQueuedMessages: () => this.session.queuedMessageCount > 0,
uiContext,
hasUI: true,
});
@@ -439,6 +516,11 @@ export class InteractiveMode {
sessionManager: this.session.sessionManager,
modelRegistry: this.session.modelRegistry,
model: this.session.model,
isIdle: () => !this.session.isStreaming,
hasQueuedMessages: () => this.session.queuedMessageCount > 0,
abort: () => {
this.session.abort();
},
});
} catch (err) {
this.showToolError(tool.name, err instanceof Error ? err.message : String(err));
@@ -456,6 +538,14 @@ export class InteractiveMode {
this.ui.requestRender();
}
/**
* Set hook status text in the footer.
*/
private setHookStatus(key: string, text: string | undefined): void {
this.footer.setHookStatus(key, text);
this.ui.requestRender();
}
/**
* Show a selector for hooks.
*/
@@ -536,6 +626,43 @@ export class InteractiveMode {
this.ui.requestRender();
}
/**
* Show a multi-line editor for hooks (with Ctrl+G support).
*/
private showHookEditor(title: string, prefill?: string): Promise<string | undefined> {
return new Promise((resolve) => {
this.hookEditor = new HookEditorComponent(
this.ui,
title,
prefill,
(value) => {
this.hideHookEditor();
resolve(value);
},
() => {
this.hideHookEditor();
resolve(undefined);
},
);
this.editorContainer.clear();
this.editorContainer.addChild(this.hookEditor);
this.ui.setFocus(this.hookEditor);
this.ui.requestRender();
});
}
/**
* Hide the hook editor.
*/
private hideHookEditor(): void {
this.editorContainer.clear();
this.editorContainer.addChild(this.editor);
this.hookEditor = undefined;
this.ui.setFocus(this.editor);
this.ui.requestRender();
}
/**
* Show a notification for hooks.
*/
@@ -674,8 +801,13 @@ export class InteractiveMode {
this.editor.setText("");
return;
}
if (text === "/share") {
await this.handleShareCommand();
this.editor.setText("");
return;
}
if (text === "/copy") {
this.handleCopyCommand();
await this.handleCopyCommand();
this.editor.setText("");
return;
}
@@ -901,6 +1033,11 @@ export class InteractiveMode {
});
}
this.pendingTools.clear();
} else {
// Args are now complete - trigger diff computation for edit tools
for (const [, component] of this.pendingTools.entries()) {
component.setArgsComplete();
}
}
this.streamingComponent = undefined;
this.streamingMessage = undefined;
@@ -1399,8 +1536,8 @@ export class InteractiveMode {
stderr: "inherit",
});
// On successful exit (status 0), replace editor content
if (result.status === 0) {
// On successful exit (exitCode 0), replace editor content
if (result.exitCode === 0) {
const newContent = fs.readFileSync(tmpFile, "utf-8").replace(/\n$/, "");
this.editor.setText(newContent);
}
@@ -1910,7 +2047,123 @@ export class InteractiveMode {
}
}
private handleCopyCommand(): void {
private async handleShareCommand(): Promise<void> {
// Check if gh is available and logged in
try {
const authResult = Bun.spawnSync(["gh", "auth", "status"]);
if (authResult.exitCode !== 0) {
this.showError("GitHub CLI is not logged in. Run 'gh auth login' first.");
return;
}
} catch {
this.showError("GitHub CLI (gh) is not installed. Install it from https://cli.github.com/");
return;
}
// Export to a temp file
const tmpFile = path.join(os.tmpdir(), "session.html");
try {
this.session.exportToHtml(tmpFile);
} catch (error: unknown) {
this.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`);
return;
}
// Show cancellable loader, replacing the editor
const loader = new BorderedLoader(this.ui, theme, "Creating gist...");
this.editorContainer.clear();
this.editorContainer.addChild(loader);
this.ui.setFocus(loader);
this.ui.requestRender();
const restoreEditor = () => {
loader.dispose();
this.editorContainer.clear();
this.editorContainer.addChild(this.editor);
this.ui.setFocus(this.editor);
try {
fs.unlinkSync(tmpFile);
} catch {
// Ignore cleanup errors
}
};
// Create a secret gist asynchronously
let proc: ReturnType<typeof Bun.spawn> | null = null;
loader.onAbort = () => {
proc?.kill();
restoreEditor();
this.showStatus("Share cancelled");
};
try {
const result = await new Promise<{ stdout: string; stderr: string; code: number | null }>((resolve) => {
proc = Bun.spawn(["gh", "gist", "create", "--public=false", tmpFile], {
stdout: "pipe",
stderr: "pipe",
});
let stdout = "";
let stderr = "";
const stdoutReader = (proc.stdout as ReadableStream<Uint8Array>).getReader();
const stderrReader = (proc.stderr as ReadableStream<Uint8Array>).getReader();
const decoder = new TextDecoder();
(async () => {
try {
while (true) {
const { done, value } = await stdoutReader.read();
if (done) break;
stdout += decoder.decode(value);
}
} catch {}
})();
(async () => {
try {
while (true) {
const { done, value } = await stderrReader.read();
if (done) break;
stderr += decoder.decode(value);
}
} catch {}
})();
proc.exited.then((code) => resolve({ stdout, stderr, code }));
});
if (loader.signal.aborted) return;
restoreEditor();
if (result.code !== 0) {
const errorMsg = result.stderr?.trim() || "Unknown error";
this.showError(`Failed to create gist: ${errorMsg}`);
return;
}
// Extract gist ID from the URL returned by gh
// gh returns something like: https://gist.github.com/username/GIST_ID
const gistUrl = result.stdout?.trim();
const gistId = gistUrl?.split("/").pop();
if (!gistId) {
this.showError("Failed to parse gist ID from gh output");
return;
}
// Create the preview URL
const previewUrl = `https://shittycodingagent.ai/session?${gistId}`;
this.showStatus(`Share URL: ${previewUrl}\nGist: ${gistUrl}`);
} catch (error: unknown) {
if (!loader.signal.aborted) {
restoreEditor();
this.showError(`Failed to create gist: ${error instanceof Error ? error.message : "Unknown error"}`);
}
}
}
private async handleCopyCommand(): Promise<void> {
const text = this.session.getLastAssistantText();
if (!text) {
this.showError("No agent messages to copy yet.");
@@ -1918,7 +2171,7 @@ export class InteractiveMode {
}
try {
copyToClipboard(text);
await copyToClipboard(text);
this.showStatus("Copied last agent message to clipboard");
} catch (error) {
this.showError(error instanceof Error ? error.message : String(error));
@@ -2031,8 +2284,8 @@ export class InteractiveMode {
}
this.statusContainer.clear();
// Reset via session (emits hook and tool session events)
await this.session.reset();
// New session via session (emits hook and tool session events)
await this.session.newSession();
// Clear UI state
this.chatContainer.clear();
@@ -2,21 +2,21 @@
"$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/theme-schema.json",
"name": "dark",
"vars": {
"cyan": "#00d7ff",
"blue": "#5f87ff",
"green": "#b5bd68",
"red": "#cc6666",
"yellow": "#ffff00",
"gray": "#808080",
"dimGray": "#666666",
"darkGray": "#505050",
"accent": "#8abeb7",
"selectedBg": "#3a3a4a",
"userMsgBg": "#343541",
"toolPendingBg": "#282832",
"toolSuccessBg": "#283228",
"toolErrorBg": "#3c2828",
"customMsgBg": "#2d2838"
"cyan": "#0088fa",
"blue": "#178fb9",
"green": "#89d281",
"red": "#fc3a4b",
"yellow": "#e4c00f",
"gray": "#777d88",
"dimGray": "#5f6673",
"darkGray": "#3d424a",
"accent": "#febc38",
"selectedBg": "#31363f",
"userMsgBg": "#221d1a",
"toolPendingBg": "#1d2129",
"toolSuccessBg": "#161a1f",
"toolErrorBg": "#291d1d",
"customMsgBg": "#2a2530"
},
"colors": {
"accent": "accent",
@@ -36,28 +36,30 @@
"userMessageText": "",
"customMessageBg": "customMsgBg",
"customMessageText": "",
"customMessageLabel": "#9575cd",
"customMessageLabel": "#b281d6",
"toolPendingBg": "toolPendingBg",
"toolSuccessBg": "toolSuccessBg",
"toolErrorBg": "toolErrorBg",
"toolTitle": "",
"toolOutput": "gray",
"mdHeading": "#f0c674",
"mdLink": "#81a2be",
"mdHeading": "#febc38",
"mdLink": "#0088fa",
"mdLinkUrl": "dimGray",
"mdCode": "accent",
"mdCodeBlock": "green",
"mdCodeBlockBorder": "gray",
"mdCode": "#e5c1ff",
"mdCodeBlock": "#9CDCFE",
"mdCodeBlockBorder": "darkGray",
"mdQuote": "gray",
"mdQuoteBorder": "gray",
"mdHr": "gray",
"mdQuoteBorder": "darkGray",
"mdHr": "darkGray",
"mdListBullet": "accent",
"toolDiffAdded": "green",
"toolDiffRemoved": "red",
"toolDiffContext": "gray",
"link": "#0088fa",
"syntaxComment": "#6A9955",
"syntaxKeyword": "#569CD6",
"syntaxFunction": "#DCDCAA",
@@ -69,12 +71,17 @@
"syntaxPunctuation": "#D4D4D4",
"thinkingOff": "darkGray",
"thinkingMinimal": "#6e6e6e",
"thinkingLow": "#5f87af",
"thinkingMedium": "#81a2be",
"thinkingHigh": "#b294bb",
"thinkingXhigh": "#d183e8",
"thinkingMinimal": "dimGray",
"thinkingLow": "#178fb9",
"thinkingMedium": "#0088fa",
"thinkingHigh": "#b281d6",
"thinkingXhigh": "#e5c1ff",
"bashMode": "green"
"bashMode": "cyan"
},
"export": {
"pageBg": "#18181e",
"cardBg": "#1e1e24",
"infoBg": "#3c3728"
}
}
@@ -75,5 +75,10 @@
"thinkingXhigh": "#8b008b",
"bashMode": "green"
},
"export": {
"pageBg": "#f8f8f8",
"cardBg": "#ffffff",
"infoBg": "#fffae6"
}
}
@@ -267,6 +267,25 @@
}
},
"additionalProperties": false
},
"export": {
"type": "object",
"description": "Optional colors for HTML export (defaults derived from userMessageBg if not specified)",
"properties": {
"pageBg": {
"$ref": "#/$defs/colorValue",
"description": "Page background color"
},
"cardBg": {
"$ref": "#/$defs/colorValue",
"description": "Card/container background color"
},
"infoBg": {
"$ref": "#/$defs/colorValue",
"description": "Info sections background (system prompt, notices)"
}
},
"additionalProperties": false
}
},
"additionalProperties": false,
@@ -82,10 +82,18 @@ const ThemeJsonSchema = Type.Object({
// Bash Mode (1 color)
bashMode: ColorValueSchema,
}),
export: Type.Optional(
Type.Object({
pageBg: Type.Optional(ColorValueSchema),
cardBg: Type.Optional(ColorValueSchema),
infoBg: Type.Optional(ColorValueSchema),
}),
),
});
type ThemeJson = Static<typeof ThemeJsonSchema>;
// @ts-expect-error TypeBox CJS/ESM type mismatch
const validateThemeJson = TypeCompiler.Compile(ThemeJsonSchema);
export type ThemeColor =
@@ -652,6 +660,129 @@ 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";
}
/**
* Get explicit export colors from theme JSON, if specified.
* Returns undefined for each color that isn't explicitly set.
*/
export function getThemeExportColors(themeName?: string): {
pageBg?: string;
cardBg?: string;
infoBg?: string;
} {
const name = themeName ?? getDefaultTheme();
try {
const themeJson = loadThemeJson(name);
const exportSection = themeJson.export;
if (!exportSection) return {};
const vars = themeJson.vars ?? {};
const resolve = (value: string | number | undefined): string | undefined => {
if (value === undefined) return undefined;
if (typeof value === "number") return ansi256ToHex(value);
if (value.startsWith("$")) {
const resolved = vars[value];
if (resolved === undefined) return undefined;
if (typeof resolved === "number") return ansi256ToHex(resolved);
return resolved;
}
return value;
};
return {
pageBg: resolve(exportSection.pageBg),
cardBg: resolve(exportSection.cardBg),
infoBg: resolve(exportSection.infoBg),
};
} catch {
return {};
}
}
// ============================================================================
// TUI Helpers
// ============================================================================