Release v0.8.0

This commit is contained in:
Mario Zechner 2025-11-21 03:12:42 +01:00
parent cc88095140
commit 85adcf22bf
48 changed files with 1530 additions and 608 deletions

View file

@ -236,8 +236,8 @@ Guidelines:
- When summarizing your actions, output plain text directly - do NOT use cat or bash to display what you did
Documentation:
- Your own documentation (including custom model setup) is at: ${readmePath}
- Read it when users ask about features, configuration, or setup, and especially if the user asks you to add a custom model or provider.`;
- Your own documentation (including custom model setup and theme creation) is at: ${readmePath}
- Read it when users ask about features, configuration, or setup, and especially if the user asks you to add a custom model or provider, or create a custom theme.`;
// Append project context files
const contextFiles = loadProjectContextFiles();

View file

@ -3,19 +3,21 @@
"name": "dark",
"vars": {
"cyan": "#00d7ff",
"blue": "#0087ff",
"green": "#00ff00",
"red": "#ff0000",
"blue": "#5f87ff",
"green": "#b5bd68",
"red": "#cc6666",
"yellow": "#ffff00",
"gray": 242,
"darkGray": 238,
"gray": "#808080",
"dimGray": "#666666",
"darkGray": "#303030",
"accent": "#8abeb7",
"userMsgBg": "#343541",
"toolPendingBg": "#282832",
"toolSuccessBg": "#283228",
"toolErrorBg": "#3c2828"
},
"colors": {
"accent": "cyan",
"accent": "accent",
"border": "blue",
"borderAccent": "cyan",
"borderMuted": "darkGray",
@ -23,6 +25,7 @@
"error": "red",
"warning": "yellow",
"muted": "gray",
"dim": "dimGray",
"text": "",
"userMessageBg": "userMsgBg",
@ -30,17 +33,19 @@
"toolPendingBg": "toolPendingBg",
"toolSuccessBg": "toolSuccessBg",
"toolErrorBg": "toolErrorBg",
"toolText": "",
"toolTitle": "",
"toolOutput": "gray",
"mdHeading": "cyan",
"mdLink": "blue",
"mdCode": "cyan",
"mdCodeBlock": "",
"mdHeading": "#f0c674",
"mdLink": "#81a2be",
"mdLinkUrl": "dimGray",
"mdCode": "accent",
"mdCodeBlock": "green",
"mdCodeBlockBorder": "gray",
"mdQuote": "gray",
"mdQuoteBorder": "gray",
"mdHr": "gray",
"mdListBullet": "cyan",
"mdListBullet": "accent",
"toolDiffAdded": "green",
"toolDiffRemoved": "red",
@ -54,6 +59,12 @@
"syntaxNumber": "yellow",
"syntaxType": "cyan",
"syntaxOperator": "",
"syntaxPunctuation": "gray"
"syntaxPunctuation": "gray",
"thinkingOff": "darkGray",
"thinkingMinimal": "#4e4e4e",
"thinkingLow": "#5f87af",
"thinkingMedium": "#81a2be",
"thinkingHigh": "#b294bb"
}
}

View file

@ -2,27 +2,29 @@
"$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/theme-schema.json",
"name": "light",
"vars": {
"darkCyan": "#008899",
"darkBlue": "#0066cc",
"darkGreen": "#008800",
"darkRed": "#cc0000",
"darkYellow": "#aa8800",
"mediumGray": 242,
"lightGray": 250,
"teal": "#5f8787",
"blue": "#5f87af",
"green": "#87af87",
"red": "#af5f5f",
"yellow": "#d7af5f",
"mediumGray": "#6c6c6c",
"dimGray": "#8a8a8a",
"lightGray": "#b0b0b0",
"userMsgBg": "#e8e8e8",
"toolPendingBg": "#e8e8f0",
"toolSuccessBg": "#e8f0e8",
"toolErrorBg": "#f0e8e8"
},
"colors": {
"accent": "darkCyan",
"border": "darkBlue",
"borderAccent": "darkCyan",
"accent": "teal",
"border": "blue",
"borderAccent": "teal",
"borderMuted": "lightGray",
"success": "darkGreen",
"error": "darkRed",
"warning": "darkYellow",
"success": "green",
"error": "red",
"warning": "yellow",
"muted": "mediumGray",
"dim": "dimGray",
"text": "",
"userMessageBg": "userMsgBg",
@ -30,30 +32,38 @@
"toolPendingBg": "toolPendingBg",
"toolSuccessBg": "toolSuccessBg",
"toolErrorBg": "toolErrorBg",
"toolText": "",
"toolTitle": "",
"toolOutput": "mediumGray",
"mdHeading": "darkCyan",
"mdLink": "darkBlue",
"mdCode": "darkCyan",
"mdCodeBlock": "",
"mdHeading": "yellow",
"mdLink": "blue",
"mdLinkUrl": "dimGray",
"mdCode": "teal",
"mdCodeBlock": "green",
"mdCodeBlockBorder": "mediumGray",
"mdQuote": "mediumGray",
"mdQuoteBorder": "mediumGray",
"mdHr": "mediumGray",
"mdListBullet": "darkCyan",
"mdListBullet": "green",
"toolDiffAdded": "darkGreen",
"toolDiffRemoved": "darkRed",
"toolDiffAdded": "green",
"toolDiffRemoved": "red",
"toolDiffContext": "mediumGray",
"syntaxComment": "mediumGray",
"syntaxKeyword": "darkCyan",
"syntaxFunction": "darkBlue",
"syntaxKeyword": "teal",
"syntaxFunction": "blue",
"syntaxVariable": "",
"syntaxString": "darkGreen",
"syntaxNumber": "darkYellow",
"syntaxType": "darkCyan",
"syntaxString": "green",
"syntaxNumber": "yellow",
"syntaxType": "teal",
"syntaxOperator": "",
"syntaxPunctuation": "mediumGray"
"syntaxPunctuation": "mediumGray",
"thinkingOff": "lightGray",
"thinkingMinimal": "#9e9e9e",
"thinkingLow": "#5f87af",
"thinkingMedium": "#5f8787",
"thinkingHigh": "#875f87"
}
}

View file

@ -43,6 +43,7 @@
"error",
"warning",
"muted",
"dim",
"text",
"userMessageBg",
"userMessageText",
@ -105,6 +106,10 @@
"$ref": "#/$defs/colorValue",
"description": "Secondary/dimmed text"
},
"dim": {
"$ref": "#/$defs/colorValue",
"description": "Very dimmed text (more subtle than muted)"
},
"text": {
"$ref": "#/$defs/colorValue",
"description": "Default text color (usually empty string)"

View file

@ -2,7 +2,7 @@ import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import type { MarkdownTheme } from "@mariozechner/pi-tui";
import type { EditorTheme, MarkdownTheme, SelectListTheme } from "@mariozechner/pi-tui";
import { type Static, Type } from "@sinclair/typebox";
import { TypeCompiler } from "@sinclair/typebox/compiler";
import chalk from "chalk";
@ -25,7 +25,7 @@ const ThemeJsonSchema = Type.Object({
name: Type.String(),
vars: Type.Optional(Type.Record(Type.String(), ColorValueSchema)),
colors: Type.Object({
// Core UI (9 colors)
// Core UI (10 colors)
accent: ColorValueSchema,
border: ColorValueSchema,
borderAccent: ColorValueSchema,
@ -34,17 +34,20 @@ const ThemeJsonSchema = Type.Object({
error: ColorValueSchema,
warning: ColorValueSchema,
muted: ColorValueSchema,
dim: ColorValueSchema,
text: ColorValueSchema,
// Backgrounds & Content Text (6 colors)
// Backgrounds & Content Text (7 colors)
userMessageBg: ColorValueSchema,
userMessageText: ColorValueSchema,
toolPendingBg: ColorValueSchema,
toolSuccessBg: ColorValueSchema,
toolErrorBg: ColorValueSchema,
toolText: ColorValueSchema,
// Markdown (9 colors)
toolTitle: ColorValueSchema,
toolOutput: ColorValueSchema,
// Markdown (10 colors)
mdHeading: ColorValueSchema,
mdLink: ColorValueSchema,
mdLinkUrl: ColorValueSchema,
mdCode: ColorValueSchema,
mdCodeBlock: ColorValueSchema,
mdCodeBlockBorder: ColorValueSchema,
@ -66,6 +69,12 @@ const ThemeJsonSchema = Type.Object({
syntaxType: ColorValueSchema,
syntaxOperator: ColorValueSchema,
syntaxPunctuation: ColorValueSchema,
// Thinking Level Borders (5 colors)
thinkingOff: ColorValueSchema,
thinkingMinimal: ColorValueSchema,
thinkingLow: ColorValueSchema,
thinkingMedium: ColorValueSchema,
thinkingHigh: ColorValueSchema,
}),
});
@ -82,11 +91,14 @@ export type ThemeColor =
| "error"
| "warning"
| "muted"
| "dim"
| "text"
| "userMessageText"
| "toolText"
| "toolTitle"
| "toolOutput"
| "mdHeading"
| "mdLink"
| "mdLinkUrl"
| "mdCode"
| "mdCodeBlock"
| "mdCodeBlockBorder"
@ -105,7 +117,12 @@ export type ThemeColor =
| "syntaxNumber"
| "syntaxType"
| "syntaxOperator"
| "syntaxPunctuation";
| "syntaxPunctuation"
| "thinkingOff"
| "thinkingMinimal"
| "thinkingLow"
| "thinkingMedium"
| "thinkingHigh";
export type ThemeBg = "userMessageBg" | "toolPendingBg" | "toolSuccessBg" | "toolErrorBg";
@ -216,8 +233,6 @@ function resolveThemeColors<T extends Record<string, ColorValue>>(
// Theme Class
// ============================================================================
const RESET = "\x1b[0m";
export class Theme {
private fgColors: Map<ThemeColor, string>;
private bgColors: Map<ThemeBg, string>;
@ -242,13 +257,13 @@ export class Theme {
fg(color: ThemeColor, text: string): string {
const ansi = this.fgColors.get(color);
if (!ansi) throw new Error(`Unknown theme color: ${color}`);
return `${ansi}${text}${RESET}`;
return `${ansi}${text}\x1b[39m`; // Reset only foreground color
}
bg(color: ThemeBg, text: string): string {
const ansi = this.bgColors.get(color);
if (!ansi) throw new Error(`Unknown theme background color: ${color}`);
return `${ansi}${text}${RESET}`;
return `${ansi}${text}\x1b[49m`; // Reset only background color
}
bold(text: string): string {
@ -278,6 +293,24 @@ export class Theme {
getColorMode(): ColorMode {
return this.mode;
}
getThinkingBorderColor(level: "off" | "minimal" | "low" | "medium" | "high"): (str: string) => string {
// Map thinking levels to dedicated theme colors
switch (level) {
case "off":
return (str: string) => this.fg("thinkingOff", str);
case "minimal":
return (str: string) => this.fg("thinkingMinimal", str);
case "low":
return (str: string) => this.fg("thinkingLow", str);
case "medium":
return (str: string) => this.fg("thinkingMedium", str);
case "high":
return (str: string) => this.fg("thinkingHigh", str);
default:
return (str: string) => this.fg("thinkingOff", str);
}
}
}
// ============================================================================
@ -369,7 +402,8 @@ function detectTerminalBackground(): "dark" | "light" {
if (parts.length >= 2) {
const bg = parseInt(parts[1], 10);
if (!Number.isNaN(bg)) {
return bg < 8 ? "dark" : "light";
const result = bg < 8 ? "dark" : "light";
return result;
}
}
}
@ -385,14 +419,109 @@ function getDefaultTheme(): string {
// ============================================================================
export let theme: Theme;
let currentThemeName: string | undefined;
let themeWatcher: fs.FSWatcher | undefined;
let onThemeChangeCallback: (() => void) | undefined;
export function initTheme(themeName?: string): void {
const name = themeName ?? getDefaultTheme();
theme = loadTheme(name);
currentThemeName = name;
try {
theme = loadTheme(name);
startThemeWatcher();
} catch (error) {
// Theme is invalid - fall back to dark theme silently
currentThemeName = "dark";
theme = loadTheme("dark");
// Don't start watcher for fallback theme
}
}
export function setTheme(name: string): void {
theme = loadTheme(name);
export function setTheme(name: string): { success: boolean; error?: string } {
currentThemeName = name;
try {
theme = loadTheme(name);
startThemeWatcher();
return { success: true };
} catch (error) {
// Theme is invalid - fall back to dark theme
currentThemeName = "dark";
theme = loadTheme("dark");
// Don't start watcher for fallback theme
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
export function onThemeChange(callback: () => void): void {
onThemeChangeCallback = callback;
}
function startThemeWatcher(): void {
// Stop existing watcher if any
if (themeWatcher) {
themeWatcher.close();
themeWatcher = undefined;
}
// Only watch if it's a custom theme (not built-in)
if (!currentThemeName || currentThemeName === "dark" || currentThemeName === "light") {
return;
}
const themesDir = getThemesDir();
const themeFile = path.join(themesDir, `${currentThemeName}.json`);
// Only watch if the file exists
if (!fs.existsSync(themeFile)) {
return;
}
try {
themeWatcher = fs.watch(themeFile, (eventType) => {
if (eventType === "change") {
// Debounce rapid changes
setTimeout(() => {
try {
// Reload the theme
theme = loadTheme(currentThemeName!);
// Notify callback (to invalidate UI)
if (onThemeChangeCallback) {
onThemeChangeCallback();
}
} catch (error) {
// Ignore errors (file might be in invalid state while being edited)
}
}, 100);
} else if (eventType === "rename") {
// File was deleted or renamed - fall back to default theme
setTimeout(() => {
if (!fs.existsSync(themeFile)) {
currentThemeName = "dark";
theme = loadTheme("dark");
if (themeWatcher) {
themeWatcher.close();
themeWatcher = undefined;
}
if (onThemeChangeCallback) {
onThemeChangeCallback();
}
}
}, 100);
}
});
} catch (error) {
// Ignore errors starting watcher
}
}
export function stopThemeWatcher(): void {
if (themeWatcher) {
themeWatcher.close();
themeWatcher = undefined;
}
}
// ============================================================================
@ -403,6 +532,7 @@ export function getMarkdownTheme(): MarkdownTheme {
return {
heading: (text: string) => theme.fg("mdHeading", text),
link: (text: string) => theme.fg("mdLink", text),
linkUrl: (text: string) => theme.fg("mdLinkUrl", text),
code: (text: string) => theme.fg("mdCode", text),
codeBlock: (text: string) => theme.fg("mdCodeBlock", text),
codeBlockBorder: (text: string) => theme.fg("mdCodeBlockBorder", text),
@ -410,5 +540,26 @@ export function getMarkdownTheme(): MarkdownTheme {
quoteBorder: (text: string) => theme.fg("mdQuoteBorder", text),
hr: (text: string) => theme.fg("mdHr", text),
listBullet: (text: string) => theme.fg("mdListBullet", text),
bold: (text: string) => theme.bold(text),
italic: (text: string) => theme.italic(text),
underline: (text: string) => theme.underline(text),
strikethrough: (text: string) => chalk.strikethrough(text),
};
}
export function getSelectListTheme(): SelectListTheme {
return {
selectedPrefix: (text: string) => theme.fg("accent", text),
selectedText: (text: string) => theme.fg("accent", text),
description: (text: string) => theme.fg("muted", text),
scrollInfo: (text: string) => theme.fg("muted", text),
noMatch: (text: string) => theme.fg("muted", text),
};
}
export function getEditorTheme(): EditorTheme {
return {
borderColor: (text: string) => theme.fg("borderMuted", text),
selectList: getSelectListTheme(),
};
}

View file

@ -1,6 +1,6 @@
import type { AssistantMessage } from "@mariozechner/pi-ai";
import { Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
import chalk from "chalk";
import { getMarkdownTheme, theme } from "../theme/theme.js";
/**
* Component that renders a complete assistant message
@ -38,13 +38,13 @@ export class AssistantMessageComponent extends Container {
if (content.type === "text" && content.text.trim()) {
// Assistant text messages with no background - trim the text
// Set paddingY=0 to avoid extra spacing before tool executions
this.contentContainer.addChild(new Markdown(content.text.trim(), 1, 0));
this.contentContainer.addChild(new Markdown(content.text.trim(), 1, 0, getMarkdownTheme()));
} else if (content.type === "thinking" && content.thinking.trim()) {
// Thinking traces in dark gray italic
// Thinking traces in muted color, italic
// Use Markdown component with default text style for consistent styling
this.contentContainer.addChild(
new Markdown(content.thinking.trim(), 1, 0, {
color: "gray",
new Markdown(content.thinking.trim(), 1, 0, getMarkdownTheme(), {
color: (text: string) => theme.fg("muted", text),
italic: true,
}),
);
@ -57,11 +57,11 @@ export class AssistantMessageComponent extends Container {
const hasToolCalls = message.content.some((c) => c.type === "toolCall");
if (!hasToolCalls) {
if (message.stopReason === "aborted") {
this.contentContainer.addChild(new Text(chalk.red("\nAborted"), 1, 0));
this.contentContainer.addChild(new Text(theme.fg("error", "\nAborted"), 1, 0));
} else if (message.stopReason === "error") {
const errorMsg = message.errorMessage || "Unknown error";
this.contentContainer.addChild(new Spacer(1));
this.contentContainer.addChild(new Text(chalk.red(`Error: ${errorMsg}`), 1, 0));
this.contentContainer.addChild(new Text(theme.fg("error", `Error: ${errorMsg}`), 1, 0));
}
}
}

View file

@ -1,5 +1,5 @@
import type { Component } from "@mariozechner/pi-tui";
import chalk from "chalk";
import { theme } from "../theme/theme.js";
/**
* Dynamic border component that adjusts to viewport width
@ -7,10 +7,14 @@ import chalk from "chalk";
export class DynamicBorder implements Component {
private color: (str: string) => string;
constructor(color: (str: string) => string = chalk.blue) {
constructor(color: (str: string) => string = (str) => theme.fg("border", str)) {
this.color = color;
}
invalidate(): void {
// No cached state to invalidate currently
}
render(width: number): string[] {
return [this.color("─".repeat(Math.max(1, width)))];
}

View file

@ -1,12 +1,12 @@
import type { AgentState } from "@mariozechner/pi-agent";
import type { AssistantMessage } from "@mariozechner/pi-ai";
import { visibleWidth } from "@mariozechner/pi-tui";
import chalk from "chalk";
import { type Component, visibleWidth } from "@mariozechner/pi-tui";
import { theme } from "../theme/theme.js";
/**
* Footer component that shows pwd, token stats, and context usage
*/
export class FooterComponent {
export class FooterComponent implements Component {
private state: AgentState;
constructor(state: AgentState) {
@ -17,6 +17,10 @@ export class FooterComponent {
this.state = state;
}
invalidate(): void {
// No cached state to invalidate currently
}
render(width: number): string[] {
// Calculate cumulative usage from all assistant messages
let totalInput = 0;
@ -50,7 +54,8 @@ export class FooterComponent {
lastAssistantMessage.usage.cacheWrite
: 0;
const contextWindow = this.state.model?.contextWindow || 0;
const contextPercent = contextWindow > 0 ? ((contextTokens / contextWindow) * 100).toFixed(1) : "0.0";
const contextPercentValue = contextWindow > 0 ? (contextTokens / contextWindow) * 100 : 0;
const contextPercent = contextPercentValue.toFixed(1);
// Format token counts (similar to web-ui)
const formatTokens = (count: number): string => {
@ -80,8 +85,18 @@ export class FooterComponent {
if (totalOutput) statsParts.push(`${formatTokens(totalOutput)}`);
if (totalCacheRead) statsParts.push(`R${formatTokens(totalCacheRead)}`);
if (totalCacheWrite) statsParts.push(`W${formatTokens(totalCacheWrite)}`);
if (totalCost) statsParts.push(`$${totalCost.toFixed(3)}`);
statsParts.push(`${contextPercent}%`);
if (totalCost) statsParts.push(`${totalCost.toFixed(3)}`);
// Colorize context percentage based on usage
let contextPercentStr: string;
if (contextPercentValue > 90) {
contextPercentStr = theme.fg("error", `${contextPercent}%`);
} else if (contextPercentValue > 70) {
contextPercentStr = theme.fg("warning", `${contextPercent}%`);
} else {
contextPercentStr = `${contextPercent}%`;
}
statsParts.push(contextPercentStr);
const statsLeft = statsParts.join(" ");
@ -126,6 +141,6 @@ export class FooterComponent {
}
// Return two lines: pwd and stats
return [chalk.gray(pwd), chalk.gray(statsLine)];
return [theme.fg("dim", pwd), theme.fg("dim", statsLine)];
}
}

View file

@ -1,8 +1,9 @@
import type { Model } from "@mariozechner/pi-ai";
import { Container, Input, Spacer, Text, type TUI } from "@mariozechner/pi-tui";
import chalk from "chalk";
import { getAvailableModels } from "../model-config.js";
import type { SettingsManager } from "../settings-manager.js";
import { theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
interface ModelItem {
provider: string;
@ -42,12 +43,12 @@ export class ModelSelectorComponent extends Container {
this.onCancelCallback = onCancel;
// Add top border
this.addChild(new Text(chalk.blue("─".repeat(80)), 0, 0));
this.addChild(new DynamicBorder());
this.addChild(new Spacer(1));
// Add hint about API key filtering
this.addChild(
new Text(chalk.yellow("Only showing models with configured API keys (see README for details)"), 0, 0),
new Text(theme.fg("warning", "Only showing models with configured API keys (see README for details)"), 0, 0),
);
this.addChild(new Spacer(1));
@ -70,7 +71,7 @@ export class ModelSelectorComponent extends Container {
this.addChild(new Spacer(1));
// Add bottom border
this.addChild(new Text(chalk.blue("─".repeat(80)), 0, 0));
this.addChild(new DynamicBorder());
// Load models and do initial render
this.loadModels().then(() => {
@ -150,15 +151,15 @@ export class ModelSelectorComponent extends Container {
let line = "";
if (isSelected) {
const prefix = chalk.blue("→ ");
const prefix = theme.fg("accent", "→ ");
const modelText = `${item.id}`;
const providerBadge = chalk.gray(`[${item.provider}]`);
const checkmark = isCurrent ? chalk.green(" ✓") : "";
line = prefix + chalk.blue(modelText) + " " + providerBadge + checkmark;
const providerBadge = theme.fg("muted", `[${item.provider}]`);
const checkmark = isCurrent ? theme.fg("success", " ✓") : "";
line = prefix + theme.fg("accent", modelText) + " " + providerBadge + checkmark;
} else {
const modelText = ` ${item.id}`;
const providerBadge = chalk.gray(`[${item.provider}]`);
const checkmark = isCurrent ? chalk.green(" ✓") : "";
const providerBadge = theme.fg("muted", `[${item.provider}]`);
const checkmark = isCurrent ? theme.fg("success", " ✓") : "";
line = modelText + " " + providerBadge + checkmark;
}
@ -167,7 +168,7 @@ export class ModelSelectorComponent extends Container {
// Add scroll indicator if needed
if (startIndex > 0 || endIndex < this.filteredModels.length) {
const scrollInfo = chalk.gray(` (${this.selectedIndex + 1}/${this.filteredModels.length})`);
const scrollInfo = theme.fg("muted", ` (${this.selectedIndex + 1}/${this.filteredModels.length})`);
this.listContainer.addChild(new Text(scrollInfo, 0, 0));
}
@ -176,10 +177,10 @@ export class ModelSelectorComponent extends Container {
// Show error in red
const errorLines = this.errorMessage.split("\n");
for (const line of errorLines) {
this.listContainer.addChild(new Text(chalk.red(line), 0, 0));
this.listContainer.addChild(new Text(theme.fg("error", line), 0, 0));
}
} else if (this.filteredModels.length === 0) {
this.listContainer.addChild(new Text(chalk.gray(" No matching models"), 0, 0));
this.listContainer.addChild(new Text(theme.fg("muted", " No matching models"), 0, 0));
}
}

View file

@ -1,6 +1,7 @@
import { Container, Spacer, Text } from "@mariozechner/pi-tui";
import chalk from "chalk";
import { getOAuthProviders, type OAuthProviderInfo } from "../oauth/index.js";
import { theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
/**
* Component that renders an OAuth provider selector
@ -24,12 +25,12 @@ export class OAuthSelectorComponent extends Container {
this.loadProviders();
// Add top border
this.addChild(new Text(chalk.blue("─".repeat(80)), 0, 0));
this.addChild(new DynamicBorder());
this.addChild(new Spacer(1));
// Add title
const title = mode === "login" ? "Select provider to login:" : "Select provider to logout:";
this.addChild(new Text(chalk.bold(title), 0, 0));
this.addChild(new Text(theme.bold(title), 0, 0));
this.addChild(new Spacer(1));
// Create list container
@ -39,7 +40,7 @@ export class OAuthSelectorComponent extends Container {
this.addChild(new Spacer(1));
// Add bottom border
this.addChild(new Text(chalk.blue("─".repeat(80)), 0, 0));
this.addChild(new DynamicBorder());
// Initial render
this.updateList();
@ -62,11 +63,11 @@ export class OAuthSelectorComponent extends Container {
let line = "";
if (isSelected) {
const prefix = chalk.blue("→ ");
const text = isAvailable ? chalk.blue(provider.name) : chalk.dim(provider.name);
const prefix = theme.fg("accent", "→ ");
const text = isAvailable ? theme.fg("accent", provider.name) : theme.fg("dim", provider.name);
line = prefix + text;
} else {
const text = isAvailable ? ` ${provider.name}` : chalk.dim(` ${provider.name}`);
const text = isAvailable ? ` ${provider.name}` : theme.fg("dim", ` ${provider.name}`);
line = text;
}
@ -77,7 +78,7 @@ export class OAuthSelectorComponent extends Container {
if (this.allProviders.length === 0) {
const message =
this.mode === "login" ? "No OAuth providers available" : "No OAuth providers logged in. Use /login first.";
this.listContainer.addChild(new Text(chalk.gray(` ${message}`), 0, 0));
this.listContainer.addChild(new Text(theme.fg("muted", ` ${message}`), 0, 0));
}
}

View file

@ -1,14 +1,6 @@
import { type Component, Container, type SelectItem, SelectList } from "@mariozechner/pi-tui";
import chalk from "chalk";
/**
* Dynamic border component that adjusts to viewport width
*/
class DynamicBorder implements Component {
render(width: number): string[] {
return [chalk.blue("─".repeat(Math.max(1, width)))];
}
}
import { Container, type SelectItem, SelectList } from "@mariozechner/pi-tui";
import { getSelectListTheme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
/**
* Component that renders a queue mode selector with borders
@ -36,7 +28,7 @@ export class QueueModeSelectorComponent extends Container {
this.addChild(new DynamicBorder());
// Create selector
this.selectList = new SelectList(queueModes, 2);
this.selectList = new SelectList(queueModes, 2, getSelectListTheme());
// Preselect current mode
const currentIndex = queueModes.findIndex((item) => item.value === currentMode);

View file

@ -1,15 +1,7 @@
import { type Component, Container, Input, Spacer, Text } from "@mariozechner/pi-tui";
import chalk from "chalk";
import type { SessionManager } from "../session-manager.js";
/**
* Dynamic border component that adjusts to viewport width
*/
class DynamicBorder implements Component {
render(width: number): string[] {
return [chalk.blue("─".repeat(Math.max(1, width)))];
}
}
import { theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
interface SessionItem {
path: string;
@ -67,6 +59,10 @@ class SessionList implements Component {
this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredSessions.length - 1));
}
invalidate(): void {
// No cached state to invalidate currently
}
render(width: number): string[] {
const lines: string[] = [];
@ -75,7 +71,7 @@ class SessionList implements Component {
lines.push(""); // Blank line after search
if (this.filteredSessions.length === 0) {
lines.push(chalk.gray(" No sessions found"));
lines.push(theme.fg("muted", " No sessions found"));
return lines;
}
@ -112,16 +108,16 @@ class SessionList implements Component {
const normalizedMessage = session.firstMessage.replace(/\n/g, " ").trim();
// First line: cursor + message
const cursor = isSelected ? chalk.blue(" ") : " ";
const cursor = isSelected ? theme.fg("accent", " ") : " ";
const maxMsgWidth = width - 2; // Account for cursor
const truncatedMsg = normalizedMessage.substring(0, maxMsgWidth);
const messageLine = cursor + (isSelected ? chalk.bold(truncatedMsg) : truncatedMsg);
const messageLine = cursor + (isSelected ? theme.bold(truncatedMsg) : truncatedMsg);
// Second line: metadata (dimmed)
const modified = formatDate(session.modified);
const msgCount = `${session.messageCount} message${session.messageCount !== 1 ? "s" : ""}`;
const metadata = ` ${modified} · ${msgCount}`;
const metadataLine = chalk.dim(metadata);
const metadataLine = theme.fg("dim", metadata);
lines.push(messageLine);
lines.push(metadataLine);
@ -130,7 +126,7 @@ class SessionList implements Component {
// Add scroll indicator if needed
if (startIndex > 0 || endIndex < this.filteredSessions.length) {
const scrollInfo = chalk.gray(` (${this.selectedIndex + 1}/${this.filteredSessions.length})`);
const scrollInfo = theme.fg("muted", ` (${this.selectedIndex + 1}/${this.filteredSessions.length})`);
lines.push(scrollInfo);
}
@ -185,7 +181,7 @@ export class SessionSelectorComponent extends Container {
// Add header
this.addChild(new Spacer(1));
this.addChild(new Text(chalk.bold("Resume Session"), 1, 0));
this.addChild(new Text(theme.bold("Resume Session"), 1, 0));
this.addChild(new Spacer(1));
this.addChild(new DynamicBorder());
this.addChild(new Spacer(1));

View file

@ -1,5 +1,5 @@
import { Container, type SelectItem, SelectList } from "@mariozechner/pi-tui";
import { getAvailableThemes, theme } from "../theme/theme.js";
import { getAvailableThemes, getSelectListTheme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
/**
@ -7,9 +7,16 @@ import { DynamicBorder } from "./dynamic-border.js";
*/
export class ThemeSelectorComponent extends Container {
private selectList: SelectList;
private onPreview: (themeName: string) => void;
constructor(currentTheme: string, onSelect: (themeName: string) => void, onCancel: () => void) {
constructor(
currentTheme: string,
onSelect: (themeName: string) => void,
onCancel: () => void,
onPreview: (themeName: string) => void,
) {
super();
this.onPreview = onPreview;
// Get available themes and create select items
const themes = getAvailableThemes();
@ -20,10 +27,10 @@ export class ThemeSelectorComponent extends Container {
}));
// Add top border
this.addChild(new DynamicBorder((text) => theme.fg("border", text)));
this.addChild(new DynamicBorder());
// Create selector
this.selectList = new SelectList(themeItems, 10);
this.selectList = new SelectList(themeItems, 10, getSelectListTheme());
// Preselect current theme
const currentIndex = themes.indexOf(currentTheme);
@ -39,10 +46,14 @@ export class ThemeSelectorComponent extends Container {
onCancel();
};
this.selectList.onSelectionChange = (item) => {
this.onPreview(item.value);
};
this.addChild(this.selectList);
// Add bottom border
this.addChild(new DynamicBorder((text) => theme.fg("border", text)));
this.addChild(new DynamicBorder());
}
getSelectList(): SelectList {

View file

@ -1,15 +1,7 @@
import type { ThinkingLevel } from "@mariozechner/pi-agent";
import { type Component, Container, type SelectItem, SelectList } from "@mariozechner/pi-tui";
import chalk from "chalk";
/**
* Dynamic border component that adjusts to viewport width
*/
class DynamicBorder implements Component {
render(width: number): string[] {
return [chalk.blue("─".repeat(Math.max(1, width)))];
}
}
import { Container, type SelectItem, SelectList } from "@mariozechner/pi-tui";
import { getSelectListTheme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
/**
* Component that renders a thinking level selector with borders
@ -32,7 +24,7 @@ export class ThinkingSelectorComponent extends Container {
this.addChild(new DynamicBorder());
// Create selector
this.selectList = new SelectList(thinkingLevels, 5);
this.selectList = new SelectList(thinkingLevels, 5, getSelectListTheme());
// Preselect current level
const currentIndex = thinkingLevels.findIndex((item) => item.value === currentLevel);

View file

@ -1,8 +1,7 @@
import * as os from "node:os";
import { Container, Spacer, Text } from "@mariozechner/pi-tui";
import chalk from "chalk";
import * as Diff from "diff";
import stripAnsi from "strip-ansi";
import { theme } from "../theme/theme.js";
/**
* Convert absolute path to tilde notation if it's in home directory
@ -22,104 +21,6 @@ function replaceTabs(text: string): string {
return text.replace(/\t/g, " ");
}
/**
* Generate a unified diff with line numbers and context
*/
function generateDiff(oldStr: string, newStr: string): string {
const parts = Diff.diffLines(oldStr, newStr);
const output: string[] = [];
// Calculate max line number for padding
const oldLines = oldStr.split("\n");
const newLines = newStr.split("\n");
const maxLineNum = Math.max(oldLines.length, newLines.length);
const lineNumWidth = String(maxLineNum).length;
const CONTEXT_LINES = 2; // Show 2 lines of context around changes
let oldLineNum = 1;
let newLineNum = 1;
let lastWasChange = false;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
const raw = part.value.split("\n");
if (raw[raw.length - 1] === "") {
raw.pop();
}
if (part.added || part.removed) {
// Show the change
for (const line of raw) {
if (part.added) {
const lineNum = String(newLineNum).padStart(lineNumWidth, " ");
output.push(chalk.green(`${lineNum} ${line}`));
newLineNum++;
} else {
// removed
const lineNum = String(oldLineNum).padStart(lineNumWidth, " ");
output.push(chalk.red(`${lineNum} ${line}`));
oldLineNum++;
}
}
lastWasChange = true;
} else {
// Context lines - only show a few before/after changes
const isFirstPart = i === 0;
const isLastPart = i === parts.length - 1;
const nextPartIsChange = i < parts.length - 1 && (parts[i + 1].added || parts[i + 1].removed);
if (lastWasChange || nextPartIsChange || isFirstPart || isLastPart) {
// Show context
let linesToShow = raw;
let skipStart = 0;
let skipEnd = 0;
if (!isFirstPart && !lastWasChange) {
// Show only last N lines as leading context
skipStart = Math.max(0, raw.length - CONTEXT_LINES);
linesToShow = raw.slice(skipStart);
}
if (!isLastPart && !nextPartIsChange && linesToShow.length > CONTEXT_LINES) {
// Show only first N lines as trailing context
skipEnd = linesToShow.length - CONTEXT_LINES;
linesToShow = linesToShow.slice(0, CONTEXT_LINES);
}
// Add ellipsis if we skipped lines at start
if (skipStart > 0) {
output.push(chalk.dim(`${"".padStart(lineNumWidth, " ")} ...`));
}
for (const line of linesToShow) {
const lineNum = String(oldLineNum).padStart(lineNumWidth, " ");
output.push(chalk.dim(`${lineNum} ${line}`));
oldLineNum++;
newLineNum++;
}
// Add ellipsis if we skipped lines at end
if (skipEnd > 0) {
output.push(chalk.dim(`${"".padStart(lineNumWidth, " ")} ...`));
}
// Update line numbers for skipped lines
oldLineNum += skipStart + skipEnd;
newLineNum += skipStart + skipEnd;
} else {
// Skip these context lines entirely
oldLineNum += raw.length;
newLineNum += raw.length;
}
lastWasChange = false;
}
}
return output.join("\n");
}
/**
* Component that renders a tool call with its result (updateable)
*/
@ -140,7 +41,7 @@ export class ToolExecutionComponent extends Container {
this.args = args;
this.addChild(new Spacer(1));
// Content with colored background and padding
this.contentText = new Text("", 1, 1, { r: 40, g: 40, b: 50 });
this.contentText = new Text("", 1, 1, (text: string) => theme.bg("toolPendingBg", text));
this.addChild(this.contentText);
this.updateDisplay();
}
@ -165,13 +66,13 @@ export class ToolExecutionComponent extends Container {
}
private updateDisplay(): void {
const bgColor = this.result
const bgFn = this.result
? this.result.isError
? { r: 60, g: 40, b: 40 }
: { r: 40, g: 50, b: 40 }
: { r: 40, g: 40, b: 50 };
? (text: string) => theme.bg("toolErrorBg", text)
: (text: string) => theme.bg("toolSuccessBg", text)
: (text: string) => theme.bg("toolPendingBg", text);
this.contentText.setCustomBgRgb(bgColor);
this.contentText.setCustomBgFn(bgFn);
this.contentText.setText(this.formatToolExecution());
}
@ -200,7 +101,7 @@ export class ToolExecutionComponent extends Container {
// Format based on tool type
if (this.toolName === "bash") {
const command = this.args?.command || "";
text = chalk.bold(`$ ${command || chalk.dim("...")}`);
text = theme.fg("toolTitle", theme.bold(`$ ${command || theme.fg("toolOutput", "...")}`));
if (this.result) {
// Show output without code fences - more minimal
@ -211,9 +112,9 @@ export class ToolExecutionComponent extends Container {
const displayLines = lines.slice(0, maxLines);
const remaining = lines.length - maxLines;
text += "\n\n" + displayLines.map((line: string) => chalk.dim(line)).join("\n");
text += "\n\n" + displayLines.map((line: string) => theme.fg("toolOutput", line)).join("\n");
if (remaining > 0) {
text += chalk.dim(`\n... (${remaining} more lines)`);
text += theme.fg("toolOutput", `\n... (${remaining} more lines)`);
}
}
}
@ -223,13 +124,13 @@ export class ToolExecutionComponent extends Container {
const limit = this.args?.limit;
// Build path display with offset/limit suffix
let pathDisplay = path ? chalk.cyan(path) : chalk.dim("...");
let pathDisplay = path ? theme.fg("accent", path) : theme.fg("toolOutput", "...");
if (offset !== undefined) {
const endLine = limit !== undefined ? offset + limit : "";
pathDisplay += chalk.dim(`:${offset}${endLine ? `-${endLine}` : ""}`);
pathDisplay += theme.fg("toolOutput", `:${offset}${endLine ? `-${endLine}` : ""}`);
}
text = chalk.bold("read") + " " + pathDisplay;
text = theme.fg("toolTitle", theme.bold("read")) + " " + pathDisplay;
if (this.result) {
const output = this.getTextOutput();
@ -238,9 +139,9 @@ export class ToolExecutionComponent extends Container {
const displayLines = lines.slice(0, maxLines);
const remaining = lines.length - maxLines;
text += "\n\n" + displayLines.map((line: string) => chalk.dim(replaceTabs(line))).join("\n");
text += "\n\n" + displayLines.map((line: string) => theme.fg("toolOutput", replaceTabs(line))).join("\n");
if (remaining > 0) {
text += chalk.dim(`\n... (${remaining} more lines)`);
text += theme.fg("toolOutput", `\n... (${remaining} more lines)`);
}
}
} else if (this.toolName === "write") {
@ -249,7 +150,10 @@ export class ToolExecutionComponent extends Container {
const lines = fileContent ? fileContent.split("\n") : [];
const totalLines = lines.length;
text = chalk.bold("write") + " " + (path ? chalk.cyan(path) : chalk.dim("..."));
text =
theme.fg("toolTitle", theme.bold("write")) +
" " +
(path ? theme.fg("accent", path) : theme.fg("toolOutput", "..."));
if (totalLines > 10) {
text += ` (${totalLines} lines)`;
}
@ -260,32 +164,35 @@ export class ToolExecutionComponent extends Container {
const displayLines = lines.slice(0, maxLines);
const remaining = lines.length - maxLines;
text += "\n\n" + displayLines.map((line: string) => chalk.dim(replaceTabs(line))).join("\n");
text += "\n\n" + displayLines.map((line: string) => theme.fg("toolOutput", replaceTabs(line))).join("\n");
if (remaining > 0) {
text += chalk.dim(`\n... (${remaining} more lines)`);
text += theme.fg("toolOutput", `\n... (${remaining} more lines)`);
}
}
} else if (this.toolName === "edit") {
const path = shortenPath(this.args?.file_path || this.args?.path || "");
text = chalk.bold("edit") + " " + (path ? chalk.cyan(path) : chalk.dim("..."));
text =
theme.fg("toolTitle", theme.bold("edit")) +
" " +
(path ? theme.fg("accent", path) : theme.fg("toolOutput", "..."));
if (this.result) {
// Show error message if it's an error
if (this.result.isError) {
const errorText = this.getTextOutput();
if (errorText) {
text += "\n\n" + chalk.red(errorText);
text += "\n\n" + theme.fg("error", errorText);
}
} else if (this.result.details?.diff) {
// Show diff if available
const diffLines = this.result.details.diff.split("\n");
const coloredLines = diffLines.map((line: string) => {
if (line.startsWith("+")) {
return chalk.green(line);
return theme.fg("toolDiffAdded", line);
} else if (line.startsWith("-")) {
return chalk.red(line);
return theme.fg("toolDiffRemoved", line);
} else {
return chalk.dim(line);
return theme.fg("toolDiffContext", line);
}
});
text += "\n\n" + coloredLines.join("\n");
@ -293,7 +200,7 @@ export class ToolExecutionComponent extends Container {
}
} else {
// Generic tool
text = chalk.bold(this.toolName);
text = theme.fg("toolTitle", theme.bold(this.toolName));
const content = JSON.stringify(this.args, null, 2);
text += "\n\n" + content;

View file

@ -13,7 +13,7 @@ import {
TruncatedText,
TUI,
} from "@mariozechner/pi-tui";
import chalk from "chalk";
import { exec } from "child_process";
import { getChangelogPath, parseChangelog } from "../changelog.js";
import { exportSessionToHtml } from "../export-html.js";
@ -21,7 +21,7 @@ import { getApiKeyForModel, getAvailableModels } from "../model-config.js";
import { listOAuthProviders, login, logout } from "../oauth/index.js";
import type { SessionManager } from "../session-manager.js";
import type { SettingsManager } from "../settings-manager.js";
import { setTheme } from "../theme/theme.js";
import { getEditorTheme, getMarkdownTheme, onThemeChange, setTheme, theme } from "../theme/theme.js";
import { AssistantMessageComponent } from "./assistant-message.js";
import { CustomEditor } from "./custom-editor.js";
import { DynamicBorder } from "./dynamic-border.js";
@ -114,7 +114,7 @@ export class TuiRenderer {
this.chatContainer = new Container();
this.pendingMessagesContainer = new Container();
this.statusContainer = new Container();
this.editor = new CustomEditor();
this.editor = new CustomEditor(getEditorTheme());
this.editorContainer = new Container(); // Container to hold editor or selector
this.editorContainer.addChild(this.editor); // Start with editor
this.footer = new FooterComponent(agent.state);
@ -193,34 +193,34 @@ export class TuiRenderer {
if (this.isInitialized) return;
// Add header with logo and instructions
const logo = chalk.bold.cyan("pi") + chalk.dim(` v${this.version}`);
const logo = theme.bold(theme.fg("accent", "pi")) + theme.fg("dim", ` v${this.version}`);
const instructions =
chalk.dim("esc") +
chalk.gray(" to interrupt") +
theme.fg("dim", "esc") +
theme.fg("muted", " to interrupt") +
"\n" +
chalk.dim("ctrl+c") +
chalk.gray(" to clear") +
theme.fg("dim", "ctrl+c") +
theme.fg("muted", " to clear") +
"\n" +
chalk.dim("ctrl+c twice") +
chalk.gray(" to exit") +
theme.fg("dim", "ctrl+c twice") +
theme.fg("muted", " to exit") +
"\n" +
chalk.dim("ctrl+k") +
chalk.gray(" to delete line") +
theme.fg("dim", "ctrl+k") +
theme.fg("muted", " to delete line") +
"\n" +
chalk.dim("shift+tab") +
chalk.gray(" to cycle thinking") +
theme.fg("dim", "shift+tab") +
theme.fg("muted", " to cycle thinking") +
"\n" +
chalk.dim("ctrl+p") +
chalk.gray(" to cycle models") +
theme.fg("dim", "ctrl+p") +
theme.fg("muted", " to cycle models") +
"\n" +
chalk.dim("ctrl+o") +
chalk.gray(" to expand tools") +
theme.fg("dim", "ctrl+o") +
theme.fg("muted", " to expand tools") +
"\n" +
chalk.dim("/") +
chalk.gray(" for commands") +
theme.fg("dim", "/") +
theme.fg("muted", " for commands") +
"\n" +
chalk.dim("drop files") +
chalk.gray(" to attach");
theme.fg("dim", "drop files") +
theme.fg("muted", " to attach");
const header = new Text(logo + "\n" + instructions, 1, 0);
// Setup UI layout
@ -230,28 +230,28 @@ export class TuiRenderer {
// Add new version notification if available
if (this.newVersion) {
this.ui.addChild(new DynamicBorder(chalk.yellow));
this.ui.addChild(new DynamicBorder((text) => theme.fg("warning", text)));
this.ui.addChild(
new Text(
chalk.bold.yellow("Update Available") +
theme.bold(theme.fg("warning", "Update Available")) +
"\n" +
chalk.gray(`New version ${this.newVersion} is available. Run: `) +
chalk.cyan("npm install -g @mariozechner/pi-coding-agent"),
theme.fg("muted", `New version ${this.newVersion} is available. Run: `) +
theme.fg("accent", "npm install -g @mariozechner/pi-coding-agent"),
1,
0,
),
);
this.ui.addChild(new DynamicBorder(chalk.yellow));
this.ui.addChild(new DynamicBorder((text) => theme.fg("warning", text)));
}
// Add changelog if provided
if (this.changelogMarkdown) {
this.ui.addChild(new DynamicBorder(chalk.cyan));
this.ui.addChild(new Text(chalk.bold.cyan("What's New"), 1, 0));
this.ui.addChild(new DynamicBorder());
this.ui.addChild(new Text(theme.bold(theme.fg("accent", "What's New")), 1, 0));
this.ui.addChild(new Spacer(1));
this.ui.addChild(new Markdown(this.changelogMarkdown.trim(), 1, 0));
this.ui.addChild(new Markdown(this.changelogMarkdown.trim(), 1, 0, getMarkdownTheme()));
this.ui.addChild(new Spacer(1));
this.ui.addChild(new DynamicBorder(chalk.cyan));
this.ui.addChild(new DynamicBorder());
}
this.ui.addChild(this.chatContainer);
@ -435,6 +435,13 @@ export class TuiRenderer {
// Start the UI
this.ui.start();
this.isInitialized = true;
// Set up theme file watcher for live reload
onThemeChange(() => {
this.ui.invalidate();
this.updateEditorBorderColor();
this.ui.requestRender();
});
}
async handleEvent(event: AgentEvent, state: AgentState): Promise<void> {
@ -454,7 +461,12 @@ export class TuiRenderer {
this.loadingAnimation.stop();
}
this.statusContainer.clear();
this.loadingAnimation = new Loader(this.ui, "Working... (esc to interrupt)");
this.loadingAnimation = new Loader(
this.ui,
(spinner) => theme.fg("accent", spinner),
(text) => theme.fg("muted", text),
"Working... (esc to interrupt)",
);
this.statusContainer.addChild(this.loadingAnimation);
this.ui.requestRender();
break;
@ -718,28 +730,9 @@ export class TuiRenderer {
}
}
private getThinkingBorderColor(level: ThinkingLevel): (str: string) => string {
// More thinking = more color (gray → dim colors → bright colors)
switch (level) {
case "off":
return chalk.gray;
case "minimal":
return chalk.dim.blue;
case "low":
return chalk.blue;
case "medium":
return chalk.cyan;
case "high":
return chalk.magenta;
default:
return chalk.gray;
}
}
private updateEditorBorderColor(): void {
const level = this.agent.state.thinkingLevel || "off";
const color = this.getThinkingBorderColor(level);
this.editor.borderColor = color;
this.editor.borderColor = theme.getThinkingBorderColor(level);
this.ui.requestRender();
}
@ -747,7 +740,7 @@ export class TuiRenderer {
// Only cycle if model supports thinking
if (!this.agent.state.model?.reasoning) {
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new Text(chalk.dim("Current model does not support thinking"), 1, 0));
this.chatContainer.addChild(new Text(theme.fg("dim", "Current model does not support thinking"), 1, 0));
this.ui.requestRender();
return;
}
@ -769,7 +762,7 @@ export class TuiRenderer {
// Show brief notification
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new Text(chalk.dim(`Thinking level: ${nextLevel}`), 1, 0));
this.chatContainer.addChild(new Text(theme.fg("dim", `Thinking level: ${nextLevel}`), 1, 0));
this.ui.requestRender();
}
@ -794,7 +787,7 @@ export class TuiRenderer {
if (modelsToUse.length === 1) {
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new Text(chalk.dim("Only one model in scope"), 1, 0));
this.chatContainer.addChild(new Text(theme.fg("dim", "Only one model in scope"), 1, 0));
this.ui.requestRender();
return;
}
@ -824,7 +817,7 @@ export class TuiRenderer {
// Show notification
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new Text(chalk.dim(`Switched to ${nextModel.name || nextModel.id}`), 1, 0));
this.chatContainer.addChild(new Text(theme.fg("dim", `Switched to ${nextModel.name || nextModel.id}`), 1, 0));
this.ui.requestRender();
}
@ -849,14 +842,14 @@ export class TuiRenderer {
showError(errorMessage: string): void {
// Show error message in the chat
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new Text(chalk.red(`Error: ${errorMessage}`), 1, 0));
this.chatContainer.addChild(new Text(theme.fg("error", `Error: ${errorMessage}`), 1, 0));
this.ui.requestRender();
}
showWarning(warningMessage: string): void {
// Show warning message in the chat
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new Text(chalk.yellow(`Warning: ${warningMessage}`), 1, 0));
this.chatContainer.addChild(new Text(theme.fg("warning", `Warning: ${warningMessage}`), 1, 0));
this.ui.requestRender();
}
@ -876,7 +869,7 @@ export class TuiRenderer {
// Show confirmation message with proper spacing
this.chatContainer.addChild(new Spacer(1));
const confirmText = new Text(chalk.dim(`Thinking level: ${level}`), 1, 0);
const confirmText = new Text(theme.fg("dim", `Thinking level: ${level}`), 1, 0);
this.chatContainer.addChild(confirmText);
// Hide selector and show editor again
@ -918,7 +911,7 @@ export class TuiRenderer {
// Show confirmation message with proper spacing
this.chatContainer.addChild(new Spacer(1));
const confirmText = new Text(chalk.dim(`Queue mode: ${mode}`), 1, 0);
const confirmText = new Text(theme.fg("dim", `Queue mode: ${mode}`), 1, 0);
this.chatContainer.addChild(confirmText);
// Hide selector and show editor again
@ -956,15 +949,27 @@ export class TuiRenderer {
currentTheme,
(themeName) => {
// Apply the selected theme
setTheme(themeName);
const result = setTheme(themeName);
// Save theme to settings
this.settingsManager.setTheme(themeName);
// Show confirmation message with proper spacing
// Invalidate all components to clear cached rendering
this.ui.invalidate();
// Show confirmation or error message
this.chatContainer.addChild(new Spacer(1));
const confirmText = new Text(chalk.dim(`Theme: ${themeName}`), 1, 0);
this.chatContainer.addChild(confirmText);
if (result.success) {
const confirmText = new Text(theme.fg("dim", `Theme: ${themeName}`), 1, 0);
this.chatContainer.addChild(confirmText);
} else {
const errorText = new Text(
theme.fg("error", `Failed to load theme "${themeName}": ${result.error}\nFell back to dark theme.`),
1,
0,
);
this.chatContainer.addChild(errorText);
}
// Hide selector and show editor again
this.hideThemeSelector();
@ -975,6 +980,15 @@ export class TuiRenderer {
this.hideThemeSelector();
this.ui.requestRender();
},
(themeName) => {
// Preview theme on selection change
const result = setTheme(themeName);
if (result.success) {
this.ui.invalidate();
this.ui.requestRender();
}
// If failed, theme already fell back to dark, just don't re-render
},
);
// Replace editor with selector
@ -1007,7 +1021,7 @@ export class TuiRenderer {
// Show confirmation message with proper spacing
this.chatContainer.addChild(new Spacer(1));
const confirmText = new Text(chalk.dim(`Model: ${model.id}`), 1, 0);
const confirmText = new Text(theme.fg("dim", `Model: ${model.id}`), 1, 0);
this.chatContainer.addChild(confirmText);
// Hide selector and show editor again
@ -1055,7 +1069,7 @@ export class TuiRenderer {
// Don't show selector if there are no messages or only one message
if (userMessages.length <= 1) {
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new Text(chalk.dim("No messages to branch from"), 1, 0));
this.chatContainer.addChild(new Text(theme.fg("dim", "No messages to branch from"), 1, 0));
this.ui.requestRender();
return;
}
@ -1088,7 +1102,7 @@ export class TuiRenderer {
// Show confirmation message
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(
new Text(chalk.dim(`Branched to new session from message ${messageIndex}`), 1, 0),
new Text(theme.fg("dim", `Branched to new session from message ${messageIndex}`), 1, 0),
);
// Put the selected message in the editor
@ -1127,7 +1141,9 @@ export class TuiRenderer {
const loggedInProviders = listOAuthProviders();
if (loggedInProviders.length === 0) {
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new Text(chalk.dim("No OAuth providers logged in. Use /login first."), 1, 0));
this.chatContainer.addChild(
new Text(theme.fg("dim", "No OAuth providers logged in. Use /login first."), 1, 0),
);
this.ui.requestRender();
return;
}
@ -1144,7 +1160,7 @@ export class TuiRenderer {
if (mode === "login") {
// Handle login
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new Text(chalk.dim(`Logging in to ${providerId}...`), 1, 0));
this.chatContainer.addChild(new Text(theme.fg("dim", `Logging in to ${providerId}...`), 1, 0));
this.ui.requestRender();
try {
@ -1153,11 +1169,11 @@ export class TuiRenderer {
(url: string) => {
// Show auth URL to user
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new Text(chalk.cyan("Opening browser to:"), 1, 0));
this.chatContainer.addChild(new Text(chalk.cyan(url), 1, 0));
this.chatContainer.addChild(new Text(theme.fg("accent", "Opening browser to:"), 1, 0));
this.chatContainer.addChild(new Text(theme.fg("accent", url), 1, 0));
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(
new Text(chalk.yellow("Paste the authorization code below:"), 1, 0),
new Text(theme.fg("warning", "Paste the authorization code below:"), 1, 0),
);
this.ui.requestRender();
@ -1189,8 +1205,12 @@ export class TuiRenderer {
// Success
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new Text(chalk.green(`✓ Successfully logged in to ${providerId}`), 1, 0));
this.chatContainer.addChild(new Text(chalk.dim(`Tokens saved to ~/.pi/agent/oauth.json`), 1, 0));
this.chatContainer.addChild(
new Text(theme.fg("success", `✓ Successfully logged in to ${providerId}`), 1, 0),
);
this.chatContainer.addChild(
new Text(theme.fg("dim", `Tokens saved to ~/.pi/agent/oauth.json`), 1, 0),
);
this.ui.requestRender();
} catch (error: any) {
this.showError(`Login failed: ${error.message}`);
@ -1202,10 +1222,10 @@ export class TuiRenderer {
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(
new Text(chalk.green(`✓ Successfully logged out of ${providerId}`), 1, 0),
new Text(theme.fg("success", `✓ Successfully logged out of ${providerId}`), 1, 0),
);
this.chatContainer.addChild(
new Text(chalk.dim(`Credentials removed from ~/.pi/agent/oauth.json`), 1, 0),
new Text(theme.fg("dim", `Credentials removed from ~/.pi/agent/oauth.json`), 1, 0),
);
this.ui.requestRender();
} catch (error: any) {
@ -1246,13 +1266,13 @@ export class TuiRenderer {
// Show success message in chat - matching thinking level style
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new Text(chalk.dim(`Session exported to: ${filePath}`), 1, 0));
this.chatContainer.addChild(new Text(theme.fg("dim", `Session exported to: ${filePath}`), 1, 0));
this.ui.requestRender();
} catch (error: any) {
// Show error message in chat
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(
new Text(chalk.red(`Failed to export session: ${error.message || "Unknown error"}`), 1, 0),
new Text(theme.fg("error", `Failed to export session: ${error.message || "Unknown error"}`), 1, 0),
);
this.ui.requestRender();
}
@ -1299,29 +1319,29 @@ export class TuiRenderer {
const totalTokens = totalInput + totalOutput + totalCacheRead + totalCacheWrite;
// Build info text
let info = `${chalk.bold("Session Info")}\n\n`;
info += `${chalk.dim("File:")} ${sessionFile}\n`;
info += `${chalk.dim("ID:")} ${this.sessionManager.getSessionId()}\n\n`;
info += `${chalk.bold("Messages")}\n`;
info += `${chalk.dim("User:")} ${userMessages}\n`;
info += `${chalk.dim("Assistant:")} ${assistantMessages}\n`;
info += `${chalk.dim("Tool Calls:")} ${toolCalls}\n`;
info += `${chalk.dim("Tool Results:")} ${toolResults}\n`;
info += `${chalk.dim("Total:")} ${totalMessages}\n\n`;
info += `${chalk.bold("Tokens")}\n`;
info += `${chalk.dim("Input:")} ${totalInput.toLocaleString()}\n`;
info += `${chalk.dim("Output:")} ${totalOutput.toLocaleString()}\n`;
let info = `${theme.bold("Session Info")}\n\n`;
info += `${theme.fg("dim", "File:")} ${sessionFile}\n`;
info += `${theme.fg("dim", "ID:")} ${this.sessionManager.getSessionId()}\n\n`;
info += `${theme.bold("Messages")}\n`;
info += `${theme.fg("dim", "User:")} ${userMessages}\n`;
info += `${theme.fg("dim", "Assistant:")} ${assistantMessages}\n`;
info += `${theme.fg("dim", "Tool Calls:")} ${toolCalls}\n`;
info += `${theme.fg("dim", "Tool Results:")} ${toolResults}\n`;
info += `${theme.fg("dim", "Total:")} ${totalMessages}\n\n`;
info += `${theme.bold("Tokens")}\n`;
info += `${theme.fg("dim", "Input:")} ${totalInput.toLocaleString()}\n`;
info += `${theme.fg("dim", "Output:")} ${totalOutput.toLocaleString()}\n`;
if (totalCacheRead > 0) {
info += `${chalk.dim("Cache Read:")} ${totalCacheRead.toLocaleString()}\n`;
info += `${theme.fg("dim", "Cache Read:")} ${totalCacheRead.toLocaleString()}\n`;
}
if (totalCacheWrite > 0) {
info += `${chalk.dim("Cache Write:")} ${totalCacheWrite.toLocaleString()}\n`;
info += `${theme.fg("dim", "Cache Write:")} ${totalCacheWrite.toLocaleString()}\n`;
}
info += `${chalk.dim("Total:")} ${totalTokens.toLocaleString()}\n`;
info += `${theme.fg("dim", "Total:")} ${totalTokens.toLocaleString()}\n`;
if (totalCost > 0) {
info += `\n${chalk.bold("Cost")}\n`;
info += `${chalk.dim("Total:")} ${totalCost.toFixed(4)}`;
info += `\n${theme.bold("Cost")}\n`;
info += `${theme.fg("dim", "Total:")} ${totalCost.toFixed(4)}`;
}
// Show info in chat
@ -1345,11 +1365,11 @@ export class TuiRenderer {
// Display in chat
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new DynamicBorder(chalk.cyan));
this.ui.addChild(new Text(chalk.bold.cyan("What's New"), 1, 0));
this.chatContainer.addChild(new DynamicBorder());
this.ui.addChild(new Text(theme.bold(theme.fg("accent", "What's New")), 1, 0));
this.ui.addChild(new Spacer(1));
this.chatContainer.addChild(new Markdown(changelogMarkdown, 1, 1));
this.chatContainer.addChild(new DynamicBorder(chalk.cyan));
this.chatContainer.addChild(new Markdown(changelogMarkdown, 1, 1, getMarkdownTheme()));
this.chatContainer.addChild(new DynamicBorder());
this.ui.requestRender();
}
@ -1360,7 +1380,7 @@ export class TuiRenderer {
this.pendingMessagesContainer.addChild(new Spacer(1));
for (const message of this.queuedMessages) {
const queuedText = chalk.dim("Queued: " + message);
const queuedText = theme.fg("dim", "Queued: " + message);
this.pendingMessagesContainer.addChild(new TruncatedText(queuedText, 1, 0));
}
}

View file

@ -1,20 +1,6 @@
import { type Component, Container, Spacer, Text } from "@mariozechner/pi-tui";
import chalk from "chalk";
/**
* Dynamic border component that adjusts to viewport width
*/
class DynamicBorder implements Component {
private colorFn: (text: string) => string;
constructor(colorFn: (text: string) => string = chalk.blue) {
this.colorFn = colorFn;
}
render(width: number): string[] {
return [this.colorFn("─".repeat(Math.max(1, width)))];
}
}
import { theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
interface UserMessageItem {
index: number; // Index in the full messages array
@ -39,11 +25,15 @@ class UserMessageList implements Component {
this.selectedIndex = Math.max(0, messages.length - 1);
}
invalidate(): void {
// No cached state to invalidate currently
}
render(width: number): string[] {
const lines: string[] = [];
if (this.messages.length === 0) {
lines.push(chalk.gray(" No user messages found"));
lines.push(theme.fg("muted", " No user messages found"));
return lines;
}
@ -63,24 +53,24 @@ class UserMessageList implements Component {
const normalizedMessage = message.text.replace(/\n/g, " ").trim();
// First line: cursor + message
const cursor = isSelected ? chalk.blue(" ") : " ";
const cursor = isSelected ? theme.fg("accent", " ") : " ";
const maxMsgWidth = width - 2; // Account for cursor
const truncatedMsg = normalizedMessage.substring(0, maxMsgWidth);
const messageLine = cursor + (isSelected ? chalk.bold(truncatedMsg) : truncatedMsg);
const messageLine = cursor + (isSelected ? theme.bold(truncatedMsg) : truncatedMsg);
lines.push(messageLine);
// Second line: metadata (position in history)
const position = i + 1;
const metadata = ` Message ${position} of ${this.messages.length}`;
const metadataLine = chalk.dim(metadata);
const metadataLine = theme.fg("muted", metadata);
lines.push(metadataLine);
lines.push(""); // Blank line between messages
}
// Add scroll indicator if needed
if (startIndex > 0 || endIndex < this.messages.length) {
const scrollInfo = chalk.gray(` (${this.selectedIndex + 1}/${this.messages.length})`);
const scrollInfo = theme.fg("muted", ` (${this.selectedIndex + 1}/${this.messages.length})`);
lines.push(scrollInfo);
}
@ -129,8 +119,8 @@ export class UserMessageSelectorComponent extends Container {
// Add header
this.addChild(new Spacer(1));
this.addChild(new Text(chalk.bold("Branch from Message"), 1, 0));
this.addChild(new Text(chalk.dim("Select a message to create a new branch from that point"), 1, 0));
this.addChild(new Text(theme.bold("Branch from Message"), 1, 0));
this.addChild(new Text(theme.fg("muted", "Select a message to create a new branch from that point"), 1, 0));
this.addChild(new Spacer(1));
this.addChild(new DynamicBorder());
this.addChild(new Spacer(1));

View file

@ -1,4 +1,5 @@
import { Container, Markdown, Spacer } from "@mariozechner/pi-tui";
import { getMarkdownTheme, theme } from "../theme/theme.js";
/**
* Component that renders a user message
@ -11,6 +12,11 @@ export class UserMessageComponent extends Container {
if (!isFirst) {
this.addChild(new Spacer(1));
}
this.addChild(new Markdown(text, 1, 1, { bgColor: "#343541" }));
this.addChild(
new Markdown(text, 1, 1, getMarkdownTheme(), {
bgColor: (text: string) => theme.bg("userMessageBg", text),
color: (text: string) => theme.fg("userMessageText", text),
}),
);
}
}