mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-16 06:02:42 +00:00
Reorganize file structure: core/, utils/, modes/interactive/components/, modes/interactive/theme/
This commit is contained in:
parent
00982705f2
commit
83a6c26969
56 changed files with 133 additions and 128 deletions
|
|
@ -0,0 +1,87 @@
|
|||
import type { AssistantMessage } from "@mariozechner/pi-ai";
|
||||
import { Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
|
||||
import { getMarkdownTheme, theme } from "../theme/theme.js";
|
||||
|
||||
/**
|
||||
* Component that renders a complete assistant message
|
||||
*/
|
||||
export class AssistantMessageComponent extends Container {
|
||||
private contentContainer: Container;
|
||||
private hideThinkingBlock: boolean;
|
||||
|
||||
constructor(message?: AssistantMessage, hideThinkingBlock = false) {
|
||||
super();
|
||||
|
||||
this.hideThinkingBlock = hideThinkingBlock;
|
||||
|
||||
// Container for text/thinking content
|
||||
this.contentContainer = new Container();
|
||||
this.addChild(this.contentContainer);
|
||||
|
||||
if (message) {
|
||||
this.updateContent(message);
|
||||
}
|
||||
}
|
||||
|
||||
setHideThinkingBlock(hide: boolean): void {
|
||||
this.hideThinkingBlock = hide;
|
||||
}
|
||||
|
||||
updateContent(message: AssistantMessage): void {
|
||||
// Clear content container
|
||||
this.contentContainer.clear();
|
||||
|
||||
if (
|
||||
message.content.length > 0 &&
|
||||
message.content.some(
|
||||
(c) => (c.type === "text" && c.text.trim()) || (c.type === "thinking" && c.thinking.trim()),
|
||||
)
|
||||
) {
|
||||
this.contentContainer.addChild(new Spacer(1));
|
||||
}
|
||||
|
||||
// Render content in order
|
||||
for (let i = 0; i < message.content.length; i++) {
|
||||
const content = message.content[i];
|
||||
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, getMarkdownTheme()));
|
||||
} else if (content.type === "thinking" && content.thinking.trim()) {
|
||||
// Check if there's text content after this thinking block
|
||||
const hasTextAfter = message.content.slice(i + 1).some((c) => c.type === "text" && c.text.trim());
|
||||
|
||||
if (this.hideThinkingBlock) {
|
||||
// Show static "Thinking..." label when hidden
|
||||
this.contentContainer.addChild(new Text(theme.fg("muted", "Thinking..."), 1, 0));
|
||||
if (hasTextAfter) {
|
||||
this.contentContainer.addChild(new Spacer(1));
|
||||
}
|
||||
} else {
|
||||
// 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, getMarkdownTheme(), {
|
||||
color: (text: string) => theme.fg("muted", text),
|
||||
italic: true,
|
||||
}),
|
||||
);
|
||||
this.contentContainer.addChild(new Spacer(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if aborted - show after partial content
|
||||
// But only if there are no tool calls (tool execution components will show the error)
|
||||
const hasToolCalls = message.content.some((c) => c.type === "toolCall");
|
||||
if (!hasToolCalls) {
|
||||
if (message.stopReason === "aborted") {
|
||||
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(theme.fg("error", `Error: ${errorMsg}`), 1, 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,177 @@
|
|||
/**
|
||||
* Component for displaying bash command execution with streaming output.
|
||||
*/
|
||||
|
||||
import { Container, Loader, Spacer, Text, type TUI } from "@mariozechner/pi-tui";
|
||||
import stripAnsi from "strip-ansi";
|
||||
import {
|
||||
DEFAULT_MAX_BYTES,
|
||||
DEFAULT_MAX_LINES,
|
||||
type TruncationResult,
|
||||
truncateTail,
|
||||
} from "../../../core/tools/truncate.js";
|
||||
import { theme } from "../theme/theme.js";
|
||||
import { DynamicBorder } from "./dynamic-border.js";
|
||||
|
||||
// Preview line limit when not expanded (matches tool execution behavior)
|
||||
const PREVIEW_LINES = 20;
|
||||
|
||||
export class BashExecutionComponent extends Container {
|
||||
private command: string;
|
||||
private outputLines: string[] = [];
|
||||
private status: "running" | "complete" | "cancelled" | "error" = "running";
|
||||
private exitCode: number | null = null;
|
||||
private loader: Loader;
|
||||
private truncationResult?: TruncationResult;
|
||||
private fullOutputPath?: string;
|
||||
private expanded = false;
|
||||
private contentContainer: Container;
|
||||
|
||||
constructor(command: string, ui: TUI) {
|
||||
super();
|
||||
this.command = command;
|
||||
|
||||
const borderColor = (str: string) => theme.fg("bashMode", str);
|
||||
|
||||
// Add spacer
|
||||
this.addChild(new Spacer(1));
|
||||
|
||||
// Top border
|
||||
this.addChild(new DynamicBorder(borderColor));
|
||||
|
||||
// Content container (holds dynamic content between borders)
|
||||
this.contentContainer = new Container();
|
||||
this.addChild(this.contentContainer);
|
||||
|
||||
// Command header
|
||||
const header = new Text(theme.fg("bashMode", theme.bold(`$ ${command}`)), 1, 0);
|
||||
this.contentContainer.addChild(header);
|
||||
|
||||
// Loader
|
||||
this.loader = new Loader(
|
||||
ui,
|
||||
(spinner) => theme.fg("bashMode", spinner),
|
||||
(text) => theme.fg("muted", text),
|
||||
"Running... (esc to cancel)",
|
||||
);
|
||||
this.contentContainer.addChild(this.loader);
|
||||
|
||||
// Bottom border
|
||||
this.addChild(new DynamicBorder(borderColor));
|
||||
}
|
||||
|
||||
/**
|
||||
* Set whether the output is expanded (shows full output) or collapsed (preview only).
|
||||
*/
|
||||
setExpanded(expanded: boolean): void {
|
||||
this.expanded = expanded;
|
||||
this.updateDisplay();
|
||||
}
|
||||
|
||||
appendOutput(chunk: string): void {
|
||||
// Strip ANSI codes and normalize line endings
|
||||
// Note: binary data is already sanitized in tui-renderer.ts executeBashCommand
|
||||
const clean = stripAnsi(chunk).replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
|
||||
// Append to output lines
|
||||
const newLines = clean.split("\n");
|
||||
if (this.outputLines.length > 0 && newLines.length > 0) {
|
||||
// Append first chunk to last line (incomplete line continuation)
|
||||
this.outputLines[this.outputLines.length - 1] += newLines[0];
|
||||
this.outputLines.push(...newLines.slice(1));
|
||||
} else {
|
||||
this.outputLines.push(...newLines);
|
||||
}
|
||||
|
||||
this.updateDisplay();
|
||||
}
|
||||
|
||||
setComplete(
|
||||
exitCode: number | null,
|
||||
cancelled: boolean,
|
||||
truncationResult?: TruncationResult,
|
||||
fullOutputPath?: string,
|
||||
): void {
|
||||
this.exitCode = exitCode;
|
||||
this.status = cancelled ? "cancelled" : exitCode !== 0 && exitCode !== null ? "error" : "complete";
|
||||
this.truncationResult = truncationResult;
|
||||
this.fullOutputPath = fullOutputPath;
|
||||
|
||||
// Stop loader
|
||||
this.loader.stop();
|
||||
|
||||
this.updateDisplay();
|
||||
}
|
||||
|
||||
private updateDisplay(): void {
|
||||
// Apply truncation for LLM context limits (same limits as bash tool)
|
||||
const fullOutput = this.outputLines.join("\n");
|
||||
const contextTruncation = truncateTail(fullOutput, {
|
||||
maxLines: DEFAULT_MAX_LINES,
|
||||
maxBytes: DEFAULT_MAX_BYTES,
|
||||
});
|
||||
|
||||
// Get the lines to potentially display (after context truncation)
|
||||
const availableLines = contextTruncation.content ? contextTruncation.content.split("\n") : [];
|
||||
|
||||
// Apply preview truncation based on expanded state
|
||||
const maxDisplayLines = this.expanded ? availableLines.length : PREVIEW_LINES;
|
||||
const displayLines = availableLines.slice(-maxDisplayLines); // Show last N lines (tail)
|
||||
const hiddenLineCount = availableLines.length - displayLines.length;
|
||||
|
||||
// Rebuild content container
|
||||
this.contentContainer.clear();
|
||||
|
||||
// Command header
|
||||
const header = new Text(theme.fg("bashMode", theme.bold(`$ ${this.command}`)), 1, 0);
|
||||
this.contentContainer.addChild(header);
|
||||
|
||||
// Output
|
||||
if (displayLines.length > 0) {
|
||||
const displayText = displayLines.map((line) => theme.fg("muted", line)).join("\n");
|
||||
this.contentContainer.addChild(new Text("\n" + displayText, 1, 0));
|
||||
}
|
||||
|
||||
// Loader or status
|
||||
if (this.status === "running") {
|
||||
this.contentContainer.addChild(this.loader);
|
||||
} else {
|
||||
const statusParts: string[] = [];
|
||||
|
||||
// Show how many lines are hidden (collapsed preview)
|
||||
if (hiddenLineCount > 0) {
|
||||
statusParts.push(theme.fg("dim", `... ${hiddenLineCount} more lines (ctrl+o to expand)`));
|
||||
}
|
||||
|
||||
if (this.status === "cancelled") {
|
||||
statusParts.push(theme.fg("warning", "(cancelled)"));
|
||||
} else if (this.status === "error") {
|
||||
statusParts.push(theme.fg("error", `(exit ${this.exitCode})`));
|
||||
}
|
||||
|
||||
// Add truncation warning (context truncation, not preview truncation)
|
||||
const wasTruncated = this.truncationResult?.truncated || contextTruncation.truncated;
|
||||
if (wasTruncated && this.fullOutputPath) {
|
||||
statusParts.push(theme.fg("warning", `Output truncated. Full output: ${this.fullOutputPath}`));
|
||||
}
|
||||
|
||||
if (statusParts.length > 0) {
|
||||
this.contentContainer.addChild(new Text("\n" + statusParts.join("\n"), 1, 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the raw output for creating BashExecutionMessage.
|
||||
*/
|
||||
getOutput(): string {
|
||||
return this.outputLines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the command that was executed.
|
||||
*/
|
||||
getCommand(): string {
|
||||
return this.command;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import { Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
|
||||
import { getMarkdownTheme, theme } from "../theme/theme.js";
|
||||
|
||||
/**
|
||||
* Component that renders a compaction indicator with collapsed/expanded state.
|
||||
* Collapsed: shows "Context compacted from X tokens"
|
||||
* Expanded: shows the full summary rendered as markdown (like a user message)
|
||||
*/
|
||||
export class CompactionComponent extends Container {
|
||||
private expanded = false;
|
||||
private tokensBefore: number;
|
||||
private summary: string;
|
||||
|
||||
constructor(tokensBefore: number, summary: string) {
|
||||
super();
|
||||
this.tokensBefore = tokensBefore;
|
||||
this.summary = summary;
|
||||
this.updateDisplay();
|
||||
}
|
||||
|
||||
setExpanded(expanded: boolean): void {
|
||||
this.expanded = expanded;
|
||||
this.updateDisplay();
|
||||
}
|
||||
|
||||
private updateDisplay(): void {
|
||||
this.clear();
|
||||
|
||||
if (this.expanded) {
|
||||
// Show header + summary as markdown (like user message)
|
||||
this.addChild(new Spacer(1));
|
||||
const header = `**Context compacted from ${this.tokensBefore.toLocaleString()} tokens**\n\n`;
|
||||
this.addChild(
|
||||
new Markdown(header + this.summary, 1, 1, getMarkdownTheme(), {
|
||||
bgColor: (text: string) => theme.bg("userMessageBg", text),
|
||||
color: (text: string) => theme.fg("userMessageText", text),
|
||||
}),
|
||||
);
|
||||
this.addChild(new Spacer(1));
|
||||
} else {
|
||||
// Collapsed: simple text in warning color with token count
|
||||
const tokenStr = this.tokensBefore.toLocaleString();
|
||||
this.addChild(
|
||||
new Text(
|
||||
theme.fg("warning", `Earlier messages compacted from ${tokenStr} tokens (ctrl+o to expand)`),
|
||||
1,
|
||||
1,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import { Editor } from "@mariozechner/pi-tui";
|
||||
|
||||
/**
|
||||
* Custom editor that handles Escape and Ctrl+C keys for coding-agent
|
||||
*/
|
||||
export class CustomEditor extends Editor {
|
||||
public onEscape?: () => void;
|
||||
public onCtrlC?: () => void;
|
||||
public onShiftTab?: () => void;
|
||||
public onCtrlP?: () => void;
|
||||
public onCtrlO?: () => void;
|
||||
public onCtrlT?: () => void;
|
||||
|
||||
handleInput(data: string): void {
|
||||
// Intercept Ctrl+T for thinking block visibility toggle
|
||||
if (data === "\x14" && this.onCtrlT) {
|
||||
this.onCtrlT();
|
||||
return;
|
||||
}
|
||||
|
||||
// Intercept Ctrl+O for tool output expansion
|
||||
if (data === "\x0f" && this.onCtrlO) {
|
||||
this.onCtrlO();
|
||||
return;
|
||||
}
|
||||
|
||||
// Intercept Ctrl+P for model cycling
|
||||
if (data === "\x10" && this.onCtrlP) {
|
||||
this.onCtrlP();
|
||||
return;
|
||||
}
|
||||
|
||||
// Intercept Shift+Tab for thinking level cycling
|
||||
if (data === "\x1b[Z" && this.onShiftTab) {
|
||||
this.onShiftTab();
|
||||
return;
|
||||
}
|
||||
|
||||
// Intercept Escape key - but only if autocomplete is NOT active
|
||||
// (let parent handle escape for autocomplete cancellation)
|
||||
if (data === "\x1b" && this.onEscape && !this.isShowingAutocomplete()) {
|
||||
this.onEscape();
|
||||
return;
|
||||
}
|
||||
|
||||
// Intercept Ctrl+C
|
||||
if (data === "\x03" && this.onCtrlC) {
|
||||
this.onCtrlC();
|
||||
return;
|
||||
}
|
||||
|
||||
// Pass to parent for normal handling
|
||||
super.handleInput(data);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import type { Component } from "@mariozechner/pi-tui";
|
||||
import { theme } from "../theme/theme.js";
|
||||
|
||||
/**
|
||||
* Dynamic border component that adjusts to viewport width
|
||||
*/
|
||||
export class DynamicBorder implements Component {
|
||||
private color: (str: string) => string;
|
||||
|
||||
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)))];
|
||||
}
|
||||
}
|
||||
254
packages/coding-agent/src/modes/interactive/components/footer.ts
Normal file
254
packages/coding-agent/src/modes/interactive/components/footer.ts
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
import type { AgentState } from "@mariozechner/pi-agent-core";
|
||||
import type { AssistantMessage } from "@mariozechner/pi-ai";
|
||||
import { type Component, visibleWidth } from "@mariozechner/pi-tui";
|
||||
import { existsSync, type FSWatcher, readFileSync, watch } from "fs";
|
||||
import { join } from "path";
|
||||
import { isModelUsingOAuth } from "../../../core/model-config.js";
|
||||
import { theme } from "../theme/theme.js";
|
||||
|
||||
/**
|
||||
* Footer component that shows pwd, token stats, and context usage
|
||||
*/
|
||||
export class FooterComponent implements Component {
|
||||
private state: AgentState;
|
||||
private cachedBranch: string | null | undefined = undefined; // undefined = not checked yet, null = not in git repo, string = branch name
|
||||
private gitWatcher: FSWatcher | null = null;
|
||||
private onBranchChange: (() => void) | null = null;
|
||||
private autoCompactEnabled: boolean = true;
|
||||
|
||||
constructor(state: AgentState) {
|
||||
this.state = state;
|
||||
}
|
||||
|
||||
setAutoCompactEnabled(enabled: boolean): void {
|
||||
this.autoCompactEnabled = enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up a file watcher on .git/HEAD to detect branch changes.
|
||||
* Call the provided callback when branch changes.
|
||||
*/
|
||||
watchBranch(onBranchChange: () => void): void {
|
||||
this.onBranchChange = onBranchChange;
|
||||
this.setupGitWatcher();
|
||||
}
|
||||
|
||||
private setupGitWatcher(): void {
|
||||
// Clean up existing watcher
|
||||
if (this.gitWatcher) {
|
||||
this.gitWatcher.close();
|
||||
this.gitWatcher = null;
|
||||
}
|
||||
|
||||
const gitHeadPath = join(process.cwd(), ".git", "HEAD");
|
||||
if (!existsSync(gitHeadPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.gitWatcher = watch(gitHeadPath, () => {
|
||||
this.cachedBranch = undefined; // Invalidate cache
|
||||
if (this.onBranchChange) {
|
||||
this.onBranchChange();
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
// Silently fail if we can't watch
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up the file watcher
|
||||
*/
|
||||
dispose(): void {
|
||||
if (this.gitWatcher) {
|
||||
this.gitWatcher.close();
|
||||
this.gitWatcher = null;
|
||||
}
|
||||
}
|
||||
|
||||
updateState(state: AgentState): void {
|
||||
this.state = state;
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
// Invalidate cached branch so it gets re-read on next render
|
||||
this.cachedBranch = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current git branch by reading .git/HEAD directly.
|
||||
* Returns null if not in a git repo, branch name otherwise.
|
||||
*/
|
||||
private getCurrentBranch(): string | null {
|
||||
// Return cached value if available
|
||||
if (this.cachedBranch !== undefined) {
|
||||
return this.cachedBranch;
|
||||
}
|
||||
|
||||
try {
|
||||
const gitHeadPath = join(process.cwd(), ".git", "HEAD");
|
||||
const content = readFileSync(gitHeadPath, "utf8").trim();
|
||||
|
||||
if (content.startsWith("ref: refs/heads/")) {
|
||||
// Normal branch: extract branch name
|
||||
this.cachedBranch = content.slice(16);
|
||||
} else {
|
||||
// Detached HEAD state
|
||||
this.cachedBranch = "detached";
|
||||
}
|
||||
} catch {
|
||||
// Not in a git repo or error reading file
|
||||
this.cachedBranch = null;
|
||||
}
|
||||
|
||||
return this.cachedBranch;
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
// Calculate cumulative usage from all assistant messages
|
||||
let totalInput = 0;
|
||||
let totalOutput = 0;
|
||||
let totalCacheRead = 0;
|
||||
let totalCacheWrite = 0;
|
||||
let totalCost = 0;
|
||||
|
||||
for (const message of this.state.messages) {
|
||||
if (message.role === "assistant") {
|
||||
const assistantMsg = message as AssistantMessage;
|
||||
totalInput += assistantMsg.usage.input;
|
||||
totalOutput += assistantMsg.usage.output;
|
||||
totalCacheRead += assistantMsg.usage.cacheRead;
|
||||
totalCacheWrite += assistantMsg.usage.cacheWrite;
|
||||
totalCost += assistantMsg.usage.cost.total;
|
||||
}
|
||||
}
|
||||
|
||||
// Get last assistant message for context percentage calculation (skip aborted messages)
|
||||
const lastAssistantMessage = this.state.messages
|
||||
.slice()
|
||||
.reverse()
|
||||
.find((m) => m.role === "assistant" && m.stopReason !== "aborted") as AssistantMessage | undefined;
|
||||
|
||||
// Calculate context percentage from last message (input + output + cacheRead + cacheWrite)
|
||||
const contextTokens = lastAssistantMessage
|
||||
? lastAssistantMessage.usage.input +
|
||||
lastAssistantMessage.usage.output +
|
||||
lastAssistantMessage.usage.cacheRead +
|
||||
lastAssistantMessage.usage.cacheWrite
|
||||
: 0;
|
||||
const contextWindow = this.state.model?.contextWindow || 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 => {
|
||||
if (count < 1000) return count.toString();
|
||||
if (count < 10000) return (count / 1000).toFixed(1) + "k";
|
||||
if (count < 1000000) return Math.round(count / 1000) + "k";
|
||||
if (count < 10000000) return (count / 1000000).toFixed(1) + "M";
|
||||
return Math.round(count / 1000000) + "M";
|
||||
};
|
||||
|
||||
// Replace home directory with ~
|
||||
let pwd = process.cwd();
|
||||
const home = process.env.HOME || process.env.USERPROFILE;
|
||||
if (home && pwd.startsWith(home)) {
|
||||
pwd = "~" + pwd.slice(home.length);
|
||||
}
|
||||
|
||||
// Add git branch if available
|
||||
const branch = this.getCurrentBranch();
|
||||
if (branch) {
|
||||
pwd = `${pwd} (${branch})`;
|
||||
}
|
||||
|
||||
// Truncate path if too long to fit width
|
||||
const maxPathLength = Math.max(20, width - 10); // Leave some margin
|
||||
if (pwd.length > maxPathLength) {
|
||||
const start = pwd.slice(0, Math.floor(maxPathLength / 2) - 2);
|
||||
const end = pwd.slice(-(Math.floor(maxPathLength / 2) - 1));
|
||||
pwd = `${start}...${end}`;
|
||||
}
|
||||
|
||||
// Build stats line
|
||||
const statsParts = [];
|
||||
if (totalInput) statsParts.push(`↑${formatTokens(totalInput)}`);
|
||||
if (totalOutput) statsParts.push(`↓${formatTokens(totalOutput)}`);
|
||||
if (totalCacheRead) statsParts.push(`R${formatTokens(totalCacheRead)}`);
|
||||
if (totalCacheWrite) statsParts.push(`W${formatTokens(totalCacheWrite)}`);
|
||||
|
||||
// Show cost with "(sub)" indicator if using OAuth subscription
|
||||
const usingSubscription = this.state.model ? isModelUsingOAuth(this.state.model) : false;
|
||||
if (totalCost || usingSubscription) {
|
||||
const costStr = `$${totalCost.toFixed(3)}${usingSubscription ? " (sub)" : ""}`;
|
||||
statsParts.push(costStr);
|
||||
}
|
||||
|
||||
// Colorize context percentage based on usage
|
||||
let contextPercentStr: string;
|
||||
const autoIndicator = this.autoCompactEnabled ? " (auto)" : "";
|
||||
const contextPercentDisplay = `${contextPercent}%/${formatTokens(contextWindow)}${autoIndicator}`;
|
||||
if (contextPercentValue > 90) {
|
||||
contextPercentStr = theme.fg("error", contextPercentDisplay);
|
||||
} else if (contextPercentValue > 70) {
|
||||
contextPercentStr = theme.fg("warning", contextPercentDisplay);
|
||||
} else {
|
||||
contextPercentStr = contextPercentDisplay;
|
||||
}
|
||||
statsParts.push(contextPercentStr);
|
||||
|
||||
let statsLeft = statsParts.join(" ");
|
||||
|
||||
// Add model name on the right side, plus thinking level if model supports it
|
||||
const modelName = this.state.model?.id || "no-model";
|
||||
|
||||
// Add thinking level hint if model supports reasoning and thinking is enabled
|
||||
let rightSide = modelName;
|
||||
if (this.state.model?.reasoning) {
|
||||
const thinkingLevel = this.state.thinkingLevel || "off";
|
||||
if (thinkingLevel !== "off") {
|
||||
rightSide = `${modelName} • ${thinkingLevel}`;
|
||||
}
|
||||
}
|
||||
|
||||
let statsLeftWidth = visibleWidth(statsLeft);
|
||||
const rightSideWidth = visibleWidth(rightSide);
|
||||
|
||||
// If statsLeft is too wide, truncate it
|
||||
if (statsLeftWidth > width) {
|
||||
// Truncate statsLeft to fit width (no room for right side)
|
||||
const plainStatsLeft = statsLeft.replace(/\x1b\[[0-9;]*m/g, "");
|
||||
statsLeft = plainStatsLeft.substring(0, width - 3) + "...";
|
||||
statsLeftWidth = visibleWidth(statsLeft);
|
||||
}
|
||||
|
||||
// Calculate available space for padding (minimum 2 spaces between stats and model)
|
||||
const minPadding = 2;
|
||||
const totalNeeded = statsLeftWidth + minPadding + rightSideWidth;
|
||||
|
||||
let statsLine: string;
|
||||
if (totalNeeded <= width) {
|
||||
// Both fit - add padding to right-align model
|
||||
const padding = " ".repeat(width - statsLeftWidth - rightSideWidth);
|
||||
statsLine = statsLeft + padding + rightSide;
|
||||
} else {
|
||||
// Need to truncate right side
|
||||
const availableForRight = width - statsLeftWidth - minPadding;
|
||||
if (availableForRight > 3) {
|
||||
// Truncate to fit (strip ANSI codes for length calculation, then truncate raw string)
|
||||
const plainRightSide = rightSide.replace(/\x1b\[[0-9;]*m/g, "");
|
||||
const truncatedPlain = plainRightSide.substring(0, availableForRight);
|
||||
// For simplicity, just use plain truncated version (loses color, but fits)
|
||||
const padding = " ".repeat(width - statsLeftWidth - truncatedPlain.length);
|
||||
statsLine = statsLeft + padding + truncatedPlain;
|
||||
} else {
|
||||
// Not enough space for right side at all
|
||||
statsLine = statsLeft;
|
||||
}
|
||||
}
|
||||
|
||||
// Return two lines: pwd and stats
|
||||
return [theme.fg("dim", pwd), theme.fg("dim", statsLine)];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,214 @@
|
|||
import type { Model } from "@mariozechner/pi-ai";
|
||||
import { Container, Input, Spacer, Text, type TUI } from "@mariozechner/pi-tui";
|
||||
import { getAvailableModels } from "../../../core/model-config.js";
|
||||
import type { SettingsManager } from "../../../core/settings-manager.js";
|
||||
import { fuzzyFilter } from "../../../utils/fuzzy.js";
|
||||
import { theme } from "../theme/theme.js";
|
||||
import { DynamicBorder } from "./dynamic-border.js";
|
||||
|
||||
interface ModelItem {
|
||||
provider: string;
|
||||
id: string;
|
||||
model: Model<any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component that renders a model selector with search
|
||||
*/
|
||||
export class ModelSelectorComponent extends Container {
|
||||
private searchInput: Input;
|
||||
private listContainer: Container;
|
||||
private allModels: ModelItem[] = [];
|
||||
private filteredModels: ModelItem[] = [];
|
||||
private selectedIndex: number = 0;
|
||||
private currentModel: Model<any> | null;
|
||||
private settingsManager: SettingsManager;
|
||||
private onSelectCallback: (model: Model<any>) => void;
|
||||
private onCancelCallback: () => void;
|
||||
private errorMessage: string | null = null;
|
||||
private tui: TUI;
|
||||
|
||||
constructor(
|
||||
tui: TUI,
|
||||
currentModel: Model<any> | null,
|
||||
settingsManager: SettingsManager,
|
||||
onSelect: (model: Model<any>) => void,
|
||||
onCancel: () => void,
|
||||
) {
|
||||
super();
|
||||
|
||||
this.tui = tui;
|
||||
this.currentModel = currentModel;
|
||||
this.settingsManager = settingsManager;
|
||||
this.onSelectCallback = onSelect;
|
||||
this.onCancelCallback = onCancel;
|
||||
|
||||
// Add top border
|
||||
this.addChild(new DynamicBorder());
|
||||
this.addChild(new Spacer(1));
|
||||
|
||||
// Add hint about API key filtering
|
||||
this.addChild(
|
||||
new Text(theme.fg("warning", "Only showing models with configured API keys (see README for details)"), 0, 0),
|
||||
);
|
||||
this.addChild(new Spacer(1));
|
||||
|
||||
// Create search input
|
||||
this.searchInput = new Input();
|
||||
this.searchInput.onSubmit = () => {
|
||||
// Enter on search input selects the first filtered item
|
||||
if (this.filteredModels[this.selectedIndex]) {
|
||||
this.handleSelect(this.filteredModels[this.selectedIndex].model);
|
||||
}
|
||||
};
|
||||
this.addChild(this.searchInput);
|
||||
|
||||
this.addChild(new Spacer(1));
|
||||
|
||||
// Create list container
|
||||
this.listContainer = new Container();
|
||||
this.addChild(this.listContainer);
|
||||
|
||||
this.addChild(new Spacer(1));
|
||||
|
||||
// Add bottom border
|
||||
this.addChild(new DynamicBorder());
|
||||
|
||||
// Load models and do initial render
|
||||
this.loadModels().then(() => {
|
||||
this.updateList();
|
||||
// Request re-render after models are loaded
|
||||
this.tui.requestRender();
|
||||
});
|
||||
}
|
||||
|
||||
private async loadModels(): Promise<void> {
|
||||
// Load available models fresh (includes custom models from models.json)
|
||||
const { models: availableModels, error } = await getAvailableModels();
|
||||
|
||||
// If there's an error loading models.json, we'll show it via the "no models" path
|
||||
// The error will be displayed to the user
|
||||
if (error) {
|
||||
this.allModels = [];
|
||||
this.filteredModels = [];
|
||||
this.errorMessage = error;
|
||||
return;
|
||||
}
|
||||
|
||||
const models: ModelItem[] = availableModels.map((model) => ({
|
||||
provider: model.provider,
|
||||
id: model.id,
|
||||
model,
|
||||
}));
|
||||
|
||||
// Sort: current model first, then by provider
|
||||
models.sort((a, b) => {
|
||||
const aIsCurrent = this.currentModel?.id === a.model.id && this.currentModel?.provider === a.provider;
|
||||
const bIsCurrent = this.currentModel?.id === b.model.id && this.currentModel?.provider === b.provider;
|
||||
if (aIsCurrent && !bIsCurrent) return -1;
|
||||
if (!aIsCurrent && bIsCurrent) return 1;
|
||||
return a.provider.localeCompare(b.provider);
|
||||
});
|
||||
|
||||
this.allModels = models;
|
||||
this.filteredModels = models;
|
||||
}
|
||||
|
||||
private filterModels(query: string): void {
|
||||
this.filteredModels = fuzzyFilter(this.allModels, query, ({ id }) => id);
|
||||
this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredModels.length - 1));
|
||||
this.updateList();
|
||||
}
|
||||
|
||||
private updateList(): void {
|
||||
this.listContainer.clear();
|
||||
|
||||
const maxVisible = 10;
|
||||
const startIndex = Math.max(
|
||||
0,
|
||||
Math.min(this.selectedIndex - Math.floor(maxVisible / 2), this.filteredModels.length - maxVisible),
|
||||
);
|
||||
const endIndex = Math.min(startIndex + maxVisible, this.filteredModels.length);
|
||||
|
||||
// Show visible slice of filtered models
|
||||
for (let i = startIndex; i < endIndex; i++) {
|
||||
const item = this.filteredModels[i];
|
||||
if (!item) continue;
|
||||
|
||||
const isSelected = i === this.selectedIndex;
|
||||
const isCurrent = this.currentModel?.id === item.model.id;
|
||||
|
||||
let line = "";
|
||||
if (isSelected) {
|
||||
const prefix = theme.fg("accent", "→ ");
|
||||
const modelText = `${item.id}`;
|
||||
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 = theme.fg("muted", `[${item.provider}]`);
|
||||
const checkmark = isCurrent ? theme.fg("success", " ✓") : "";
|
||||
line = modelText + " " + providerBadge + checkmark;
|
||||
}
|
||||
|
||||
this.listContainer.addChild(new Text(line, 0, 0));
|
||||
}
|
||||
|
||||
// Add scroll indicator if needed
|
||||
if (startIndex > 0 || endIndex < this.filteredModels.length) {
|
||||
const scrollInfo = theme.fg("muted", ` (${this.selectedIndex + 1}/${this.filteredModels.length})`);
|
||||
this.listContainer.addChild(new Text(scrollInfo, 0, 0));
|
||||
}
|
||||
|
||||
// Show error message or "no results" if empty
|
||||
if (this.errorMessage) {
|
||||
// Show error in red
|
||||
const errorLines = this.errorMessage.split("\n");
|
||||
for (const line of errorLines) {
|
||||
this.listContainer.addChild(new Text(theme.fg("error", line), 0, 0));
|
||||
}
|
||||
} else if (this.filteredModels.length === 0) {
|
||||
this.listContainer.addChild(new Text(theme.fg("muted", " No matching models"), 0, 0));
|
||||
}
|
||||
}
|
||||
|
||||
handleInput(keyData: string): void {
|
||||
// Up arrow - wrap to bottom when at top
|
||||
if (keyData === "\x1b[A") {
|
||||
this.selectedIndex = this.selectedIndex === 0 ? this.filteredModels.length - 1 : this.selectedIndex - 1;
|
||||
this.updateList();
|
||||
}
|
||||
// Down arrow - wrap to top when at bottom
|
||||
else if (keyData === "\x1b[B") {
|
||||
this.selectedIndex = this.selectedIndex === this.filteredModels.length - 1 ? 0 : this.selectedIndex + 1;
|
||||
this.updateList();
|
||||
}
|
||||
// Enter
|
||||
else if (keyData === "\r") {
|
||||
const selectedModel = this.filteredModels[this.selectedIndex];
|
||||
if (selectedModel) {
|
||||
this.handleSelect(selectedModel.model);
|
||||
}
|
||||
}
|
||||
// Escape
|
||||
else if (keyData === "\x1b") {
|
||||
this.onCancelCallback();
|
||||
}
|
||||
// Pass everything else to search input
|
||||
else {
|
||||
this.searchInput.handleInput(keyData);
|
||||
this.filterModels(this.searchInput.getValue());
|
||||
}
|
||||
}
|
||||
|
||||
private handleSelect(model: Model<any>): void {
|
||||
// Save as new default
|
||||
this.settingsManager.setDefaultModelAndProvider(model.provider, model.id);
|
||||
this.onSelectCallback(model);
|
||||
}
|
||||
|
||||
getSearchInput(): Input {
|
||||
return this.searchInput;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
import { Container, Spacer, TruncatedText } from "@mariozechner/pi-tui";
|
||||
import { getOAuthProviders, type OAuthProviderInfo } from "../../../core/oauth/index.js";
|
||||
import { loadOAuthCredentials } from "../../../core/oauth/storage.js";
|
||||
import { theme } from "../theme/theme.js";
|
||||
import { DynamicBorder } from "./dynamic-border.js";
|
||||
|
||||
/**
|
||||
* Component that renders an OAuth provider selector
|
||||
*/
|
||||
export class OAuthSelectorComponent extends Container {
|
||||
private listContainer: Container;
|
||||
private allProviders: OAuthProviderInfo[] = [];
|
||||
private selectedIndex: number = 0;
|
||||
private mode: "login" | "logout";
|
||||
private onSelectCallback: (providerId: string) => void;
|
||||
private onCancelCallback: () => void;
|
||||
|
||||
constructor(mode: "login" | "logout", onSelect: (providerId: string) => void, onCancel: () => void) {
|
||||
super();
|
||||
|
||||
this.mode = mode;
|
||||
this.onSelectCallback = onSelect;
|
||||
this.onCancelCallback = onCancel;
|
||||
|
||||
// Load all OAuth providers
|
||||
this.loadProviders();
|
||||
|
||||
// Add top border
|
||||
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 TruncatedText(theme.bold(title)));
|
||||
this.addChild(new Spacer(1));
|
||||
|
||||
// Create list container
|
||||
this.listContainer = new Container();
|
||||
this.addChild(this.listContainer);
|
||||
|
||||
this.addChild(new Spacer(1));
|
||||
|
||||
// Add bottom border
|
||||
this.addChild(new DynamicBorder());
|
||||
|
||||
// Initial render
|
||||
this.updateList();
|
||||
}
|
||||
|
||||
private loadProviders(): void {
|
||||
this.allProviders = getOAuthProviders();
|
||||
this.allProviders = this.allProviders.filter((p) => p.available);
|
||||
}
|
||||
|
||||
private updateList(): void {
|
||||
this.listContainer.clear();
|
||||
|
||||
for (let i = 0; i < this.allProviders.length; i++) {
|
||||
const provider = this.allProviders[i];
|
||||
if (!provider) continue;
|
||||
|
||||
const isSelected = i === this.selectedIndex;
|
||||
const isAvailable = provider.available;
|
||||
|
||||
// Check if user is logged in for this provider
|
||||
const credentials = loadOAuthCredentials(provider.id);
|
||||
const isLoggedIn = credentials !== null;
|
||||
const statusIndicator = isLoggedIn ? theme.fg("success", " ✓ logged in") : "";
|
||||
|
||||
let line = "";
|
||||
if (isSelected) {
|
||||
const prefix = theme.fg("accent", "→ ");
|
||||
const text = isAvailable ? theme.fg("accent", provider.name) : theme.fg("dim", provider.name);
|
||||
line = prefix + text + statusIndicator;
|
||||
} else {
|
||||
const text = isAvailable ? ` ${provider.name}` : theme.fg("dim", ` ${provider.name}`);
|
||||
line = text + statusIndicator;
|
||||
}
|
||||
|
||||
this.listContainer.addChild(new TruncatedText(line, 0, 0));
|
||||
}
|
||||
|
||||
// Show "no providers" if empty
|
||||
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 TruncatedText(theme.fg("muted", ` ${message}`), 0, 0));
|
||||
}
|
||||
}
|
||||
|
||||
handleInput(keyData: string): void {
|
||||
// Up arrow
|
||||
if (keyData === "\x1b[A") {
|
||||
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
||||
this.updateList();
|
||||
}
|
||||
// Down arrow
|
||||
else if (keyData === "\x1b[B") {
|
||||
this.selectedIndex = Math.min(this.allProviders.length - 1, this.selectedIndex + 1);
|
||||
this.updateList();
|
||||
}
|
||||
// Enter
|
||||
else if (keyData === "\r") {
|
||||
const selectedProvider = this.allProviders[this.selectedIndex];
|
||||
if (selectedProvider?.available) {
|
||||
this.onSelectCallback(selectedProvider.id);
|
||||
}
|
||||
}
|
||||
// Escape
|
||||
else if (keyData === "\x1b") {
|
||||
this.onCancelCallback();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
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
|
||||
*/
|
||||
export class QueueModeSelectorComponent extends Container {
|
||||
private selectList: SelectList;
|
||||
|
||||
constructor(
|
||||
currentMode: "all" | "one-at-a-time",
|
||||
onSelect: (mode: "all" | "one-at-a-time") => void,
|
||||
onCancel: () => void,
|
||||
) {
|
||||
super();
|
||||
|
||||
const queueModes: SelectItem[] = [
|
||||
{
|
||||
value: "one-at-a-time",
|
||||
label: "one-at-a-time",
|
||||
description: "Process queued messages one by one (recommended)",
|
||||
},
|
||||
{ value: "all", label: "all", description: "Process all queued messages at once" },
|
||||
];
|
||||
|
||||
// Add top border
|
||||
this.addChild(new DynamicBorder());
|
||||
|
||||
// Create selector
|
||||
this.selectList = new SelectList(queueModes, 2, getSelectListTheme());
|
||||
|
||||
// Preselect current mode
|
||||
const currentIndex = queueModes.findIndex((item) => item.value === currentMode);
|
||||
if (currentIndex !== -1) {
|
||||
this.selectList.setSelectedIndex(currentIndex);
|
||||
}
|
||||
|
||||
this.selectList.onSelect = (item) => {
|
||||
onSelect(item.value as "all" | "one-at-a-time");
|
||||
};
|
||||
|
||||
this.selectList.onCancel = () => {
|
||||
onCancel();
|
||||
};
|
||||
|
||||
this.addChild(this.selectList);
|
||||
|
||||
// Add bottom border
|
||||
this.addChild(new DynamicBorder());
|
||||
}
|
||||
|
||||
getSelectList(): SelectList {
|
||||
return this.selectList;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,198 @@
|
|||
import { type Component, Container, Input, Spacer, Text, truncateToWidth } from "@mariozechner/pi-tui";
|
||||
import type { SessionManager } from "../../../core/session-manager.js";
|
||||
import { fuzzyFilter } from "../../../utils/fuzzy.js";
|
||||
import { theme } from "../theme/theme.js";
|
||||
import { DynamicBorder } from "./dynamic-border.js";
|
||||
|
||||
interface SessionItem {
|
||||
path: string;
|
||||
id: string;
|
||||
created: Date;
|
||||
modified: Date;
|
||||
messageCount: number;
|
||||
firstMessage: string;
|
||||
allMessagesText: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom session list component with multi-line items and search
|
||||
*/
|
||||
class SessionList implements Component {
|
||||
private allSessions: SessionItem[] = [];
|
||||
private filteredSessions: SessionItem[] = [];
|
||||
private selectedIndex: number = 0;
|
||||
private searchInput: Input;
|
||||
public onSelect?: (sessionPath: string) => void;
|
||||
public onCancel?: () => void;
|
||||
private maxVisible: number = 5; // Max sessions visible (each session is 3 lines: msg + metadata + blank)
|
||||
|
||||
constructor(sessions: SessionItem[]) {
|
||||
this.allSessions = sessions;
|
||||
this.filteredSessions = sessions;
|
||||
this.searchInput = new Input();
|
||||
|
||||
// Handle Enter in search input - select current item
|
||||
this.searchInput.onSubmit = () => {
|
||||
if (this.filteredSessions[this.selectedIndex]) {
|
||||
const selected = this.filteredSessions[this.selectedIndex];
|
||||
if (this.onSelect) {
|
||||
this.onSelect(selected.path);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private filterSessions(query: string): void {
|
||||
this.filteredSessions = fuzzyFilter(this.allSessions, query, (session) => session.allMessagesText);
|
||||
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[] = [];
|
||||
|
||||
// Render search input
|
||||
lines.push(...this.searchInput.render(width));
|
||||
lines.push(""); // Blank line after search
|
||||
|
||||
if (this.filteredSessions.length === 0) {
|
||||
lines.push(theme.fg("muted", " No sessions found"));
|
||||
return lines;
|
||||
}
|
||||
|
||||
// Format dates
|
||||
const formatDate = (date: Date): string => {
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return "just now";
|
||||
if (diffMins < 60) return `${diffMins} minute${diffMins !== 1 ? "s" : ""} ago`;
|
||||
if (diffHours < 24) return `${diffHours} hour${diffHours !== 1 ? "s" : ""} ago`;
|
||||
if (diffDays === 1) return "1 day ago";
|
||||
if (diffDays < 7) return `${diffDays} days ago`;
|
||||
|
||||
return date.toLocaleDateString();
|
||||
};
|
||||
|
||||
// Calculate visible range with scrolling
|
||||
const startIndex = Math.max(
|
||||
0,
|
||||
Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.filteredSessions.length - this.maxVisible),
|
||||
);
|
||||
const endIndex = Math.min(startIndex + this.maxVisible, this.filteredSessions.length);
|
||||
|
||||
// Render visible sessions (2 lines per session + blank line)
|
||||
for (let i = startIndex; i < endIndex; i++) {
|
||||
const session = this.filteredSessions[i];
|
||||
const isSelected = i === this.selectedIndex;
|
||||
|
||||
// Normalize first message to single line
|
||||
const normalizedMessage = session.firstMessage.replace(/\n/g, " ").trim();
|
||||
|
||||
// First line: cursor + message (truncate to visible width)
|
||||
const cursor = isSelected ? theme.fg("accent", "› ") : " ";
|
||||
const maxMsgWidth = width - 2; // Account for cursor (2 visible chars)
|
||||
const truncatedMsg = truncateToWidth(normalizedMessage, maxMsgWidth, "...");
|
||||
const messageLine = cursor + (isSelected ? theme.bold(truncatedMsg) : truncatedMsg);
|
||||
|
||||
// Second line: metadata (dimmed) - also truncate for safety
|
||||
const modified = formatDate(session.modified);
|
||||
const msgCount = `${session.messageCount} message${session.messageCount !== 1 ? "s" : ""}`;
|
||||
const metadata = ` ${modified} · ${msgCount}`;
|
||||
const metadataLine = theme.fg("dim", truncateToWidth(metadata, width, ""));
|
||||
|
||||
lines.push(messageLine);
|
||||
lines.push(metadataLine);
|
||||
lines.push(""); // Blank line between sessions
|
||||
}
|
||||
|
||||
// Add scroll indicator if needed
|
||||
if (startIndex > 0 || endIndex < this.filteredSessions.length) {
|
||||
const scrollText = ` (${this.selectedIndex + 1}/${this.filteredSessions.length})`;
|
||||
const scrollInfo = theme.fg("muted", truncateToWidth(scrollText, width, ""));
|
||||
lines.push(scrollInfo);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
handleInput(keyData: string): void {
|
||||
// Up arrow
|
||||
if (keyData === "\x1b[A") {
|
||||
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
||||
}
|
||||
// Down arrow
|
||||
else if (keyData === "\x1b[B") {
|
||||
this.selectedIndex = Math.min(this.filteredSessions.length - 1, this.selectedIndex + 1);
|
||||
}
|
||||
// Enter
|
||||
else if (keyData === "\r") {
|
||||
const selected = this.filteredSessions[this.selectedIndex];
|
||||
if (selected && this.onSelect) {
|
||||
this.onSelect(selected.path);
|
||||
}
|
||||
}
|
||||
// Escape - cancel
|
||||
else if (keyData === "\x1b") {
|
||||
if (this.onCancel) {
|
||||
this.onCancel();
|
||||
}
|
||||
}
|
||||
// Ctrl+C - exit process
|
||||
else if (keyData === "\x03") {
|
||||
process.exit(0);
|
||||
}
|
||||
// Pass everything else to search input
|
||||
else {
|
||||
this.searchInput.handleInput(keyData);
|
||||
this.filterSessions(this.searchInput.getValue());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Component that renders a session selector
|
||||
*/
|
||||
export class SessionSelectorComponent extends Container {
|
||||
private sessionList: SessionList;
|
||||
|
||||
constructor(sessionManager: SessionManager, onSelect: (sessionPath: string) => void, onCancel: () => void) {
|
||||
super();
|
||||
|
||||
// Load all sessions
|
||||
const sessions = sessionManager.loadAllSessions();
|
||||
|
||||
// Add header
|
||||
this.addChild(new Spacer(1));
|
||||
this.addChild(new Text(theme.bold("Resume Session"), 1, 0));
|
||||
this.addChild(new Spacer(1));
|
||||
this.addChild(new DynamicBorder());
|
||||
this.addChild(new Spacer(1));
|
||||
|
||||
// Create session list
|
||||
this.sessionList = new SessionList(sessions);
|
||||
this.sessionList.onSelect = onSelect;
|
||||
this.sessionList.onCancel = onCancel;
|
||||
|
||||
this.addChild(this.sessionList);
|
||||
|
||||
// Add bottom border
|
||||
this.addChild(new Spacer(1));
|
||||
this.addChild(new DynamicBorder());
|
||||
|
||||
// Auto-cancel if no sessions
|
||||
if (sessions.length === 0) {
|
||||
setTimeout(() => onCancel(), 100);
|
||||
}
|
||||
}
|
||||
|
||||
getSessionList(): SessionList {
|
||||
return this.sessionList;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import { Container, type SelectItem, SelectList } from "@mariozechner/pi-tui";
|
||||
import { getAvailableThemes, getSelectListTheme } from "../theme/theme.js";
|
||||
import { DynamicBorder } from "./dynamic-border.js";
|
||||
|
||||
/**
|
||||
* Component that renders a theme selector
|
||||
*/
|
||||
export class ThemeSelectorComponent extends Container {
|
||||
private selectList: SelectList;
|
||||
private onPreview: (themeName: string) => 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();
|
||||
const themeItems: SelectItem[] = themes.map((name) => ({
|
||||
value: name,
|
||||
label: name,
|
||||
description: name === currentTheme ? "(current)" : undefined,
|
||||
}));
|
||||
|
||||
// Add top border
|
||||
this.addChild(new DynamicBorder());
|
||||
|
||||
// Create selector
|
||||
this.selectList = new SelectList(themeItems, 10, getSelectListTheme());
|
||||
|
||||
// Preselect current theme
|
||||
const currentIndex = themes.indexOf(currentTheme);
|
||||
if (currentIndex !== -1) {
|
||||
this.selectList.setSelectedIndex(currentIndex);
|
||||
}
|
||||
|
||||
this.selectList.onSelect = (item) => {
|
||||
onSelect(item.value);
|
||||
};
|
||||
|
||||
this.selectList.onCancel = () => {
|
||||
onCancel();
|
||||
};
|
||||
|
||||
this.selectList.onSelectionChange = (item) => {
|
||||
this.onPreview(item.value);
|
||||
};
|
||||
|
||||
this.addChild(this.selectList);
|
||||
|
||||
// Add bottom border
|
||||
this.addChild(new DynamicBorder());
|
||||
}
|
||||
|
||||
getSelectList(): SelectList {
|
||||
return this.selectList;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import type { ThinkingLevel } from "@mariozechner/pi-agent-core";
|
||||
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
|
||||
*/
|
||||
export class ThinkingSelectorComponent extends Container {
|
||||
private selectList: SelectList;
|
||||
|
||||
constructor(currentLevel: ThinkingLevel, onSelect: (level: ThinkingLevel) => void, onCancel: () => void) {
|
||||
super();
|
||||
|
||||
const thinkingLevels: SelectItem[] = [
|
||||
{ value: "off", label: "off", description: "No reasoning" },
|
||||
{ value: "minimal", label: "minimal", description: "Very brief reasoning (~1k tokens)" },
|
||||
{ value: "low", label: "low", description: "Light reasoning (~2k tokens)" },
|
||||
{ value: "medium", label: "medium", description: "Moderate reasoning (~8k tokens)" },
|
||||
{ value: "high", label: "high", description: "Deep reasoning (~16k tokens)" },
|
||||
];
|
||||
|
||||
// Add top border
|
||||
this.addChild(new DynamicBorder());
|
||||
|
||||
// Create selector
|
||||
this.selectList = new SelectList(thinkingLevels, 5, getSelectListTheme());
|
||||
|
||||
// Preselect current level
|
||||
const currentIndex = thinkingLevels.findIndex((item) => item.value === currentLevel);
|
||||
if (currentIndex !== -1) {
|
||||
this.selectList.setSelectedIndex(currentIndex);
|
||||
}
|
||||
|
||||
this.selectList.onSelect = (item) => {
|
||||
onSelect(item.value as ThinkingLevel);
|
||||
};
|
||||
|
||||
this.selectList.onCancel = () => {
|
||||
onCancel();
|
||||
};
|
||||
|
||||
this.addChild(this.selectList);
|
||||
|
||||
// Add bottom border
|
||||
this.addChild(new DynamicBorder());
|
||||
}
|
||||
|
||||
getSelectList(): SelectList {
|
||||
return this.selectList;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,391 @@
|
|||
import * as os from "node:os";
|
||||
import { Container, Spacer, Text } from "@mariozechner/pi-tui";
|
||||
import stripAnsi from "strip-ansi";
|
||||
import { theme } from "../theme/theme.js";
|
||||
|
||||
/**
|
||||
* Convert absolute path to tilde notation if it's in home directory
|
||||
*/
|
||||
function shortenPath(path: string): string {
|
||||
const home = os.homedir();
|
||||
if (path.startsWith(home)) {
|
||||
return "~" + path.slice(home.length);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace tabs with spaces for consistent rendering
|
||||
*/
|
||||
function replaceTabs(text: string): string {
|
||||
return text.replace(/\t/g, " ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Component that renders a tool call with its result (updateable)
|
||||
*/
|
||||
export class ToolExecutionComponent extends Container {
|
||||
private contentText: Text;
|
||||
private toolName: string;
|
||||
private args: any;
|
||||
private expanded = false;
|
||||
private result?: {
|
||||
content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
|
||||
isError: boolean;
|
||||
details?: any;
|
||||
};
|
||||
|
||||
constructor(toolName: string, args: any) {
|
||||
super();
|
||||
this.toolName = toolName;
|
||||
this.args = args;
|
||||
this.addChild(new Spacer(1));
|
||||
// Content with colored background and padding
|
||||
this.contentText = new Text("", 1, 1, (text: string) => theme.bg("toolPendingBg", text));
|
||||
this.addChild(this.contentText);
|
||||
this.updateDisplay();
|
||||
}
|
||||
|
||||
updateArgs(args: any): void {
|
||||
this.args = args;
|
||||
this.updateDisplay();
|
||||
}
|
||||
|
||||
updateResult(result: {
|
||||
content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
|
||||
details?: any;
|
||||
isError: boolean;
|
||||
}): void {
|
||||
this.result = result;
|
||||
this.updateDisplay();
|
||||
}
|
||||
|
||||
setExpanded(expanded: boolean): void {
|
||||
this.expanded = expanded;
|
||||
this.updateDisplay();
|
||||
}
|
||||
|
||||
private updateDisplay(): void {
|
||||
const bgFn = this.result
|
||||
? this.result.isError
|
||||
? (text: string) => theme.bg("toolErrorBg", text)
|
||||
: (text: string) => theme.bg("toolSuccessBg", text)
|
||||
: (text: string) => theme.bg("toolPendingBg", text);
|
||||
|
||||
this.contentText.setCustomBgFn(bgFn);
|
||||
this.contentText.setText(this.formatToolExecution());
|
||||
}
|
||||
|
||||
private getTextOutput(): string {
|
||||
if (!this.result) return "";
|
||||
|
||||
// Extract text from content blocks
|
||||
const textBlocks = this.result.content?.filter((c: any) => c.type === "text") || [];
|
||||
const imageBlocks = this.result.content?.filter((c: any) => c.type === "image") || [];
|
||||
|
||||
// Strip ANSI codes and carriage returns from raw output
|
||||
// (bash may emit colors/formatting, and Windows may include \r)
|
||||
let output = textBlocks
|
||||
.map((c: any) => {
|
||||
let text = stripAnsi(c.text || "").replace(/\r/g, "");
|
||||
// stripAnsi misses some escape sequences like standalone ESC \ (String Terminator)
|
||||
// and leaves orphaned fragments from malformed sequences (e.g. TUI output captured to file)
|
||||
// Clean up: remove ESC + any following char, and control chars except newline/tab
|
||||
text = text.replace(/\x1b./g, "");
|
||||
text = text.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g, "");
|
||||
return text;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
// Add indicator for images
|
||||
if (imageBlocks.length > 0) {
|
||||
const imageIndicators = imageBlocks.map((img: any) => `[Image: ${img.mimeType}]`).join("\n");
|
||||
output = output ? `${output}\n${imageIndicators}` : imageIndicators;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
private formatToolExecution(): string {
|
||||
let text = "";
|
||||
|
||||
// Format based on tool type
|
||||
if (this.toolName === "bash") {
|
||||
const command = this.args?.command || "";
|
||||
text = theme.fg("toolTitle", theme.bold(`$ ${command || theme.fg("toolOutput", "...")}`));
|
||||
|
||||
if (this.result) {
|
||||
const output = this.getTextOutput().trim();
|
||||
if (output) {
|
||||
const lines = output.split("\n");
|
||||
const maxLines = this.expanded ? lines.length : 5;
|
||||
const displayLines = lines.slice(0, maxLines);
|
||||
const remaining = lines.length - maxLines;
|
||||
|
||||
text += "\n\n" + displayLines.map((line: string) => theme.fg("toolOutput", line)).join("\n");
|
||||
if (remaining > 0) {
|
||||
text += theme.fg("toolOutput", `\n... (${remaining} more lines)`);
|
||||
}
|
||||
}
|
||||
|
||||
// Show truncation warning at the bottom (outside collapsed area)
|
||||
const truncation = this.result.details?.truncation;
|
||||
const fullOutputPath = this.result.details?.fullOutputPath;
|
||||
if (truncation?.truncated || fullOutputPath) {
|
||||
const warnings: string[] = [];
|
||||
if (fullOutputPath) {
|
||||
warnings.push(`Full output: ${fullOutputPath}`);
|
||||
}
|
||||
if (truncation?.truncated) {
|
||||
if (truncation.truncatedBy === "lines") {
|
||||
warnings.push(`Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines`);
|
||||
} else {
|
||||
warnings.push(`Truncated: ${truncation.outputLines} lines shown (30KB limit)`);
|
||||
}
|
||||
}
|
||||
text += "\n" + theme.fg("warning", `[${warnings.join(". ")}]`);
|
||||
}
|
||||
}
|
||||
} else if (this.toolName === "read") {
|
||||
const path = shortenPath(this.args?.file_path || this.args?.path || "");
|
||||
const offset = this.args?.offset;
|
||||
const limit = this.args?.limit;
|
||||
|
||||
// Build path display with offset/limit suffix (in warning color if offset/limit used)
|
||||
let pathDisplay = path ? theme.fg("accent", path) : theme.fg("toolOutput", "...");
|
||||
if (offset !== undefined || limit !== undefined) {
|
||||
const startLine = offset ?? 1;
|
||||
const endLine = limit !== undefined ? startLine + limit - 1 : "";
|
||||
pathDisplay += theme.fg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`);
|
||||
}
|
||||
|
||||
text = theme.fg("toolTitle", theme.bold("read")) + " " + pathDisplay;
|
||||
|
||||
if (this.result) {
|
||||
const output = this.getTextOutput();
|
||||
const lines = output.split("\n");
|
||||
|
||||
const maxLines = this.expanded ? lines.length : 10;
|
||||
const displayLines = lines.slice(0, maxLines);
|
||||
const remaining = lines.length - maxLines;
|
||||
|
||||
text += "\n\n" + displayLines.map((line: string) => theme.fg("toolOutput", replaceTabs(line))).join("\n");
|
||||
if (remaining > 0) {
|
||||
text += theme.fg("toolOutput", `\n... (${remaining} more lines)`);
|
||||
}
|
||||
|
||||
// Show truncation warning at the bottom (outside collapsed area)
|
||||
const truncation = this.result.details?.truncation;
|
||||
if (truncation?.truncated) {
|
||||
if (truncation.firstLineExceedsLimit) {
|
||||
text += "\n" + theme.fg("warning", `[First line exceeds 30KB limit]`);
|
||||
} else if (truncation.truncatedBy === "lines") {
|
||||
text +=
|
||||
"\n" +
|
||||
theme.fg(
|
||||
"warning",
|
||||
`[Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines]`,
|
||||
);
|
||||
} else {
|
||||
text += "\n" + theme.fg("warning", `[Truncated: ${truncation.outputLines} lines shown (30KB limit)]`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (this.toolName === "write") {
|
||||
const path = shortenPath(this.args?.file_path || this.args?.path || "");
|
||||
const fileContent = this.args?.content || "";
|
||||
const lines = fileContent ? fileContent.split("\n") : [];
|
||||
const totalLines = lines.length;
|
||||
|
||||
text =
|
||||
theme.fg("toolTitle", theme.bold("write")) +
|
||||
" " +
|
||||
(path ? theme.fg("accent", path) : theme.fg("toolOutput", "..."));
|
||||
if (totalLines > 10) {
|
||||
text += ` (${totalLines} lines)`;
|
||||
}
|
||||
|
||||
// Show first 10 lines of content if available
|
||||
if (fileContent) {
|
||||
const maxLines = this.expanded ? lines.length : 10;
|
||||
const displayLines = lines.slice(0, maxLines);
|
||||
const remaining = lines.length - maxLines;
|
||||
|
||||
text += "\n\n" + displayLines.map((line: string) => theme.fg("toolOutput", replaceTabs(line))).join("\n");
|
||||
if (remaining > 0) {
|
||||
text += theme.fg("toolOutput", `\n... (${remaining} more lines)`);
|
||||
}
|
||||
}
|
||||
} else if (this.toolName === "edit") {
|
||||
const path = shortenPath(this.args?.file_path || this.args?.path || "");
|
||||
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" + 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 theme.fg("toolDiffAdded", line);
|
||||
} else if (line.startsWith("-")) {
|
||||
return theme.fg("toolDiffRemoved", line);
|
||||
} else {
|
||||
return theme.fg("toolDiffContext", line);
|
||||
}
|
||||
});
|
||||
text += "\n\n" + coloredLines.join("\n");
|
||||
}
|
||||
}
|
||||
} else if (this.toolName === "ls") {
|
||||
const path = shortenPath(this.args?.path || ".");
|
||||
const limit = this.args?.limit;
|
||||
|
||||
text = theme.fg("toolTitle", theme.bold("ls")) + " " + theme.fg("accent", path);
|
||||
if (limit !== undefined) {
|
||||
text += theme.fg("toolOutput", ` (limit ${limit})`);
|
||||
}
|
||||
|
||||
if (this.result) {
|
||||
const output = this.getTextOutput().trim();
|
||||
if (output) {
|
||||
const lines = output.split("\n");
|
||||
const maxLines = this.expanded ? lines.length : 20;
|
||||
const displayLines = lines.slice(0, maxLines);
|
||||
const remaining = lines.length - maxLines;
|
||||
|
||||
text += "\n\n" + displayLines.map((line: string) => theme.fg("toolOutput", line)).join("\n");
|
||||
if (remaining > 0) {
|
||||
text += theme.fg("toolOutput", `\n... (${remaining} more lines)`);
|
||||
}
|
||||
}
|
||||
|
||||
// Show truncation warning at the bottom (outside collapsed area)
|
||||
const entryLimit = this.result.details?.entryLimitReached;
|
||||
const truncation = this.result.details?.truncation;
|
||||
if (entryLimit || truncation?.truncated) {
|
||||
const warnings: string[] = [];
|
||||
if (entryLimit) {
|
||||
warnings.push(`${entryLimit} entries limit`);
|
||||
}
|
||||
if (truncation?.truncated) {
|
||||
warnings.push("30KB limit");
|
||||
}
|
||||
text += "\n" + theme.fg("warning", `[Truncated: ${warnings.join(", ")}]`);
|
||||
}
|
||||
}
|
||||
} else if (this.toolName === "find") {
|
||||
const pattern = this.args?.pattern || "";
|
||||
const path = shortenPath(this.args?.path || ".");
|
||||
const limit = this.args?.limit;
|
||||
|
||||
text =
|
||||
theme.fg("toolTitle", theme.bold("find")) +
|
||||
" " +
|
||||
theme.fg("accent", pattern) +
|
||||
theme.fg("toolOutput", ` in ${path}`);
|
||||
if (limit !== undefined) {
|
||||
text += theme.fg("toolOutput", ` (limit ${limit})`);
|
||||
}
|
||||
|
||||
if (this.result) {
|
||||
const output = this.getTextOutput().trim();
|
||||
if (output) {
|
||||
const lines = output.split("\n");
|
||||
const maxLines = this.expanded ? lines.length : 20;
|
||||
const displayLines = lines.slice(0, maxLines);
|
||||
const remaining = lines.length - maxLines;
|
||||
|
||||
text += "\n\n" + displayLines.map((line: string) => theme.fg("toolOutput", line)).join("\n");
|
||||
if (remaining > 0) {
|
||||
text += theme.fg("toolOutput", `\n... (${remaining} more lines)`);
|
||||
}
|
||||
}
|
||||
|
||||
// Show truncation warning at the bottom (outside collapsed area)
|
||||
const resultLimit = this.result.details?.resultLimitReached;
|
||||
const truncation = this.result.details?.truncation;
|
||||
if (resultLimit || truncation?.truncated) {
|
||||
const warnings: string[] = [];
|
||||
if (resultLimit) {
|
||||
warnings.push(`${resultLimit} results limit`);
|
||||
}
|
||||
if (truncation?.truncated) {
|
||||
warnings.push("30KB limit");
|
||||
}
|
||||
text += "\n" + theme.fg("warning", `[Truncated: ${warnings.join(", ")}]`);
|
||||
}
|
||||
}
|
||||
} else if (this.toolName === "grep") {
|
||||
const pattern = this.args?.pattern || "";
|
||||
const path = shortenPath(this.args?.path || ".");
|
||||
const glob = this.args?.glob;
|
||||
const limit = this.args?.limit;
|
||||
|
||||
text =
|
||||
theme.fg("toolTitle", theme.bold("grep")) +
|
||||
" " +
|
||||
theme.fg("accent", `/${pattern}/`) +
|
||||
theme.fg("toolOutput", ` in ${path}`);
|
||||
if (glob) {
|
||||
text += theme.fg("toolOutput", ` (${glob})`);
|
||||
}
|
||||
if (limit !== undefined) {
|
||||
text += theme.fg("toolOutput", ` limit ${limit}`);
|
||||
}
|
||||
|
||||
if (this.result) {
|
||||
const output = this.getTextOutput().trim();
|
||||
if (output) {
|
||||
const lines = output.split("\n");
|
||||
const maxLines = this.expanded ? lines.length : 15;
|
||||
const displayLines = lines.slice(0, maxLines);
|
||||
const remaining = lines.length - maxLines;
|
||||
|
||||
text += "\n\n" + displayLines.map((line: string) => theme.fg("toolOutput", line)).join("\n");
|
||||
if (remaining > 0) {
|
||||
text += theme.fg("toolOutput", `\n... (${remaining} more lines)`);
|
||||
}
|
||||
}
|
||||
|
||||
// Show truncation warning at the bottom (outside collapsed area)
|
||||
const matchLimit = this.result.details?.matchLimitReached;
|
||||
const truncation = this.result.details?.truncation;
|
||||
const linesTruncated = this.result.details?.linesTruncated;
|
||||
if (matchLimit || truncation?.truncated || linesTruncated) {
|
||||
const warnings: string[] = [];
|
||||
if (matchLimit) {
|
||||
warnings.push(`${matchLimit} matches limit`);
|
||||
}
|
||||
if (truncation?.truncated) {
|
||||
warnings.push("30KB limit");
|
||||
}
|
||||
if (linesTruncated) {
|
||||
warnings.push("some lines truncated");
|
||||
}
|
||||
text += "\n" + theme.fg("warning", `[Truncated: ${warnings.join(", ")}]`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Generic tool
|
||||
text = theme.fg("toolTitle", theme.bold(this.toolName));
|
||||
|
||||
const content = JSON.stringify(this.args, null, 2);
|
||||
text += "\n\n" + content;
|
||||
const output = this.getTextOutput();
|
||||
if (output) {
|
||||
text += "\n" + output;
|
||||
}
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
import { type Component, Container, Spacer, Text, truncateToWidth } from "@mariozechner/pi-tui";
|
||||
import { theme } from "../theme/theme.js";
|
||||
import { DynamicBorder } from "./dynamic-border.js";
|
||||
|
||||
interface UserMessageItem {
|
||||
index: number; // Index in the full messages array
|
||||
text: string; // The message text
|
||||
timestamp?: string; // Optional timestamp if available
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom user message list component with selection
|
||||
*/
|
||||
class UserMessageList implements Component {
|
||||
private messages: UserMessageItem[] = [];
|
||||
private selectedIndex: number = 0;
|
||||
public onSelect?: (messageIndex: number) => void;
|
||||
public onCancel?: () => void;
|
||||
private maxVisible: number = 10; // Max messages visible
|
||||
|
||||
constructor(messages: UserMessageItem[]) {
|
||||
// Store messages in chronological order (oldest to newest)
|
||||
this.messages = messages;
|
||||
// Start with the last (most recent) message selected
|
||||
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(theme.fg("muted", " No user messages found"));
|
||||
return lines;
|
||||
}
|
||||
|
||||
// Calculate visible range with scrolling
|
||||
const startIndex = Math.max(
|
||||
0,
|
||||
Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.messages.length - this.maxVisible),
|
||||
);
|
||||
const endIndex = Math.min(startIndex + this.maxVisible, this.messages.length);
|
||||
|
||||
// Render visible messages (2 lines per message + blank line)
|
||||
for (let i = startIndex; i < endIndex; i++) {
|
||||
const message = this.messages[i];
|
||||
const isSelected = i === this.selectedIndex;
|
||||
|
||||
// Normalize message to single line
|
||||
const normalizedMessage = message.text.replace(/\n/g, " ").trim();
|
||||
|
||||
// First line: cursor + message
|
||||
const cursor = isSelected ? theme.fg("accent", "› ") : " ";
|
||||
const maxMsgWidth = width - 2; // Account for cursor (2 chars)
|
||||
const truncatedMsg = truncateToWidth(normalizedMessage, maxMsgWidth);
|
||||
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 = 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 = theme.fg("muted", ` (${this.selectedIndex + 1}/${this.messages.length})`);
|
||||
lines.push(scrollInfo);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
handleInput(keyData: string): void {
|
||||
// Up arrow - go to previous (older) message, wrap to bottom when at top
|
||||
if (keyData === "\x1b[A") {
|
||||
this.selectedIndex = this.selectedIndex === 0 ? this.messages.length - 1 : this.selectedIndex - 1;
|
||||
}
|
||||
// Down arrow - go to next (newer) message, wrap to top when at bottom
|
||||
else if (keyData === "\x1b[B") {
|
||||
this.selectedIndex = this.selectedIndex === this.messages.length - 1 ? 0 : this.selectedIndex + 1;
|
||||
}
|
||||
// Enter - select message and branch
|
||||
else if (keyData === "\r") {
|
||||
const selected = this.messages[this.selectedIndex];
|
||||
if (selected && this.onSelect) {
|
||||
this.onSelect(selected.index);
|
||||
}
|
||||
}
|
||||
// Escape - cancel
|
||||
else if (keyData === "\x1b") {
|
||||
if (this.onCancel) {
|
||||
this.onCancel();
|
||||
}
|
||||
}
|
||||
// Ctrl+C - cancel
|
||||
else if (keyData === "\x03") {
|
||||
if (this.onCancel) {
|
||||
this.onCancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Component that renders a user message selector for branching
|
||||
*/
|
||||
export class UserMessageSelectorComponent extends Container {
|
||||
private messageList: UserMessageList;
|
||||
|
||||
constructor(messages: UserMessageItem[], onSelect: (messageIndex: number) => void, onCancel: () => void) {
|
||||
super();
|
||||
|
||||
// Add header
|
||||
this.addChild(new Spacer(1));
|
||||
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));
|
||||
|
||||
// Create message list
|
||||
this.messageList = new UserMessageList(messages);
|
||||
this.messageList.onSelect = onSelect;
|
||||
this.messageList.onCancel = onCancel;
|
||||
|
||||
this.addChild(this.messageList);
|
||||
|
||||
// Add bottom border
|
||||
this.addChild(new Spacer(1));
|
||||
this.addChild(new DynamicBorder());
|
||||
|
||||
// Auto-cancel if no messages or only one message
|
||||
if (messages.length <= 1) {
|
||||
setTimeout(() => onCancel(), 100);
|
||||
}
|
||||
}
|
||||
|
||||
getMessageList(): UserMessageList {
|
||||
return this.messageList;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import { Container, Markdown, Spacer } from "@mariozechner/pi-tui";
|
||||
import { getMarkdownTheme, theme } from "../theme/theme.js";
|
||||
|
||||
/**
|
||||
* Component that renders a user message
|
||||
*/
|
||||
export class UserMessageComponent extends Container {
|
||||
constructor(text: string, isFirst: boolean) {
|
||||
super();
|
||||
|
||||
// Add spacer before user message (except first one)
|
||||
if (!isFirst) {
|
||||
this.addChild(new Spacer(1));
|
||||
}
|
||||
this.addChild(
|
||||
new Markdown(text, 1, 1, getMarkdownTheme(), {
|
||||
bgColor: (text: string) => theme.bg("userMessageBg", text),
|
||||
color: (text: string) => theme.fg("userMessageText", text),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -22,31 +22,31 @@ import {
|
|||
visibleWidth,
|
||||
} from "@mariozechner/pi-tui";
|
||||
import { exec } from "child_process";
|
||||
import { getChangelogPath, parseChangelog } from "../../changelog.js";
|
||||
import { copyToClipboard } from "../../clipboard.js";
|
||||
import { APP_NAME, getDebugLogPath, getOAuthPath } from "../../config.js";
|
||||
import type { AgentSession } from "../../core/agent-session.js";
|
||||
import { type BashExecutionMessage, isBashExecutionMessage } from "../../messages.js";
|
||||
import { invalidateOAuthCache } from "../../model-config.js";
|
||||
import { listOAuthProviders, login, logout, type SupportedOAuthProvider } from "../../oauth/index.js";
|
||||
import { getLatestCompactionEntry, SUMMARY_PREFIX, SUMMARY_SUFFIX } from "../../session-manager.js";
|
||||
import { getEditorTheme, getMarkdownTheme, onThemeChange, setTheme, theme } from "../../theme/theme.js";
|
||||
import type { TruncationResult } from "../../tools/truncate.js";
|
||||
import { AssistantMessageComponent } from "../../tui/assistant-message.js";
|
||||
import { BashExecutionComponent } from "../../tui/bash-execution.js";
|
||||
import { CompactionComponent } from "../../tui/compaction.js";
|
||||
import { CustomEditor } from "../../tui/custom-editor.js";
|
||||
import { DynamicBorder } from "../../tui/dynamic-border.js";
|
||||
import { FooterComponent } from "../../tui/footer.js";
|
||||
import { ModelSelectorComponent } from "../../tui/model-selector.js";
|
||||
import { OAuthSelectorComponent } from "../../tui/oauth-selector.js";
|
||||
import { QueueModeSelectorComponent } from "../../tui/queue-mode-selector.js";
|
||||
import { SessionSelectorComponent } from "../../tui/session-selector.js";
|
||||
import { ThemeSelectorComponent } from "../../tui/theme-selector.js";
|
||||
import { ThinkingSelectorComponent } from "../../tui/thinking-selector.js";
|
||||
import { ToolExecutionComponent } from "../../tui/tool-execution.js";
|
||||
import { UserMessageComponent } from "../../tui/user-message.js";
|
||||
import { UserMessageSelectorComponent } from "../../tui/user-message-selector.js";
|
||||
import { type BashExecutionMessage, isBashExecutionMessage } from "../../core/messages.js";
|
||||
import { invalidateOAuthCache } from "../../core/model-config.js";
|
||||
import { listOAuthProviders, login, logout, type SupportedOAuthProvider } from "../../core/oauth/index.js";
|
||||
import { getLatestCompactionEntry, SUMMARY_PREFIX, SUMMARY_SUFFIX } from "../../core/session-manager.js";
|
||||
import type { TruncationResult } from "../../core/tools/truncate.js";
|
||||
import { getChangelogPath, parseChangelog } from "../../utils/changelog.js";
|
||||
import { copyToClipboard } from "../../utils/clipboard.js";
|
||||
import { APP_NAME, getDebugLogPath, getOAuthPath } from "../../utils/config.js";
|
||||
import { AssistantMessageComponent } from "./components/assistant-message.js";
|
||||
import { BashExecutionComponent } from "./components/bash-execution.js";
|
||||
import { CompactionComponent } from "./components/compaction.js";
|
||||
import { CustomEditor } from "./components/custom-editor.js";
|
||||
import { DynamicBorder } from "./components/dynamic-border.js";
|
||||
import { FooterComponent } from "./components/footer.js";
|
||||
import { ModelSelectorComponent } from "./components/model-selector.js";
|
||||
import { OAuthSelectorComponent } from "./components/oauth-selector.js";
|
||||
import { QueueModeSelectorComponent } from "./components/queue-mode-selector.js";
|
||||
import { SessionSelectorComponent } from "./components/session-selector.js";
|
||||
import { ThemeSelectorComponent } from "./components/theme-selector.js";
|
||||
import { ThinkingSelectorComponent } from "./components/thinking-selector.js";
|
||||
import { ToolExecutionComponent } from "./components/tool-execution.js";
|
||||
import { UserMessageComponent } from "./components/user-message.js";
|
||||
import { UserMessageSelectorComponent } from "./components/user-message-selector.js";
|
||||
import { getEditorTheme, getMarkdownTheme, onThemeChange, setTheme, theme } from "./theme/theme.js";
|
||||
|
||||
export class InteractiveMode {
|
||||
private session: AgentSession;
|
||||
|
|
|
|||
73
packages/coding-agent/src/modes/interactive/theme/dark.json
Normal file
73
packages/coding-agent/src/modes/interactive/theme/dark.json
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
{
|
||||
"$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",
|
||||
"userMsgBg": "#343541",
|
||||
"toolPendingBg": "#282832",
|
||||
"toolSuccessBg": "#283228",
|
||||
"toolErrorBg": "#3c2828"
|
||||
},
|
||||
"colors": {
|
||||
"accent": "accent",
|
||||
"border": "blue",
|
||||
"borderAccent": "cyan",
|
||||
"borderMuted": "darkGray",
|
||||
"success": "green",
|
||||
"error": "red",
|
||||
"warning": "yellow",
|
||||
"muted": "gray",
|
||||
"dim": "dimGray",
|
||||
"text": "",
|
||||
|
||||
"userMessageBg": "userMsgBg",
|
||||
"userMessageText": "",
|
||||
"toolPendingBg": "toolPendingBg",
|
||||
"toolSuccessBg": "toolSuccessBg",
|
||||
"toolErrorBg": "toolErrorBg",
|
||||
"toolTitle": "",
|
||||
"toolOutput": "gray",
|
||||
|
||||
"mdHeading": "#f0c674",
|
||||
"mdLink": "#81a2be",
|
||||
"mdLinkUrl": "dimGray",
|
||||
"mdCode": "accent",
|
||||
"mdCodeBlock": "green",
|
||||
"mdCodeBlockBorder": "gray",
|
||||
"mdQuote": "gray",
|
||||
"mdQuoteBorder": "gray",
|
||||
"mdHr": "gray",
|
||||
"mdListBullet": "accent",
|
||||
|
||||
"toolDiffAdded": "green",
|
||||
"toolDiffRemoved": "red",
|
||||
"toolDiffContext": "gray",
|
||||
|
||||
"syntaxComment": "gray",
|
||||
"syntaxKeyword": "cyan",
|
||||
"syntaxFunction": "blue",
|
||||
"syntaxVariable": "",
|
||||
"syntaxString": "green",
|
||||
"syntaxNumber": "yellow",
|
||||
"syntaxType": "cyan",
|
||||
"syntaxOperator": "",
|
||||
"syntaxPunctuation": "gray",
|
||||
|
||||
"thinkingOff": "darkGray",
|
||||
"thinkingMinimal": "#6e6e6e",
|
||||
"thinkingLow": "#5f87af",
|
||||
"thinkingMedium": "#81a2be",
|
||||
"thinkingHigh": "#b294bb",
|
||||
"thinkingXhigh": "#d183e8",
|
||||
|
||||
"bashMode": "green"
|
||||
}
|
||||
}
|
||||
72
packages/coding-agent/src/modes/interactive/theme/light.json
Normal file
72
packages/coding-agent/src/modes/interactive/theme/light.json
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
{
|
||||
"$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/theme-schema.json",
|
||||
"name": "light",
|
||||
"vars": {
|
||||
"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": "teal",
|
||||
"border": "blue",
|
||||
"borderAccent": "teal",
|
||||
"borderMuted": "lightGray",
|
||||
"success": "green",
|
||||
"error": "red",
|
||||
"warning": "yellow",
|
||||
"muted": "mediumGray",
|
||||
"dim": "dimGray",
|
||||
"text": "",
|
||||
|
||||
"userMessageBg": "userMsgBg",
|
||||
"userMessageText": "",
|
||||
"toolPendingBg": "toolPendingBg",
|
||||
"toolSuccessBg": "toolSuccessBg",
|
||||
"toolErrorBg": "toolErrorBg",
|
||||
"toolTitle": "",
|
||||
"toolOutput": "mediumGray",
|
||||
|
||||
"mdHeading": "yellow",
|
||||
"mdLink": "blue",
|
||||
"mdLinkUrl": "dimGray",
|
||||
"mdCode": "teal",
|
||||
"mdCodeBlock": "green",
|
||||
"mdCodeBlockBorder": "mediumGray",
|
||||
"mdQuote": "mediumGray",
|
||||
"mdQuoteBorder": "mediumGray",
|
||||
"mdHr": "mediumGray",
|
||||
"mdListBullet": "green",
|
||||
|
||||
"toolDiffAdded": "green",
|
||||
"toolDiffRemoved": "red",
|
||||
"toolDiffContext": "mediumGray",
|
||||
|
||||
"syntaxComment": "mediumGray",
|
||||
"syntaxKeyword": "teal",
|
||||
"syntaxFunction": "blue",
|
||||
"syntaxVariable": "",
|
||||
"syntaxString": "green",
|
||||
"syntaxNumber": "yellow",
|
||||
"syntaxType": "teal",
|
||||
"syntaxOperator": "",
|
||||
"syntaxPunctuation": "mediumGray",
|
||||
|
||||
"thinkingOff": "lightGray",
|
||||
"thinkingMinimal": "#9e9e9e",
|
||||
"thinkingLow": "#5f87af",
|
||||
"thinkingMedium": "#5f8787",
|
||||
"thinkingHigh": "#875f87",
|
||||
"thinkingXhigh": "#8b008b",
|
||||
|
||||
"bashMode": "green"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,274 @@
|
|||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "Pi Coding Agent Theme",
|
||||
"description": "Theme schema for Pi coding agent",
|
||||
"type": "object",
|
||||
"required": ["name", "colors"],
|
||||
"properties": {
|
||||
"$schema": {
|
||||
"type": "string",
|
||||
"description": "JSON schema reference"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Theme name"
|
||||
},
|
||||
"vars": {
|
||||
"type": "object",
|
||||
"description": "Reusable color variables",
|
||||
"additionalProperties": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Hex color (#RRGGBB), variable reference, or empty string for terminal default"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 255,
|
||||
"description": "256-color palette index (0-255)"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"colors": {
|
||||
"type": "object",
|
||||
"description": "Theme color definitions (all required)",
|
||||
"required": [
|
||||
"accent",
|
||||
"border",
|
||||
"borderAccent",
|
||||
"borderMuted",
|
||||
"success",
|
||||
"error",
|
||||
"warning",
|
||||
"muted",
|
||||
"dim",
|
||||
"text",
|
||||
"userMessageBg",
|
||||
"userMessageText",
|
||||
"toolPendingBg",
|
||||
"toolSuccessBg",
|
||||
"toolErrorBg",
|
||||
"toolText",
|
||||
"mdHeading",
|
||||
"mdLink",
|
||||
"mdCode",
|
||||
"mdCodeBlock",
|
||||
"mdCodeBlockBorder",
|
||||
"mdQuote",
|
||||
"mdQuoteBorder",
|
||||
"mdHr",
|
||||
"mdListBullet",
|
||||
"toolDiffAdded",
|
||||
"toolDiffRemoved",
|
||||
"toolDiffContext",
|
||||
"syntaxComment",
|
||||
"syntaxKeyword",
|
||||
"syntaxFunction",
|
||||
"syntaxVariable",
|
||||
"syntaxString",
|
||||
"syntaxNumber",
|
||||
"syntaxType",
|
||||
"syntaxOperator",
|
||||
"syntaxPunctuation"
|
||||
],
|
||||
"properties": {
|
||||
"accent": {
|
||||
"$ref": "#/$defs/colorValue",
|
||||
"description": "Primary accent color (logo, selected items, cursor)"
|
||||
},
|
||||
"border": {
|
||||
"$ref": "#/$defs/colorValue",
|
||||
"description": "Normal borders"
|
||||
},
|
||||
"borderAccent": {
|
||||
"$ref": "#/$defs/colorValue",
|
||||
"description": "Highlighted borders"
|
||||
},
|
||||
"borderMuted": {
|
||||
"$ref": "#/$defs/colorValue",
|
||||
"description": "Subtle borders"
|
||||
},
|
||||
"success": {
|
||||
"$ref": "#/$defs/colorValue",
|
||||
"description": "Success states"
|
||||
},
|
||||
"error": {
|
||||
"$ref": "#/$defs/colorValue",
|
||||
"description": "Error states"
|
||||
},
|
||||
"warning": {
|
||||
"$ref": "#/$defs/colorValue",
|
||||
"description": "Warning states"
|
||||
},
|
||||
"muted": {
|
||||
"$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)"
|
||||
},
|
||||
"userMessageBg": {
|
||||
"$ref": "#/$defs/colorValue",
|
||||
"description": "User message background"
|
||||
},
|
||||
"userMessageText": {
|
||||
"$ref": "#/$defs/colorValue",
|
||||
"description": "User message text color"
|
||||
},
|
||||
"toolPendingBg": {
|
||||
"$ref": "#/$defs/colorValue",
|
||||
"description": "Tool execution box (pending state)"
|
||||
},
|
||||
"toolSuccessBg": {
|
||||
"$ref": "#/$defs/colorValue",
|
||||
"description": "Tool execution box (success state)"
|
||||
},
|
||||
"toolErrorBg": {
|
||||
"$ref": "#/$defs/colorValue",
|
||||
"description": "Tool execution box (error state)"
|
||||
},
|
||||
"toolText": {
|
||||
"$ref": "#/$defs/colorValue",
|
||||
"description": "Tool execution box text color"
|
||||
},
|
||||
"mdHeading": {
|
||||
"$ref": "#/$defs/colorValue",
|
||||
"description": "Markdown heading text"
|
||||
},
|
||||
"mdLink": {
|
||||
"$ref": "#/$defs/colorValue",
|
||||
"description": "Markdown link text"
|
||||
},
|
||||
"mdCode": {
|
||||
"$ref": "#/$defs/colorValue",
|
||||
"description": "Markdown inline code"
|
||||
},
|
||||
"mdCodeBlock": {
|
||||
"$ref": "#/$defs/colorValue",
|
||||
"description": "Markdown code block content"
|
||||
},
|
||||
"mdCodeBlockBorder": {
|
||||
"$ref": "#/$defs/colorValue",
|
||||
"description": "Markdown code block fences"
|
||||
},
|
||||
"mdQuote": {
|
||||
"$ref": "#/$defs/colorValue",
|
||||
"description": "Markdown blockquote text"
|
||||
},
|
||||
"mdQuoteBorder": {
|
||||
"$ref": "#/$defs/colorValue",
|
||||
"description": "Markdown blockquote border"
|
||||
},
|
||||
"mdHr": {
|
||||
"$ref": "#/$defs/colorValue",
|
||||
"description": "Markdown horizontal rule"
|
||||
},
|
||||
"mdListBullet": {
|
||||
"$ref": "#/$defs/colorValue",
|
||||
"description": "Markdown list bullets/numbers"
|
||||
},
|
||||
"toolDiffAdded": {
|
||||
"$ref": "#/$defs/colorValue",
|
||||
"description": "Added lines in tool diffs"
|
||||
},
|
||||
"toolDiffRemoved": {
|
||||
"$ref": "#/$defs/colorValue",
|
||||
"description": "Removed lines in tool diffs"
|
||||
},
|
||||
"toolDiffContext": {
|
||||
"$ref": "#/$defs/colorValue",
|
||||
"description": "Context lines in tool diffs"
|
||||
},
|
||||
"syntaxComment": {
|
||||
"$ref": "#/$defs/colorValue",
|
||||
"description": "Syntax highlighting: comments"
|
||||
},
|
||||
"syntaxKeyword": {
|
||||
"$ref": "#/$defs/colorValue",
|
||||
"description": "Syntax highlighting: keywords"
|
||||
},
|
||||
"syntaxFunction": {
|
||||
"$ref": "#/$defs/colorValue",
|
||||
"description": "Syntax highlighting: function names"
|
||||
},
|
||||
"syntaxVariable": {
|
||||
"$ref": "#/$defs/colorValue",
|
||||
"description": "Syntax highlighting: variable names"
|
||||
},
|
||||
"syntaxString": {
|
||||
"$ref": "#/$defs/colorValue",
|
||||
"description": "Syntax highlighting: string literals"
|
||||
},
|
||||
"syntaxNumber": {
|
||||
"$ref": "#/$defs/colorValue",
|
||||
"description": "Syntax highlighting: number literals"
|
||||
},
|
||||
"syntaxType": {
|
||||
"$ref": "#/$defs/colorValue",
|
||||
"description": "Syntax highlighting: type names"
|
||||
},
|
||||
"syntaxOperator": {
|
||||
"$ref": "#/$defs/colorValue",
|
||||
"description": "Syntax highlighting: operators"
|
||||
},
|
||||
"syntaxPunctuation": {
|
||||
"$ref": "#/$defs/colorValue",
|
||||
"description": "Syntax highlighting: punctuation"
|
||||
},
|
||||
"thinkingOff": {
|
||||
"$ref": "#/$defs/colorValue",
|
||||
"description": "Thinking level border: off"
|
||||
},
|
||||
"thinkingMinimal": {
|
||||
"$ref": "#/$defs/colorValue",
|
||||
"description": "Thinking level border: minimal"
|
||||
},
|
||||
"thinkingLow": {
|
||||
"$ref": "#/$defs/colorValue",
|
||||
"description": "Thinking level border: low"
|
||||
},
|
||||
"thinkingMedium": {
|
||||
"$ref": "#/$defs/colorValue",
|
||||
"description": "Thinking level border: medium"
|
||||
},
|
||||
"thinkingHigh": {
|
||||
"$ref": "#/$defs/colorValue",
|
||||
"description": "Thinking level border: high"
|
||||
},
|
||||
"thinkingXhigh": {
|
||||
"$ref": "#/$defs/colorValue",
|
||||
"description": "Thinking level border: xhigh (OpenAI codex-max only)"
|
||||
},
|
||||
"bashMode": {
|
||||
"$ref": "#/$defs/colorValue",
|
||||
"description": "Editor border color in bash mode"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"$defs": {
|
||||
"colorValue": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Hex color (#RRGGBB), variable reference, or empty string for terminal default"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 255,
|
||||
"description": "256-color palette index (0-255)"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
597
packages/coding-agent/src/modes/interactive/theme/theme.ts
Normal file
597
packages/coding-agent/src/modes/interactive/theme/theme.ts
Normal file
|
|
@ -0,0 +1,597 @@
|
|||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
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";
|
||||
import { getCustomThemesDir, getThemesDir } from "../../../utils/config.js";
|
||||
|
||||
// ============================================================================
|
||||
// Types & Schema
|
||||
// ============================================================================
|
||||
|
||||
const ColorValueSchema = Type.Union([
|
||||
Type.String(), // hex "#ff0000", var ref "primary", or empty ""
|
||||
Type.Integer({ minimum: 0, maximum: 255 }), // 256-color index
|
||||
]);
|
||||
|
||||
type ColorValue = Static<typeof ColorValueSchema>;
|
||||
|
||||
const ThemeJsonSchema = Type.Object({
|
||||
$schema: Type.Optional(Type.String()),
|
||||
name: Type.String(),
|
||||
vars: Type.Optional(Type.Record(Type.String(), ColorValueSchema)),
|
||||
colors: Type.Object({
|
||||
// Core UI (10 colors)
|
||||
accent: ColorValueSchema,
|
||||
border: ColorValueSchema,
|
||||
borderAccent: ColorValueSchema,
|
||||
borderMuted: ColorValueSchema,
|
||||
success: ColorValueSchema,
|
||||
error: ColorValueSchema,
|
||||
warning: ColorValueSchema,
|
||||
muted: ColorValueSchema,
|
||||
dim: ColorValueSchema,
|
||||
text: ColorValueSchema,
|
||||
// Backgrounds & Content Text (7 colors)
|
||||
userMessageBg: ColorValueSchema,
|
||||
userMessageText: ColorValueSchema,
|
||||
toolPendingBg: ColorValueSchema,
|
||||
toolSuccessBg: ColorValueSchema,
|
||||
toolErrorBg: ColorValueSchema,
|
||||
toolTitle: ColorValueSchema,
|
||||
toolOutput: ColorValueSchema,
|
||||
// Markdown (10 colors)
|
||||
mdHeading: ColorValueSchema,
|
||||
mdLink: ColorValueSchema,
|
||||
mdLinkUrl: ColorValueSchema,
|
||||
mdCode: ColorValueSchema,
|
||||
mdCodeBlock: ColorValueSchema,
|
||||
mdCodeBlockBorder: ColorValueSchema,
|
||||
mdQuote: ColorValueSchema,
|
||||
mdQuoteBorder: ColorValueSchema,
|
||||
mdHr: ColorValueSchema,
|
||||
mdListBullet: ColorValueSchema,
|
||||
// Tool Diffs (3 colors)
|
||||
toolDiffAdded: ColorValueSchema,
|
||||
toolDiffRemoved: ColorValueSchema,
|
||||
toolDiffContext: ColorValueSchema,
|
||||
// Syntax Highlighting (9 colors)
|
||||
syntaxComment: ColorValueSchema,
|
||||
syntaxKeyword: ColorValueSchema,
|
||||
syntaxFunction: ColorValueSchema,
|
||||
syntaxVariable: ColorValueSchema,
|
||||
syntaxString: ColorValueSchema,
|
||||
syntaxNumber: ColorValueSchema,
|
||||
syntaxType: ColorValueSchema,
|
||||
syntaxOperator: ColorValueSchema,
|
||||
syntaxPunctuation: ColorValueSchema,
|
||||
// Thinking Level Borders (6 colors)
|
||||
thinkingOff: ColorValueSchema,
|
||||
thinkingMinimal: ColorValueSchema,
|
||||
thinkingLow: ColorValueSchema,
|
||||
thinkingMedium: ColorValueSchema,
|
||||
thinkingHigh: ColorValueSchema,
|
||||
thinkingXhigh: ColorValueSchema,
|
||||
// Bash Mode (1 color)
|
||||
bashMode: ColorValueSchema,
|
||||
}),
|
||||
});
|
||||
|
||||
type ThemeJson = Static<typeof ThemeJsonSchema>;
|
||||
|
||||
const validateThemeJson = TypeCompiler.Compile(ThemeJsonSchema);
|
||||
|
||||
export type ThemeColor =
|
||||
| "accent"
|
||||
| "border"
|
||||
| "borderAccent"
|
||||
| "borderMuted"
|
||||
| "success"
|
||||
| "error"
|
||||
| "warning"
|
||||
| "muted"
|
||||
| "dim"
|
||||
| "text"
|
||||
| "userMessageText"
|
||||
| "toolTitle"
|
||||
| "toolOutput"
|
||||
| "mdHeading"
|
||||
| "mdLink"
|
||||
| "mdLinkUrl"
|
||||
| "mdCode"
|
||||
| "mdCodeBlock"
|
||||
| "mdCodeBlockBorder"
|
||||
| "mdQuote"
|
||||
| "mdQuoteBorder"
|
||||
| "mdHr"
|
||||
| "mdListBullet"
|
||||
| "toolDiffAdded"
|
||||
| "toolDiffRemoved"
|
||||
| "toolDiffContext"
|
||||
| "syntaxComment"
|
||||
| "syntaxKeyword"
|
||||
| "syntaxFunction"
|
||||
| "syntaxVariable"
|
||||
| "syntaxString"
|
||||
| "syntaxNumber"
|
||||
| "syntaxType"
|
||||
| "syntaxOperator"
|
||||
| "syntaxPunctuation"
|
||||
| "thinkingOff"
|
||||
| "thinkingMinimal"
|
||||
| "thinkingLow"
|
||||
| "thinkingMedium"
|
||||
| "thinkingHigh"
|
||||
| "thinkingXhigh"
|
||||
| "bashMode";
|
||||
|
||||
export type ThemeBg = "userMessageBg" | "toolPendingBg" | "toolSuccessBg" | "toolErrorBg";
|
||||
|
||||
type ColorMode = "truecolor" | "256color";
|
||||
|
||||
// ============================================================================
|
||||
// Color Utilities
|
||||
// ============================================================================
|
||||
|
||||
function detectColorMode(): ColorMode {
|
||||
const colorterm = process.env.COLORTERM;
|
||||
if (colorterm === "truecolor" || colorterm === "24bit") {
|
||||
return "truecolor";
|
||||
}
|
||||
// Windows Terminal supports truecolor
|
||||
if (process.env.WT_SESSION) {
|
||||
return "truecolor";
|
||||
}
|
||||
const term = process.env.TERM || "";
|
||||
if (term.includes("256color")) {
|
||||
return "256color";
|
||||
}
|
||||
return "256color";
|
||||
}
|
||||
|
||||
function hexToRgb(hex: string): { r: number; g: number; b: number } {
|
||||
const cleaned = hex.replace("#", "");
|
||||
if (cleaned.length !== 6) {
|
||||
throw new Error(`Invalid hex color: ${hex}`);
|
||||
}
|
||||
const r = parseInt(cleaned.substring(0, 2), 16);
|
||||
const g = parseInt(cleaned.substring(2, 4), 16);
|
||||
const b = parseInt(cleaned.substring(4, 6), 16);
|
||||
if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) {
|
||||
throw new Error(`Invalid hex color: ${hex}`);
|
||||
}
|
||||
return { r, g, b };
|
||||
}
|
||||
|
||||
function rgbTo256(r: number, g: number, b: number): number {
|
||||
const rIndex = Math.round((r / 255) * 5);
|
||||
const gIndex = Math.round((g / 255) * 5);
|
||||
const bIndex = Math.round((b / 255) * 5);
|
||||
return 16 + 36 * rIndex + 6 * gIndex + bIndex;
|
||||
}
|
||||
|
||||
function hexTo256(hex: string): number {
|
||||
const { r, g, b } = hexToRgb(hex);
|
||||
return rgbTo256(r, g, b);
|
||||
}
|
||||
|
||||
function fgAnsi(color: string | number, mode: ColorMode): string {
|
||||
if (color === "") return "\x1b[39m";
|
||||
if (typeof color === "number") return `\x1b[38;5;${color}m`;
|
||||
if (color.startsWith("#")) {
|
||||
if (mode === "truecolor") {
|
||||
const { r, g, b } = hexToRgb(color);
|
||||
return `\x1b[38;2;${r};${g};${b}m`;
|
||||
} else {
|
||||
const index = hexTo256(color);
|
||||
return `\x1b[38;5;${index}m`;
|
||||
}
|
||||
}
|
||||
throw new Error(`Invalid color value: ${color}`);
|
||||
}
|
||||
|
||||
function bgAnsi(color: string | number, mode: ColorMode): string {
|
||||
if (color === "") return "\x1b[49m";
|
||||
if (typeof color === "number") return `\x1b[48;5;${color}m`;
|
||||
if (color.startsWith("#")) {
|
||||
if (mode === "truecolor") {
|
||||
const { r, g, b } = hexToRgb(color);
|
||||
return `\x1b[48;2;${r};${g};${b}m`;
|
||||
} else {
|
||||
const index = hexTo256(color);
|
||||
return `\x1b[48;5;${index}m`;
|
||||
}
|
||||
}
|
||||
throw new Error(`Invalid color value: ${color}`);
|
||||
}
|
||||
|
||||
function resolveVarRefs(
|
||||
value: ColorValue,
|
||||
vars: Record<string, ColorValue>,
|
||||
visited = new Set<string>(),
|
||||
): string | number {
|
||||
if (typeof value === "number" || value === "" || value.startsWith("#")) {
|
||||
return value;
|
||||
}
|
||||
if (visited.has(value)) {
|
||||
throw new Error(`Circular variable reference detected: ${value}`);
|
||||
}
|
||||
if (!(value in vars)) {
|
||||
throw new Error(`Variable reference not found: ${value}`);
|
||||
}
|
||||
visited.add(value);
|
||||
return resolveVarRefs(vars[value], vars, visited);
|
||||
}
|
||||
|
||||
function resolveThemeColors<T extends Record<string, ColorValue>>(
|
||||
colors: T,
|
||||
vars: Record<string, ColorValue> = {},
|
||||
): Record<keyof T, string | number> {
|
||||
const resolved: Record<string, string | number> = {};
|
||||
for (const [key, value] of Object.entries(colors)) {
|
||||
resolved[key] = resolveVarRefs(value, vars);
|
||||
}
|
||||
return resolved as Record<keyof T, string | number>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Theme Class
|
||||
// ============================================================================
|
||||
|
||||
export class Theme {
|
||||
private fgColors: Map<ThemeColor, string>;
|
||||
private bgColors: Map<ThemeBg, string>;
|
||||
private mode: ColorMode;
|
||||
|
||||
constructor(
|
||||
fgColors: Record<ThemeColor, string | number>,
|
||||
bgColors: Record<ThemeBg, string | number>,
|
||||
mode: ColorMode,
|
||||
) {
|
||||
this.mode = mode;
|
||||
this.fgColors = new Map();
|
||||
for (const [key, value] of Object.entries(fgColors) as [ThemeColor, string | number][]) {
|
||||
this.fgColors.set(key, fgAnsi(value, mode));
|
||||
}
|
||||
this.bgColors = new Map();
|
||||
for (const [key, value] of Object.entries(bgColors) as [ThemeBg, string | number][]) {
|
||||
this.bgColors.set(key, bgAnsi(value, mode));
|
||||
}
|
||||
}
|
||||
|
||||
fg(color: ThemeColor, text: string): string {
|
||||
const ansi = this.fgColors.get(color);
|
||||
if (!ansi) throw new Error(`Unknown theme color: ${color}`);
|
||||
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}\x1b[49m`; // Reset only background color
|
||||
}
|
||||
|
||||
bold(text: string): string {
|
||||
return chalk.bold(text);
|
||||
}
|
||||
|
||||
italic(text: string): string {
|
||||
return chalk.italic(text);
|
||||
}
|
||||
|
||||
underline(text: string): string {
|
||||
return chalk.underline(text);
|
||||
}
|
||||
|
||||
getFgAnsi(color: ThemeColor): string {
|
||||
const ansi = this.fgColors.get(color);
|
||||
if (!ansi) throw new Error(`Unknown theme color: ${color}`);
|
||||
return ansi;
|
||||
}
|
||||
|
||||
getBgAnsi(color: ThemeBg): string {
|
||||
const ansi = this.bgColors.get(color);
|
||||
if (!ansi) throw new Error(`Unknown theme background color: ${color}`);
|
||||
return ansi;
|
||||
}
|
||||
|
||||
getColorMode(): ColorMode {
|
||||
return this.mode;
|
||||
}
|
||||
|
||||
getThinkingBorderColor(level: "off" | "minimal" | "low" | "medium" | "high" | "xhigh"): (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);
|
||||
case "xhigh":
|
||||
return (str: string) => this.fg("thinkingXhigh", str);
|
||||
default:
|
||||
return (str: string) => this.fg("thinkingOff", str);
|
||||
}
|
||||
}
|
||||
|
||||
getBashModeBorderColor(): (str: string) => string {
|
||||
return (str: string) => this.fg("bashMode", str);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Theme Loading
|
||||
// ============================================================================
|
||||
|
||||
let BUILTIN_THEMES: Record<string, ThemeJson> | undefined;
|
||||
|
||||
function getBuiltinThemes(): Record<string, ThemeJson> {
|
||||
if (!BUILTIN_THEMES) {
|
||||
const themesDir = getThemesDir();
|
||||
const darkPath = path.join(themesDir, "dark.json");
|
||||
const lightPath = path.join(themesDir, "light.json");
|
||||
BUILTIN_THEMES = {
|
||||
dark: JSON.parse(fs.readFileSync(darkPath, "utf-8")) as ThemeJson,
|
||||
light: JSON.parse(fs.readFileSync(lightPath, "utf-8")) as ThemeJson,
|
||||
};
|
||||
}
|
||||
return BUILTIN_THEMES;
|
||||
}
|
||||
|
||||
export function getAvailableThemes(): string[] {
|
||||
const themes = new Set<string>(Object.keys(getBuiltinThemes()));
|
||||
const customThemesDir = getCustomThemesDir();
|
||||
if (fs.existsSync(customThemesDir)) {
|
||||
const files = fs.readdirSync(customThemesDir);
|
||||
for (const file of files) {
|
||||
if (file.endsWith(".json")) {
|
||||
themes.add(file.slice(0, -5));
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(themes).sort();
|
||||
}
|
||||
|
||||
function loadThemeJson(name: string): ThemeJson {
|
||||
const builtinThemes = getBuiltinThemes();
|
||||
if (name in builtinThemes) {
|
||||
return builtinThemes[name];
|
||||
}
|
||||
const customThemesDir = getCustomThemesDir();
|
||||
const themePath = path.join(customThemesDir, `${name}.json`);
|
||||
if (!fs.existsSync(themePath)) {
|
||||
throw new Error(`Theme not found: ${name}`);
|
||||
}
|
||||
const content = fs.readFileSync(themePath, "utf-8");
|
||||
let json: unknown;
|
||||
try {
|
||||
json = JSON.parse(content);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to parse theme ${name}: ${error}`);
|
||||
}
|
||||
if (!validateThemeJson.Check(json)) {
|
||||
const errors = Array.from(validateThemeJson.Errors(json));
|
||||
const missingColors: string[] = [];
|
||||
const otherErrors: string[] = [];
|
||||
|
||||
for (const e of errors) {
|
||||
// Check for missing required color properties
|
||||
const match = e.path.match(/^\/colors\/(\w+)$/);
|
||||
if (match && e.message.includes("Required")) {
|
||||
missingColors.push(match[1]);
|
||||
} else {
|
||||
otherErrors.push(` - ${e.path}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
let errorMessage = `Invalid theme "${name}":\n`;
|
||||
if (missingColors.length > 0) {
|
||||
errorMessage += `\nMissing required color tokens:\n`;
|
||||
errorMessage += missingColors.map((c) => ` - ${c}`).join("\n");
|
||||
errorMessage += `\n\nPlease add these colors to your theme's "colors" object.`;
|
||||
errorMessage += `\nSee the built-in themes (dark.json, light.json) for reference values.`;
|
||||
}
|
||||
if (otherErrors.length > 0) {
|
||||
errorMessage += `\n\nOther errors:\n${otherErrors.join("\n")}`;
|
||||
}
|
||||
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
return json as ThemeJson;
|
||||
}
|
||||
|
||||
function createTheme(themeJson: ThemeJson, mode?: ColorMode): Theme {
|
||||
const colorMode = mode ?? detectColorMode();
|
||||
const resolvedColors = resolveThemeColors(themeJson.colors, themeJson.vars);
|
||||
const fgColors: Record<ThemeColor, string | number> = {} as Record<ThemeColor, string | number>;
|
||||
const bgColors: Record<ThemeBg, string | number> = {} as Record<ThemeBg, string | number>;
|
||||
const bgColorKeys: Set<string> = new Set(["userMessageBg", "toolPendingBg", "toolSuccessBg", "toolErrorBg"]);
|
||||
for (const [key, value] of Object.entries(resolvedColors)) {
|
||||
if (bgColorKeys.has(key)) {
|
||||
bgColors[key as ThemeBg] = value;
|
||||
} else {
|
||||
fgColors[key as ThemeColor] = value;
|
||||
}
|
||||
}
|
||||
return new Theme(fgColors, bgColors, colorMode);
|
||||
}
|
||||
|
||||
function loadTheme(name: string, mode?: ColorMode): Theme {
|
||||
const themeJson = loadThemeJson(name);
|
||||
return createTheme(themeJson, mode);
|
||||
}
|
||||
|
||||
function detectTerminalBackground(): "dark" | "light" {
|
||||
const colorfgbg = process.env.COLORFGBG || "";
|
||||
if (colorfgbg) {
|
||||
const parts = colorfgbg.split(";");
|
||||
if (parts.length >= 2) {
|
||||
const bg = parseInt(parts[1], 10);
|
||||
if (!Number.isNaN(bg)) {
|
||||
const result = bg < 8 ? "dark" : "light";
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
return "dark";
|
||||
}
|
||||
|
||||
function getDefaultTheme(): string {
|
||||
return detectTerminalBackground();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Global Theme Instance
|
||||
// ============================================================================
|
||||
|
||||
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();
|
||||
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): { 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 customThemesDir = getCustomThemesDir();
|
||||
const themeFile = path.join(customThemesDir, `${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;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TUI Helpers
|
||||
// ============================================================================
|
||||
|
||||
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),
|
||||
quote: (text: string) => theme.fg("mdQuote", text),
|
||||
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(),
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue