chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:18:19 +08:00
commit f840617a3f
1018 changed files with 268235 additions and 0 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 526 KiB

@@ -0,0 +1,382 @@
/**
* Armin says hi! A fun easter egg with animated XBM art.
*/
import type { Component, TUI } from "@earendil-works/pi-tui";
import { theme } from "../theme/theme.ts";
// XBM image: 31x36 pixels, LSB first, 1=background, 0=foreground
const WIDTH = 31;
const HEIGHT = 36;
const BITS = [
0xff, 0xff, 0xff, 0x7f, 0xff, 0xf0, 0xff, 0x7f, 0xff, 0xed, 0xff, 0x7f, 0xff, 0xdb, 0xff, 0x7f, 0xff, 0xb7, 0xff,
0x7f, 0xff, 0x77, 0xfe, 0x7f, 0x3f, 0xf8, 0xfe, 0x7f, 0xdf, 0xff, 0xfe, 0x7f, 0xdf, 0x3f, 0xfc, 0x7f, 0x9f, 0xc3,
0xfb, 0x7f, 0x6f, 0xfc, 0xf4, 0x7f, 0xf7, 0x0f, 0xf7, 0x7f, 0xf7, 0xff, 0xf7, 0x7f, 0xf7, 0xff, 0xe3, 0x7f, 0xf7,
0x07, 0xe8, 0x7f, 0xef, 0xf8, 0x67, 0x70, 0x0f, 0xff, 0xbb, 0x6f, 0xf1, 0x00, 0xd0, 0x5b, 0xfd, 0x3f, 0xec, 0x53,
0xc1, 0xff, 0xef, 0x57, 0x9f, 0xfd, 0xee, 0x5f, 0x9f, 0xfc, 0xae, 0x5f, 0x1f, 0x78, 0xac, 0x5f, 0x3f, 0x00, 0x50,
0x6c, 0x7f, 0x00, 0xdc, 0x77, 0xff, 0xc0, 0x3f, 0x78, 0xff, 0x01, 0xf8, 0x7f, 0xff, 0x03, 0x9c, 0x78, 0xff, 0x07,
0x8c, 0x7c, 0xff, 0x0f, 0xce, 0x78, 0xff, 0xff, 0xcf, 0x7f, 0xff, 0xff, 0xcf, 0x78, 0xff, 0xff, 0xdf, 0x78, 0xff,
0xff, 0xdf, 0x7d, 0xff, 0xff, 0x3f, 0x7e, 0xff, 0xff, 0xff, 0x7f,
];
const BYTES_PER_ROW = Math.ceil(WIDTH / 8);
const DISPLAY_HEIGHT = Math.ceil(HEIGHT / 2); // Half-block rendering
type Effect = "typewriter" | "scanline" | "rain" | "fade" | "crt" | "glitch" | "dissolve";
const EFFECTS: Effect[] = ["typewriter", "scanline", "rain", "fade", "crt", "glitch", "dissolve"];
// Get pixel at (x, y): true = foreground, false = background
function getPixel(x: number, y: number): boolean {
if (y >= HEIGHT) return false;
const byteIndex = y * BYTES_PER_ROW + Math.floor(x / 8);
const bitIndex = x % 8;
return ((BITS[byteIndex] >> bitIndex) & 1) === 0;
}
// Get the character for a cell (2 vertical pixels packed)
function getChar(x: number, row: number): string {
const upper = getPixel(x, row * 2);
const lower = getPixel(x, row * 2 + 1);
if (upper && lower) return "█";
if (upper) return "▀";
if (lower) return "▄";
return " ";
}
// Build the final image grid
function buildFinalGrid(): string[][] {
const grid: string[][] = [];
for (let row = 0; row < DISPLAY_HEIGHT; row++) {
const line: string[] = [];
for (let x = 0; x < WIDTH; x++) {
line.push(getChar(x, row));
}
grid.push(line);
}
return grid;
}
export class ArminComponent implements Component {
private ui: TUI;
private interval: ReturnType<typeof setInterval> | null = null;
private effect: Effect;
private finalGrid: string[][];
private currentGrid: string[][];
private effectState: Record<string, unknown> = {};
private cachedLines: string[] = [];
private cachedWidth = 0;
private gridVersion = 0;
private cachedVersion = -1;
constructor(ui: TUI) {
this.ui = ui;
this.effect = EFFECTS[Math.floor(Math.random() * EFFECTS.length)];
this.finalGrid = buildFinalGrid();
this.currentGrid = this.createEmptyGrid();
this.initEffect();
this.startAnimation();
}
invalidate(): void {
this.cachedWidth = 0;
}
render(width: number): string[] {
if (width === this.cachedWidth && this.cachedVersion === this.gridVersion) {
return this.cachedLines;
}
const padding = 1;
const availableWidth = width - padding;
this.cachedLines = this.currentGrid.map((row) => {
// Clip row to available width before applying color
const clipped = row.slice(0, availableWidth).join("");
const padRight = Math.max(0, width - padding - clipped.length);
return ` ${theme.fg("accent", clipped)}${" ".repeat(padRight)}`;
});
// Add "ARMIN SAYS HI" at the end
const message = "ARMIN SAYS HI";
const msgPadRight = Math.max(0, width - padding - message.length);
this.cachedLines.push(` ${theme.fg("accent", message)}${" ".repeat(msgPadRight)}`);
this.cachedWidth = width;
this.cachedVersion = this.gridVersion;
return this.cachedLines;
}
private createEmptyGrid(): string[][] {
return Array.from({ length: DISPLAY_HEIGHT }, () => Array(WIDTH).fill(" "));
}
private initEffect(): void {
switch (this.effect) {
case "typewriter":
this.effectState = { pos: 0 };
break;
case "scanline":
this.effectState = { row: 0 };
break;
case "rain":
// Track falling position for each column
this.effectState = {
drops: Array.from({ length: WIDTH }, () => ({
y: -Math.floor(Math.random() * DISPLAY_HEIGHT * 2),
settled: 0,
})),
};
break;
case "fade": {
// Shuffle all pixel positions
const positions: [number, number][] = [];
for (let row = 0; row < DISPLAY_HEIGHT; row++) {
for (let x = 0; x < WIDTH; x++) {
positions.push([row, x]);
}
}
// Fisher-Yates shuffle
for (let i = positions.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[positions[i], positions[j]] = [positions[j], positions[i]];
}
this.effectState = { positions, idx: 0 };
break;
}
case "crt":
this.effectState = { expansion: 0 };
break;
case "glitch":
this.effectState = { phase: 0, glitchFrames: 8 };
break;
case "dissolve": {
// Start with random noise
this.currentGrid = Array.from({ length: DISPLAY_HEIGHT }, () =>
Array.from({ length: WIDTH }, () => {
const chars = [" ", "░", "▒", "▓", "█", "▀", "▄"];
return chars[Math.floor(Math.random() * chars.length)];
}),
);
// Shuffle positions for gradual resolve
const dissolvePositions: [number, number][] = [];
for (let row = 0; row < DISPLAY_HEIGHT; row++) {
for (let x = 0; x < WIDTH; x++) {
dissolvePositions.push([row, x]);
}
}
for (let i = dissolvePositions.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[dissolvePositions[i], dissolvePositions[j]] = [dissolvePositions[j], dissolvePositions[i]];
}
this.effectState = { positions: dissolvePositions, idx: 0 };
break;
}
}
}
private startAnimation(): void {
const fps = this.effect === "glitch" ? 60 : 30;
this.interval = setInterval(() => {
const done = this.tickEffect();
this.updateDisplay();
this.ui.requestRender();
if (done) {
this.stopAnimation();
}
}, 1000 / fps);
}
private stopAnimation(): void {
if (this.interval) {
clearInterval(this.interval);
this.interval = null;
}
}
private tickEffect(): boolean {
switch (this.effect) {
case "typewriter":
return this.tickTypewriter();
case "scanline":
return this.tickScanline();
case "rain":
return this.tickRain();
case "fade":
return this.tickFade();
case "crt":
return this.tickCrt();
case "glitch":
return this.tickGlitch();
case "dissolve":
return this.tickDissolve();
default:
return true;
}
}
private tickTypewriter(): boolean {
const state = this.effectState as { pos: number };
const pixelsPerFrame = 3;
for (let i = 0; i < pixelsPerFrame; i++) {
const row = Math.floor(state.pos / WIDTH);
const x = state.pos % WIDTH;
if (row >= DISPLAY_HEIGHT) return true;
this.currentGrid[row][x] = this.finalGrid[row][x];
state.pos++;
}
return false;
}
private tickScanline(): boolean {
const state = this.effectState as { row: number };
if (state.row >= DISPLAY_HEIGHT) return true;
// Copy row
for (let x = 0; x < WIDTH; x++) {
this.currentGrid[state.row][x] = this.finalGrid[state.row][x];
}
state.row++;
return false;
}
private tickRain(): boolean {
const state = this.effectState as {
drops: { y: number; settled: number }[];
};
let allSettled = true;
this.currentGrid = this.createEmptyGrid();
for (let x = 0; x < WIDTH; x++) {
const drop = state.drops[x];
// Draw settled pixels
for (let row = DISPLAY_HEIGHT - 1; row >= DISPLAY_HEIGHT - drop.settled; row--) {
if (row >= 0) {
this.currentGrid[row][x] = this.finalGrid[row][x];
}
}
// Check if this column is done
if (drop.settled >= DISPLAY_HEIGHT) continue;
allSettled = false;
// Find the target row for this column (lowest non-space pixel)
let targetRow = -1;
for (let row = DISPLAY_HEIGHT - 1 - drop.settled; row >= 0; row--) {
if (this.finalGrid[row][x] !== " ") {
targetRow = row;
break;
}
}
// Move drop down
drop.y++;
// Draw falling drop
if (drop.y >= 0 && drop.y < DISPLAY_HEIGHT) {
if (targetRow >= 0 && drop.y >= targetRow) {
// Settle
drop.settled = DISPLAY_HEIGHT - targetRow;
drop.y = -Math.floor(Math.random() * 5) - 1;
} else {
// Still falling
this.currentGrid[drop.y][x] = "▓";
}
}
}
return allSettled;
}
private tickFade(): boolean {
const state = this.effectState as { positions: [number, number][]; idx: number };
const pixelsPerFrame = 15;
for (let i = 0; i < pixelsPerFrame; i++) {
if (state.idx >= state.positions.length) return true;
const [row, x] = state.positions[state.idx];
this.currentGrid[row][x] = this.finalGrid[row][x];
state.idx++;
}
return false;
}
private tickCrt(): boolean {
const state = this.effectState as { expansion: number };
const midRow = Math.floor(DISPLAY_HEIGHT / 2);
this.currentGrid = this.createEmptyGrid();
// Draw from middle expanding outward
const top = midRow - state.expansion;
const bottom = midRow + state.expansion;
for (let row = Math.max(0, top); row <= Math.min(DISPLAY_HEIGHT - 1, bottom); row++) {
for (let x = 0; x < WIDTH; x++) {
this.currentGrid[row][x] = this.finalGrid[row][x];
}
}
state.expansion++;
return state.expansion > DISPLAY_HEIGHT;
}
private tickGlitch(): boolean {
const state = this.effectState as { phase: number; glitchFrames: number };
if (state.phase < state.glitchFrames) {
// Glitch phase: show corrupted version
this.currentGrid = this.finalGrid.map((row) => {
const offset = Math.floor(Math.random() * 7) - 3;
const glitchRow = [...row];
// Random horizontal offset
if (Math.random() < 0.3) {
const shifted = glitchRow.slice(offset).concat(glitchRow.slice(0, offset));
return shifted.slice(0, WIDTH);
}
// Random vertical swap
if (Math.random() < 0.2) {
const swapRow = Math.floor(Math.random() * DISPLAY_HEIGHT);
return [...this.finalGrid[swapRow]];
}
return glitchRow;
});
state.phase++;
return false;
}
// Final frame: show clean image
this.currentGrid = this.finalGrid.map((row) => [...row]);
return true;
}
private tickDissolve(): boolean {
const state = this.effectState as { positions: [number, number][]; idx: number };
const pixelsPerFrame = 20;
for (let i = 0; i < pixelsPerFrame; i++) {
if (state.idx >= state.positions.length) return true;
const [row, x] = state.positions[state.idx];
this.currentGrid[row][x] = this.finalGrid[row][x];
state.idx++;
}
return false;
}
private updateDisplay(): void {
this.gridVersion++;
}
dispose(): void {
this.stopAnimation();
}
}
@@ -0,0 +1,166 @@
import type { AssistantMessage } from "@earendil-works/pi-ai";
import { Container, Markdown, type MarkdownTheme, Spacer, Text } from "@earendil-works/pi-tui";
import { getMarkdownTheme, theme } from "../theme/theme.ts";
const OSC133_ZONE_START = "\x1b]133;A\x07";
const OSC133_ZONE_END = "\x1b]133;B\x07";
const OSC133_ZONE_FINAL = "\x1b]133;C\x07";
/**
* Component that renders a complete assistant message
*/
export class AssistantMessageComponent extends Container {
private contentContainer: Container;
private hideThinkingBlock: boolean;
private markdownTheme: MarkdownTheme;
private hiddenThinkingLabel: string;
private outputPad: number;
private lastMessage?: AssistantMessage;
private hasToolCalls = false;
constructor(
message?: AssistantMessage,
hideThinkingBlock = false,
markdownTheme: MarkdownTheme = getMarkdownTheme(),
hiddenThinkingLabel = "Thinking...",
outputPad = 1,
) {
super();
this.hideThinkingBlock = hideThinkingBlock;
this.markdownTheme = markdownTheme;
this.hiddenThinkingLabel = hiddenThinkingLabel;
this.outputPad = outputPad;
// Container for text/thinking content
this.contentContainer = new Container();
this.addChild(this.contentContainer);
if (message) {
this.updateContent(message);
}
}
override invalidate(): void {
super.invalidate();
if (this.lastMessage) {
this.updateContent(this.lastMessage);
}
}
setHideThinkingBlock(hide: boolean): void {
this.hideThinkingBlock = hide;
if (this.lastMessage) {
this.updateContent(this.lastMessage);
}
}
setHiddenThinkingLabel(label: string): void {
this.hiddenThinkingLabel = label;
if (this.lastMessage) {
this.updateContent(this.lastMessage);
}
}
setOutputPad(padding: number): void {
this.outputPad = padding;
if (this.lastMessage) {
this.updateContent(this.lastMessage);
}
}
override render(width: number): string[] {
const lines = super.render(width);
if (this.hasToolCalls || lines.length === 0) {
return lines;
}
lines[0] = OSC133_ZONE_START + lines[0];
lines[lines.length - 1] = OSC133_ZONE_END + OSC133_ZONE_FINAL + lines[lines.length - 1];
return lines;
}
updateContent(message: AssistantMessage): void {
this.lastMessage = message;
// Clear content container
this.contentContainer.clear();
const hasVisibleContent = message.content.some(
(c) => (c.type === "text" && c.text.trim()) || (c.type === "thinking" && c.thinking.trim()),
);
if (hasVisibleContent) {
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(), this.outputPad, 0, this.markdownTheme));
} else if (content.type === "thinking" && content.thinking.trim()) {
// Add spacing only when another visible assistant content block follows.
// This avoids a superfluous blank line before separately-rendered tool execution blocks.
const hasVisibleContentAfter = message.content
.slice(i + 1)
.some((c) => (c.type === "text" && c.text.trim()) || (c.type === "thinking" && c.thinking.trim()));
if (this.hideThinkingBlock) {
// Show static thinking label when hidden
this.contentContainer.addChild(
new Text(theme.italic(theme.fg("thinkingText", this.hiddenThinkingLabel)), this.outputPad, 0),
);
if (hasVisibleContentAfter) {
this.contentContainer.addChild(new Spacer(1));
}
} else {
// Thinking traces in thinkingText color, italic
this.contentContainer.addChild(
new Markdown(content.thinking.trim(), this.outputPad, 0, this.markdownTheme, {
color: (text: string) => theme.fg("thinkingText", text),
italic: true,
}),
);
if (hasVisibleContentAfter) {
this.contentContainer.addChild(new Spacer(1));
}
}
}
}
// Check if incomplete/failed - show after partial content.
// For aborted/error tool calls, tool execution components show the error.
// Length stops can happen before a tool call is complete, so surface them here too.
const hasToolCalls = message.content.some((c) => c.type === "toolCall");
this.hasToolCalls = hasToolCalls;
if (message.stopReason === "length") {
this.contentContainer.addChild(new Spacer(1));
this.contentContainer.addChild(
new Text(
theme.fg(
"error",
"Error: Model stopped because it reached the maximum output token limit. The response may be incomplete.",
),
this.outputPad,
0,
),
);
} else if (!hasToolCalls) {
if (message.stopReason === "aborted") {
const abortMessage =
message.errorMessage && message.errorMessage !== "Request was aborted"
? message.errorMessage
: "Operation aborted";
this.contentContainer.addChild(new Spacer(1));
this.contentContainer.addChild(new Text(theme.fg("error", abortMessage), this.outputPad, 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}`), this.outputPad, 0));
}
}
}
}
@@ -0,0 +1,220 @@
/**
* Component for displaying bash command execution with streaming output.
*/
import { Container, Loader, Spacer, Text, type TUI } from "@earendil-works/pi-tui";
import {
DEFAULT_MAX_BYTES,
DEFAULT_MAX_LINES,
type TruncationResult,
truncateTail,
} from "../../../core/tools/truncate.ts";
import { stripAnsi } from "../../../utils/ansi.ts";
import { theme } from "../theme/theme.ts";
import { DynamicBorder } from "./dynamic-border.ts";
import { keyHint, keyText } from "./keybinding-hints.ts";
import { truncateToVisualLines } from "./visual-truncate.ts";
// 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 | undefined = undefined;
private loader: Loader;
private truncationResult?: TruncationResult;
private fullOutputPath?: string;
private expanded = false;
private contentContainer: Container;
constructor(command: string, ui: TUI, excludeFromContext = false) {
super();
this.command = command;
// Use dim border for excluded-from-context commands (!! prefix)
const colorKey = excludeFromContext ? "dim" : "bashMode";
const borderColor = (str: string) => theme.fg(colorKey, 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(colorKey, theme.bold(`$ ${command}`)), 1, 0);
this.contentContainer.addChild(header);
// Loader
this.loader = new Loader(
ui,
(spinner) => theme.fg(colorKey, spinner),
(text) => theme.fg("muted", text),
`Running... (${keyText("tui.select.cancel")} to cancel)`, // Plain text for loader
);
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();
}
override invalidate(): void {
super.invalidate();
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 | undefined,
cancelled: boolean,
truncationResult?: TruncationResult,
fullOutputPath?: string,
): void {
this.exitCode = exitCode;
this.status = cancelled
? "cancelled"
: exitCode !== 0 && exitCode !== undefined && 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 previewLogicalLines = availableLines.slice(-PREVIEW_LINES);
const hiddenLineCount = availableLines.length - previewLogicalLines.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 (availableLines.length > 0) {
if (this.expanded) {
// Show all lines
const displayText = availableLines.map((line) => theme.fg("muted", line)).join("\n");
this.contentContainer.addChild(new Text(`\n${displayText}`, 1, 0));
} else {
// Use shared visual truncation utility with width-aware caching
const styledOutput = previewLogicalLines.map((line) => theme.fg("muted", line)).join("\n");
const styledInput = `\n${styledOutput}`;
let cachedWidth: number | undefined;
let cachedLines: string[] | undefined;
this.contentContainer.addChild({
render: (width: number) => {
if (cachedLines === undefined || cachedWidth !== width) {
const result = truncateToVisualLines(styledInput, PREVIEW_LINES, width, 1);
cachedLines = result.visualLines;
cachedWidth = width;
}
return cachedLines ?? [];
},
invalidate: () => {
cachedWidth = undefined;
cachedLines = undefined;
},
});
}
}
// 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) {
if (this.expanded) {
statusParts.push(
`${theme.fg("muted", "(")}${keyHint("app.tools.expand", "to collapse")}${theme.fg("muted", ")")}`,
);
} else {
statusParts.push(
`${theme.fg("muted", `... ${hiddenLineCount} more lines (`)}${keyHint("app.tools.expand", "to expand")}${theme.fg("muted", ")")}`,
);
}
}
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,68 @@
import { CancellableLoader, Container, Loader, Spacer, Text, type TUI } from "@earendil-works/pi-tui";
import type { Theme } from "../theme/theme.ts";
import { DynamicBorder } from "./dynamic-border.ts";
import { keyHint } from "./keybinding-hints.ts";
/** Loader wrapped with borders for extension UI */
export class BorderedLoader extends Container {
private loader: CancellableLoader | Loader;
private cancellable: boolean;
private signalController?: AbortController;
constructor(tui: TUI, theme: Theme, message: string, options?: { cancellable?: boolean }) {
super();
this.cancellable = options?.cancellable ?? true;
const borderColor = (s: string) => theme.fg("border", s);
this.addChild(new DynamicBorder(borderColor));
if (this.cancellable) {
this.loader = new CancellableLoader(
tui,
(s) => theme.fg("accent", s),
(s) => theme.fg("muted", s),
message,
);
} else {
this.signalController = new AbortController();
this.loader = new Loader(
tui,
(s) => theme.fg("accent", s),
(s) => theme.fg("muted", s),
message,
);
}
this.addChild(this.loader);
if (this.cancellable) {
this.addChild(new Spacer(1));
this.addChild(new Text(keyHint("tui.select.cancel", "cancel"), 1, 0));
}
this.addChild(new Spacer(1));
this.addChild(new DynamicBorder(borderColor));
}
get signal(): AbortSignal {
if (this.cancellable) {
return (this.loader as CancellableLoader).signal;
}
return this.signalController?.signal ?? new AbortController().signal;
}
set onAbort(fn: (() => void) | undefined) {
if (this.cancellable) {
(this.loader as CancellableLoader).onAbort = fn;
}
}
handleInput(data: string): void {
if (this.cancellable) {
(this.loader as CancellableLoader).handleInput(data);
}
}
dispose(): void {
if ("dispose" in this.loader && typeof this.loader.dispose === "function") {
this.loader.dispose();
} else if ("stop" in this.loader && typeof this.loader.stop === "function") {
this.loader.stop();
}
}
}
@@ -0,0 +1,58 @@
import { Box, Markdown, type MarkdownTheme, Spacer, Text } from "@earendil-works/pi-tui";
import type { BranchSummaryMessage } from "../../../core/messages.ts";
import { getMarkdownTheme, theme } from "../theme/theme.ts";
import { keyText } from "./keybinding-hints.ts";
/**
* Component that renders a branch summary message with collapsed/expanded state.
* Uses same background color as custom messages for visual consistency.
*/
export class BranchSummaryMessageComponent extends Box {
private expanded = false;
private message: BranchSummaryMessage;
private markdownTheme: MarkdownTheme;
constructor(message: BranchSummaryMessage, markdownTheme: MarkdownTheme = getMarkdownTheme()) {
super(1, 1, (t) => theme.bg("customMessageBg", t));
this.message = message;
this.markdownTheme = markdownTheme;
this.updateDisplay();
}
setExpanded(expanded: boolean): void {
this.expanded = expanded;
this.updateDisplay();
}
override invalidate(): void {
super.invalidate();
this.updateDisplay();
}
private updateDisplay(): void {
this.clear();
const label = theme.fg("customMessageLabel", `\x1b[1m[branch]\x1b[22m`);
this.addChild(new Text(label, 0, 0));
this.addChild(new Spacer(1));
if (this.expanded) {
const header = "**Branch Summary**\n\n";
this.addChild(
new Markdown(header + this.message.summary, 0, 0, this.markdownTheme, {
color: (text: string) => theme.fg("customMessageText", text),
}),
);
} else {
this.addChild(
new Text(
theme.fg("customMessageText", "Branch summary (") +
theme.fg("dim", keyText("app.tools.expand")) +
theme.fg("customMessageText", " to expand)"),
0,
0,
),
);
}
}
}
@@ -0,0 +1,59 @@
import { Box, Markdown, type MarkdownTheme, Spacer, Text } from "@earendil-works/pi-tui";
import type { CompactionSummaryMessage } from "../../../core/messages.ts";
import { getMarkdownTheme, theme } from "../theme/theme.ts";
import { keyText } from "./keybinding-hints.ts";
/**
* Component that renders a compaction message with collapsed/expanded state.
* Uses same background color as custom messages for visual consistency.
*/
export class CompactionSummaryMessageComponent extends Box {
private expanded = false;
private message: CompactionSummaryMessage;
private markdownTheme: MarkdownTheme;
constructor(message: CompactionSummaryMessage, markdownTheme: MarkdownTheme = getMarkdownTheme()) {
super(1, 1, (t) => theme.bg("customMessageBg", t));
this.message = message;
this.markdownTheme = markdownTheme;
this.updateDisplay();
}
setExpanded(expanded: boolean): void {
this.expanded = expanded;
this.updateDisplay();
}
override invalidate(): void {
super.invalidate();
this.updateDisplay();
}
private updateDisplay(): void {
this.clear();
const tokenStr = this.message.tokensBefore.toLocaleString();
const label = theme.fg("customMessageLabel", `\x1b[1m[compaction]\x1b[22m`);
this.addChild(new Text(label, 0, 0));
this.addChild(new Spacer(1));
if (this.expanded) {
const header = `**Compacted from ${tokenStr} tokens**\n\n`;
this.addChild(
new Markdown(header + this.message.summary, 0, 0, this.markdownTheme, {
color: (text: string) => theme.fg("customMessageText", text),
}),
);
} else {
this.addChild(
new Text(
theme.fg("customMessageText", `Compacted from ${tokenStr} tokens (`) +
theme.fg("dim", keyText("app.tools.expand")) +
theme.fg("customMessageText", " to expand)"),
0,
0,
),
);
}
}
}
@@ -0,0 +1,942 @@
/**
* TUI component for managing package resources (enable/disable)
*/
import { homedir } from "node:os";
import { basename, dirname, join, relative } from "node:path";
import {
type Component,
Container,
type Focusable,
getKeybindings,
Input,
matchesKey,
Spacer,
truncateToWidth,
visibleWidth,
} from "@earendil-works/pi-tui";
import { CONFIG_DIR_NAME } from "../../../config.ts";
import type { PathMetadata, ResolvedPaths, ResolvedResource } from "../../../core/package-manager.ts";
import type { PackageSource, SettingsManager } from "../../../core/settings-manager.ts";
import { canonicalizePath, isLocalPath, resolvePath } from "../../../utils/paths.ts";
import { theme } from "../theme/theme.ts";
import { DynamicBorder } from "./dynamic-border.ts";
import { keyHint, rawKeyHint } from "./keybinding-hints.ts";
type ResourceType = "extensions" | "skills" | "prompts" | "themes";
type ConfigWriteScope = "global" | "project";
type SettingsScope = "user" | "project";
type ProjectOverrideState = "inherit" | "load" | "unload";
export type ScopedResolvedPaths = Record<ConfigWriteScope, ResolvedPaths>;
const RESOURCE_TYPES = ["extensions", "skills", "prompts", "themes"] as const satisfies readonly ResourceType[];
const RESOURCE_TYPE_LABELS: Record<ResourceType, string> = {
extensions: "Extensions",
skills: "Skills",
prompts: "Prompts",
themes: "Themes",
};
interface ResourceItem {
path: string;
enabled: boolean;
metadata: PathMetadata;
resourceType: ResourceType;
displayName: string;
groupKey: string;
subgroupKey: string;
}
interface ResourceSubgroup {
type: ResourceType;
label: string;
items: ResourceItem[];
}
interface ResourceGroup {
key: string;
label: string;
scope: "user" | "project" | "temporary";
origin: "package" | "top-level";
source: string;
subgroups: ResourceSubgroup[];
}
function formatBaseDir(baseDir: string): string {
const homeDir = homedir();
let displayPath: string;
if (baseDir === homeDir) {
displayPath = "~";
} else if (baseDir.startsWith(homeDir)) {
// Replace home prefix with ~, normalize separators for display
const rest = baseDir.slice(homeDir.length);
displayPath = `~${rest.replace(/\\/g, "/")}`;
} else {
displayPath = baseDir.replace(/\\/g, "/");
}
return displayPath.endsWith("/") ? displayPath : `${displayPath}/`;
}
function getGroupLabel(metadata: PathMetadata, agentDir: string): string {
if (metadata.origin === "package") {
return `${metadata.source} (${metadata.scope})`;
}
// Top-level resources
if (metadata.source === "auto") {
if (metadata.baseDir) {
return metadata.scope === "user"
? `User (${formatBaseDir(metadata.baseDir)})`
: `Project (${formatBaseDir(metadata.baseDir)})`;
}
return metadata.scope === "user" ? `User (${formatBaseDir(agentDir)})` : `Project (${CONFIG_DIR_NAME}/)`;
}
return metadata.scope === "user" ? "User settings" : "Project settings";
}
function buildGroups(resolved: ResolvedPaths, agentDir: string): ResourceGroup[] {
const groupMap = new Map<string, ResourceGroup>();
const addToGroup = (resources: ResolvedResource[], resourceType: ResourceType) => {
for (const res of resources) {
const { path, enabled, metadata } = res;
const groupKey = `${metadata.origin}:${metadata.scope}:${metadata.source}:${metadata.baseDir ?? ""}`;
if (!groupMap.has(groupKey)) {
groupMap.set(groupKey, {
key: groupKey,
label: getGroupLabel(metadata, agentDir),
scope: metadata.scope,
origin: metadata.origin,
source: metadata.source,
subgroups: [],
});
}
const group = groupMap.get(groupKey)!;
const subgroupKey = `${groupKey}:${resourceType}`;
let subgroup = group.subgroups.find((sg) => sg.type === resourceType);
if (!subgroup) {
subgroup = {
type: resourceType,
label: RESOURCE_TYPE_LABELS[resourceType],
items: [],
};
group.subgroups.push(subgroup);
}
const fileName = basename(path);
const parentFolder = basename(dirname(path));
let displayName: string;
if (resourceType === "extensions" && parentFolder !== "extensions") {
displayName = `${parentFolder}/${fileName}`;
} else if (resourceType === "skills" && fileName === "SKILL.md") {
displayName = parentFolder;
} else {
displayName = fileName;
}
subgroup.items.push({
path,
enabled,
metadata,
resourceType,
displayName,
groupKey,
subgroupKey,
});
}
};
addToGroup(resolved.extensions, "extensions");
addToGroup(resolved.skills, "skills");
addToGroup(resolved.prompts, "prompts");
addToGroup(resolved.themes, "themes");
// Sort groups: packages first, then top-level; user before project
const groups = Array.from(groupMap.values());
groups.sort((a, b) => {
if (a.origin !== b.origin) {
return a.origin === "package" ? -1 : 1;
}
if (a.scope !== b.scope) {
return a.scope === "user" ? -1 : 1;
}
return a.source.localeCompare(b.source);
});
// Sort subgroups within each group by type order, and items by name
const typeOrder: Record<ResourceType, number> = { extensions: 0, skills: 1, prompts: 2, themes: 3 };
for (const group of groups) {
group.subgroups.sort((a, b) => typeOrder[a.type] - typeOrder[b.type]);
for (const subgroup of group.subgroups) {
subgroup.items.sort((a, b) => a.displayName.localeCompare(b.displayName));
}
}
return groups;
}
type FlatEntry =
| { type: "group"; group: ResourceGroup }
| { type: "subgroup"; subgroup: ResourceSubgroup; group: ResourceGroup }
| { type: "item"; item: ResourceItem };
class ConfigSelectorHeader implements Component {
private writeScope: ConfigWriteScope;
private projectModeAvailable: boolean;
constructor(writeScope: ConfigWriteScope, projectModeAvailable: boolean) {
this.writeScope = writeScope;
this.projectModeAvailable = projectModeAvailable;
}
setWriteScope(writeScope: ConfigWriteScope): void {
this.writeScope = writeScope;
}
invalidate(): void {}
render(width: number): string[] {
const title = theme.bold(this.writeScope === "project" ? "Project Local Resources" : "Global Resources");
const sep = theme.fg("muted", " · ");
const switchHint = this.projectModeAvailable ? keyHint("tui.input.tab", "switch mode") + sep : "";
const actionHint =
this.writeScope === "project" ? rawKeyHint("space", "cycle inherit/+/-") : rawKeyHint("space", "toggle");
const hint = switchHint + actionHint + sep + rawKeyHint("esc", "close");
const spacing = Math.max(1, width - visibleWidth(title) - visibleWidth(hint));
const scopeHint =
this.writeScope === "project"
? theme.fg("muted", `${CONFIG_DIR_NAME}/settings.json · inherited global resources are dimmed`)
: theme.fg("muted", `~/${CONFIG_DIR_NAME}/agent/settings.json`);
return [
truncateToWidth(`${title}${" ".repeat(spacing)}${hint}`, width, ""),
truncateToWidth(scopeHint, width, ""),
];
}
}
class ResourceList implements Component, Focusable {
private groupsByScope: Record<ConfigWriteScope, ResourceGroup[]>;
private flatItems: FlatEntry[] = [];
private filteredItems: FlatEntry[] = [];
private selectedIndex = 0;
private searchInput: Input;
private maxVisible: number;
private settingsManager: SettingsManager;
private cwd: string;
private agentDir: string;
private writeScope: ConfigWriteScope;
private inheritedEnabledByKey: Map<string, boolean>;
public onCancel?: () => void;
public onExit?: () => void;
public onToggle?: (item: ResourceItem, newEnabled: boolean) => void;
public onSwitchMode?: () => void;
private _focused = false;
get focused(): boolean {
return this._focused;
}
set focused(value: boolean) {
this._focused = value;
this.searchInput.focused = value;
}
constructor(
groupsByScope: Record<ConfigWriteScope, ResourceGroup[]>,
settingsManager: SettingsManager,
cwd: string,
agentDir: string,
terminalHeight?: number,
writeScope: ConfigWriteScope = "global",
) {
this.groupsByScope = groupsByScope;
this.settingsManager = settingsManager;
this.cwd = cwd;
this.agentDir = agentDir;
this.writeScope = writeScope;
this.inheritedEnabledByKey = this.buildInheritedEnabledMap(groupsByScope.global);
this.searchInput = new Input();
// 8 lines of chrome: top spacer + top border + spacer + header (2 lines) + spacer + bottom spacer + bottom border
const chrome = 8;
this.maxVisible = Math.max(5, (terminalHeight ?? 24) - chrome);
this.buildFlatList();
this.filteredItems = [...this.flatItems];
}
setWriteScope(writeScope: ConfigWriteScope): void {
this.writeScope = writeScope;
this.buildFlatList();
this.filterItems(this.searchInput.getValue());
}
private get groups(): ResourceGroup[] {
return this.groupsByScope[this.writeScope];
}
private buildInheritedEnabledMap(groups: ResourceGroup[]): Map<string, boolean> {
const result = new Map<string, boolean>();
for (const group of groups) {
for (const subgroup of group.subgroups) {
for (const item of subgroup.items) {
result.set(this.getResourceItemKey(item), item.enabled);
}
}
}
return result;
}
private buildFlatList(): void {
this.flatItems = [];
for (const group of this.groups) {
this.flatItems.push({ type: "group", group });
for (const subgroup of group.subgroups) {
this.flatItems.push({ type: "subgroup", subgroup, group });
for (const item of subgroup.items) {
this.flatItems.push({ type: "item", item });
}
}
}
// Start selection on first item (not header)
this.selectedIndex = this.flatItems.findIndex((e) => e.type === "item");
if (this.selectedIndex < 0) this.selectedIndex = 0;
}
private findNextItem(fromIndex: number, direction: 1 | -1): number {
let idx = fromIndex + direction;
while (idx >= 0 && idx < this.filteredItems.length) {
if (this.filteredItems[idx].type === "item") {
return idx;
}
idx += direction;
}
return fromIndex; // Stay at current if no item found
}
private filterItems(query: string): void {
if (!query.trim()) {
this.filteredItems = [...this.flatItems];
this.selectFirstItem();
return;
}
const lowerQuery = query.toLowerCase();
const matchingItems = new Set<ResourceItem>();
const matchingSubgroups = new Set<ResourceSubgroup>();
const matchingGroups = new Set<ResourceGroup>();
for (const entry of this.flatItems) {
if (entry.type === "item") {
const item = entry.item;
if (
item.displayName.toLowerCase().includes(lowerQuery) ||
item.resourceType.toLowerCase().includes(lowerQuery) ||
item.path.toLowerCase().includes(lowerQuery)
) {
matchingItems.add(item);
}
}
}
// Find which subgroups and groups contain matching items
for (const group of this.groups) {
for (const subgroup of group.subgroups) {
for (const item of subgroup.items) {
if (matchingItems.has(item)) {
matchingSubgroups.add(subgroup);
matchingGroups.add(group);
}
}
}
}
this.filteredItems = [];
for (const entry of this.flatItems) {
if (entry.type === "group" && matchingGroups.has(entry.group)) {
this.filteredItems.push(entry);
} else if (entry.type === "subgroup" && matchingSubgroups.has(entry.subgroup)) {
this.filteredItems.push(entry);
} else if (entry.type === "item" && matchingItems.has(entry.item)) {
this.filteredItems.push(entry);
}
}
this.selectFirstItem();
}
private selectFirstItem(): void {
const firstItemIndex = this.filteredItems.findIndex((e) => e.type === "item");
this.selectedIndex = firstItemIndex >= 0 ? firstItemIndex : 0;
}
updateItem(item: ResourceItem, enabled: boolean): void {
item.enabled = enabled;
// Update in groups too
for (const group of this.groups) {
for (const subgroup of group.subgroups) {
const found = subgroup.items.find((i) => i.path === item.path && i.resourceType === item.resourceType);
if (found) {
found.enabled = enabled;
return;
}
}
}
}
invalidate(): void {}
render(width: number): string[] {
const lines: string[] = [];
// Search input
lines.push(...this.searchInput.render(width));
lines.push("");
if (this.filteredItems.length === 0) {
lines.push(theme.fg("muted", " No resources found"));
return lines;
}
// Calculate visible range
const startIndex = Math.max(
0,
Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.filteredItems.length - this.maxVisible),
);
const endIndex = Math.min(startIndex + this.maxVisible, this.filteredItems.length);
for (let i = startIndex; i < endIndex; i++) {
const entry = this.filteredItems[i];
const isSelected = i === this.selectedIndex;
if (entry.type === "group") {
// Main group header (no cursor)
const inherited = this.writeScope === "project" && entry.group.scope === "user";
const label = theme.bold(`${entry.group.label}${inherited ? " · inherited global" : ""}`);
const groupLine = theme.fg(inherited ? "dim" : "accent", label);
lines.push(truncateToWidth(` ${groupLine}`, width, ""));
} else if (entry.type === "subgroup") {
// Subgroup header (indented, no cursor)
const color = this.writeScope === "project" && entry.group.scope === "user" ? "dim" : "muted";
const subgroupLine = theme.fg(color, entry.subgroup.label);
lines.push(truncateToWidth(` ${subgroupLine}`, width, ""));
} else {
// Resource item (cursor only on items)
const item = entry.item;
const cursor = isSelected ? "> " : " ";
const dimmed = this.isDimmedItem(item);
const nameText = isSelected && !dimmed ? theme.bold(item.displayName) : item.displayName;
const name = dimmed ? theme.fg("dim", nameText) : nameText;
lines.push(
truncateToWidth(
`${cursor} ${this.renderCheckbox(item)} ${name}${this.getItemSuffix(item)}`,
width,
"...",
),
);
}
}
// Scroll indicator
if (startIndex > 0 || endIndex < this.filteredItems.length) {
const itemCount = this.filteredItems.filter((e) => e.type === "item").length;
const currentItemIndex =
this.filteredItems.slice(0, this.selectedIndex).filter((e) => e.type === "item").length + 1;
lines.push(theme.fg("dim", ` (${currentItemIndex}/${itemCount})`));
}
return lines;
}
handleInput(data: string): void {
const kb = getKeybindings();
if (kb.matches(data, "tui.select.up")) {
this.selectedIndex = this.findNextItem(this.selectedIndex, -1);
return;
}
if (kb.matches(data, "tui.select.down")) {
this.selectedIndex = this.findNextItem(this.selectedIndex, 1);
return;
}
if (kb.matches(data, "tui.select.pageUp")) {
// Jump up by maxVisible, then find nearest item
let target = Math.max(0, this.selectedIndex - this.maxVisible);
while (target < this.filteredItems.length && this.filteredItems[target].type !== "item") {
target++;
}
if (target < this.filteredItems.length) {
this.selectedIndex = target;
}
return;
}
if (kb.matches(data, "tui.select.pageDown")) {
// Jump down by maxVisible, then find nearest item
let target = Math.min(this.filteredItems.length - 1, this.selectedIndex + this.maxVisible);
while (target >= 0 && this.filteredItems[target].type !== "item") {
target--;
}
if (target >= 0) {
this.selectedIndex = target;
}
return;
}
if (kb.matches(data, "tui.select.cancel")) {
this.onCancel?.();
return;
}
if (matchesKey(data, "ctrl+c")) {
this.onExit?.();
return;
}
if (kb.matches(data, "tui.input.tab")) {
this.onSwitchMode?.();
return;
}
if (data === " " || kb.matches(data, "tui.select.confirm")) {
const entry = this.filteredItems[this.selectedIndex];
if (entry?.type === "item" && (this.writeScope === "project" || this.getItemScope(entry.item) === "user")) {
const newEnabled = this.toggleResource(entry.item);
if (newEnabled !== undefined) {
this.updateItem(entry.item, newEnabled);
this.onToggle?.(entry.item, newEnabled);
}
}
return;
}
// Pass to search input
this.searchInput.handleInput(data);
this.filterItems(this.searchInput.getValue());
}
private toggleResource(item: ResourceItem): boolean | undefined {
if (this.writeScope === "project") {
const state = this.getNextOverrideState(item);
if (!this.setProjectResourceOverride(item, state)) return undefined;
return state === "inherit" ? this.getInheritedEnabled(item) : state === "load";
}
const enabled = !item.enabled;
if (item.metadata.origin === "top-level") {
this.toggleTopLevelResource(item, enabled);
} else {
this.togglePackageResource(item, enabled);
}
return enabled;
}
private toggleTopLevelResource(item: ResourceItem, enabled: boolean): void {
const scope = item.metadata.scope as "user" | "project";
const settings =
scope === "project" ? this.settingsManager.getProjectSettings() : this.settingsManager.getGlobalSettings();
const arrayKey = item.resourceType as "extensions" | "skills" | "prompts" | "themes";
const current = (settings[arrayKey] ?? []) as string[];
// Generate pattern for this resource
const pattern = this.getResourcePattern(item);
const disablePattern = `-${pattern}`;
const enablePattern = `+${pattern}`;
// Filter out existing patterns for this resource
const updated = current.filter((p) => {
const stripped = p.startsWith("!") || p.startsWith("+") || p.startsWith("-") ? p.slice(1) : p;
return stripped !== pattern;
});
if (enabled) {
updated.push(enablePattern);
} else {
updated.push(disablePattern);
}
if (scope === "project") {
if (arrayKey === "extensions") {
this.settingsManager.setProjectExtensionPaths(updated);
} else if (arrayKey === "skills") {
this.settingsManager.setProjectSkillPaths(updated);
} else if (arrayKey === "prompts") {
this.settingsManager.setProjectPromptTemplatePaths(updated);
} else if (arrayKey === "themes") {
this.settingsManager.setProjectThemePaths(updated);
}
} else {
if (arrayKey === "extensions") {
this.settingsManager.setExtensionPaths(updated);
} else if (arrayKey === "skills") {
this.settingsManager.setSkillPaths(updated);
} else if (arrayKey === "prompts") {
this.settingsManager.setPromptTemplatePaths(updated);
} else if (arrayKey === "themes") {
this.settingsManager.setThemePaths(updated);
}
}
}
private togglePackageResource(item: ResourceItem, enabled: boolean): void {
const scope = item.metadata.scope as "user" | "project";
const settings =
scope === "project" ? this.settingsManager.getProjectSettings() : this.settingsManager.getGlobalSettings();
const packages = [...(settings.packages ?? [])] as PackageSource[];
const pkgIndex = packages.findIndex((pkg) => {
const source = typeof pkg === "string" ? pkg : pkg.source;
return source === item.metadata.source;
});
if (pkgIndex === -1) return;
let pkg = packages[pkgIndex];
// Convert string to object form if needed
if (typeof pkg === "string") {
pkg = { source: pkg };
packages[pkgIndex] = pkg;
}
// Get the resource array for this type
const arrayKey = item.resourceType as "extensions" | "skills" | "prompts" | "themes";
const current = (pkg[arrayKey] ?? []) as string[];
// Generate pattern relative to package root
const pattern = this.getPackageResourcePattern(item);
const disablePattern = `-${pattern}`;
const enablePattern = `+${pattern}`;
// Filter out existing patterns for this resource
const updated = current.filter((p) => {
const stripped = p.startsWith("!") || p.startsWith("+") || p.startsWith("-") ? p.slice(1) : p;
return stripped !== pattern;
});
if (enabled) {
updated.push(enablePattern);
} else {
updated.push(disablePattern);
}
(pkg as Record<string, unknown>)[arrayKey] = updated.length > 0 ? updated : undefined;
// Clean up empty filter object
const hasFilters = ["extensions", "skills", "prompts", "themes"].some(
(k) => (pkg as Record<string, unknown>)[k] !== undefined,
);
if (!hasFilters) {
packages[pkgIndex] = (pkg as { source: string }).source;
}
if (scope === "project") {
this.settingsManager.setProjectPackages(packages);
} else {
this.settingsManager.setPackages(packages);
}
}
private renderCheckbox(item: ResourceItem): string {
if (this.writeScope === "project") {
const state = this.getProjectOverrideState(item);
if (state === "load") return theme.fg("success", "[+]");
if (state === "unload") return theme.fg("warning", "[-]");
return theme.fg("dim", item.enabled ? "[x]" : "[ ]");
}
return item.enabled ? theme.fg("success", "[x]") : theme.fg("dim", "[ ]");
}
private getItemSuffix(item: ResourceItem): string {
if (this.writeScope !== "project") return "";
const state = this.getProjectOverrideState(item);
if (state === "load") return theme.fg("muted", " project load");
if (state === "unload") return theme.fg("muted", " project unload");
return this.isInheritedGlobalItem(item) ? theme.fg("dim", " inherited global") : "";
}
private isDimmedItem(item: ResourceItem): boolean {
return (
this.writeScope === "project" &&
this.isInheritedGlobalItem(item) &&
this.getProjectOverrideState(item) === "inherit"
);
}
private setProjectResourceOverride(item: ResourceItem, state: ProjectOverrideState): boolean {
return item.metadata.origin === "top-level"
? this.setProjectTopLevelOverride(item, state)
: this.setProjectPackageOverride(item, state);
}
private setProjectTopLevelOverride(item: ResourceItem, state: ProjectOverrideState): boolean {
const current = (this.settingsManager.getProjectSettings()[item.resourceType] ?? []) as string[];
const pattern = this.isInheritedGlobalItem(item) ? item.path : this.getResourcePatternForScope(item, "project");
const patterns = this.getTopLevelOverridePatterns(item, "project");
const updated = current.filter((entry) => {
const target = this.getPatternEntryTarget(entry);
if ((entry.startsWith("!") || entry.startsWith("+") || entry.startsWith("-")) && patterns.has(target))
return false;
return !(state === "inherit" && this.isInheritedGlobalItem(item) && target === pattern);
});
if (state !== "inherit") {
if (this.isInheritedGlobalItem(item) && !updated.includes(pattern)) updated.push(pattern);
updated.push(`${state === "load" ? "+" : "-"}${pattern}`);
}
this.setProjectTopLevelPaths(item.resourceType, updated);
return true;
}
private setProjectTopLevelPaths(key: ResourceType, paths: string[]): void {
if (key === "extensions") this.settingsManager.setProjectExtensionPaths(paths);
else if (key === "skills") this.settingsManager.setProjectSkillPaths(paths);
else if (key === "prompts") this.settingsManager.setProjectPromptTemplatePaths(paths);
else this.settingsManager.setProjectThemePaths(paths);
}
private setProjectPackageOverride(item: ResourceItem, state: ProjectOverrideState): boolean {
const packages = [...(this.settingsManager.getProjectSettings().packages ?? [])] as PackageSource[];
let pkgIndex = packages.findIndex((pkg) =>
this.packageSourceStringMatches(
item.metadata.source,
this.getItemScope(item),
typeof pkg === "string" ? pkg : pkg.source,
"project",
),
);
if (pkgIndex === -1) {
if (state === "inherit") return false;
packages.push(this.createPackageOverrideSource(item));
pkgIndex = packages.length - 1;
}
let pkg = packages[pkgIndex];
if (pkg === undefined) return false;
if (typeof pkg === "string") {
pkg = { source: pkg };
packages[pkgIndex] = pkg;
}
const pattern = this.getPackageResourcePattern(item);
const updated = ((pkg[item.resourceType] ?? []) as string[]).filter(
(entry) => this.getPatternEntryTarget(entry) !== pattern,
);
if (state !== "inherit") updated.push(`${state === "load" ? "+" : "-"}${pattern}`);
(pkg as Record<string, unknown>)[item.resourceType] = updated.length > 0 ? updated : undefined;
if (!RESOURCE_TYPES.some((key) => (pkg as Record<string, unknown>)[key] !== undefined)) {
if (pkg.autoload === false) packages.splice(pkgIndex, 1);
else packages[pkgIndex] = pkg.source;
}
this.settingsManager.setProjectPackages(packages);
return true;
}
private getNextOverrideState(item: ResourceItem): ProjectOverrideState {
const state = this.getProjectOverrideState(item);
const inheritedEnabled = this.getInheritedEnabled(item);
if (state === "inherit") return inheritedEnabled ? "unload" : "load";
if (state === "unload") return inheritedEnabled ? "load" : "inherit";
return inheritedEnabled ? "inherit" : "unload";
}
private getProjectOverrideState(item: ResourceItem): ProjectOverrideState {
if (this.writeScope !== "project") return "inherit";
if (item.metadata.origin === "top-level") {
return this.getOverrideStateFromEntries(
(this.settingsManager.getProjectSettings()[item.resourceType] ?? []) as string[],
this.getTopLevelOverridePatterns(item, "project"),
false,
);
}
const pkg = this.findMatchingPackageSource(item, "project");
if (typeof pkg !== "object") return "inherit";
const entries = pkg[item.resourceType];
if (entries === undefined) return "inherit";
return this.getOverrideStateFromEntries(
entries,
new Set([this.getPackageResourcePattern(item)]),
pkg.autoload !== false,
);
}
private getOverrideStateFromEntries(
entries: string[],
patterns: Set<string>,
emptyArrayIsUnload: boolean,
): ProjectOverrideState {
if (entries.length === 0 && emptyArrayIsUnload) return "unload";
let state: ProjectOverrideState = "inherit";
for (const entry of entries) {
if (!patterns.has(this.getPatternEntryTarget(entry))) continue;
if (entry.startsWith("!") || entry.startsWith("-")) state = "unload";
else state = "load";
}
return state;
}
private getInheritedEnabled(item: ResourceItem): boolean {
return (
this.inheritedEnabledByKey.get(this.getResourceItemKey(item)) ??
(this.getItemScope(item) === "user" ? item.enabled : true)
);
}
private isInheritedGlobalItem(item: ResourceItem): boolean {
return this.getItemScope(item) === "user" || this.inheritedEnabledByKey.has(this.getResourceItemKey(item));
}
private getTopLevelOverridePatterns(item: ResourceItem, scope: SettingsScope): Set<string> {
const baseDir = this.getTopLevelBaseDir(scope);
const patterns = new Set<string>([
this.getResourcePatternForScope(item, scope),
item.path,
relative(baseDir, item.path),
]);
if (item.metadata.baseDir) patterns.add(relative(item.metadata.baseDir, item.path));
return patterns;
}
private getResourcePatternForScope(item: ResourceItem, scope: SettingsScope): string {
const sourceScope = this.getItemScope(item);
if (scope !== sourceScope) return item.path;
const baseDir = item.metadata.baseDir ?? this.getTopLevelBaseDir(sourceScope);
return relative(baseDir, item.path);
}
private createPackageOverrideSource(item: ResourceItem): PackageSource {
const source = item.metadata.source;
if (!isLocalPath(source)) return { source, autoload: false };
const sourcePath = resolvePath(source, this.getTopLevelBaseDir(this.getItemScope(item)), { trim: true });
return { source: relative(this.getTopLevelBaseDir("project"), sourcePath) || ".", autoload: false };
}
private packageSourceStringMatches(
leftSource: string,
leftScope: SettingsScope,
rightSource: string,
rightScope: SettingsScope,
): boolean {
if (leftSource === rightSource) return true;
if (!isLocalPath(leftSource) || !isLocalPath(rightSource)) return false;
const left = resolvePath(leftSource, this.getTopLevelBaseDir(leftScope), { trim: true });
const right = resolvePath(rightSource, this.getTopLevelBaseDir(rightScope), { trim: true });
return left === right;
}
private findMatchingPackageSource(item: ResourceItem, targetScope: SettingsScope): PackageSource | undefined {
const settings =
targetScope === "project"
? this.settingsManager.getProjectSettings()
: this.settingsManager.getGlobalSettings();
return (settings.packages ?? []).find((pkg) =>
this.packageSourceStringMatches(
item.metadata.source,
this.getItemScope(item),
typeof pkg === "string" ? pkg : pkg.source,
targetScope,
),
);
}
private getPatternEntryTarget(entry: string): string {
return entry.startsWith("!") || entry.startsWith("+") || entry.startsWith("-") ? entry.slice(1) : entry;
}
private getResourceItemKey(item: ResourceItem): string {
return `${item.resourceType}:${canonicalizePath(item.path)}`;
}
private getItemScope(item: ResourceItem): SettingsScope {
return item.metadata.scope === "project" ? "project" : "user";
}
private getTopLevelBaseDir(scope: "user" | "project"): string {
return scope === "project" ? join(this.cwd, CONFIG_DIR_NAME) : this.agentDir;
}
private getResourcePattern(item: ResourceItem): string {
const scope = item.metadata.scope as "user" | "project";
const baseDir = item.metadata.baseDir ?? this.getTopLevelBaseDir(scope);
return relative(baseDir, item.path);
}
private getPackageResourcePattern(item: ResourceItem): string {
const baseDir = item.metadata.baseDir ?? dirname(item.path);
return relative(baseDir, item.path);
}
}
export class ConfigSelectorComponent extends Container implements Focusable {
private header: ConfigSelectorHeader;
private resourceList: ResourceList;
private writeScope: ConfigWriteScope;
private _focused = false;
get focused(): boolean {
return this._focused;
}
set focused(value: boolean) {
this._focused = value;
this.resourceList.focused = value;
}
constructor(
resolvedPaths: ScopedResolvedPaths,
settingsManager: SettingsManager,
cwd: string,
agentDir: string,
onClose: () => void,
onExit: () => void,
requestRender: () => void,
terminalHeight?: number,
writeScope: ConfigWriteScope = "global",
projectModeAvailable = true,
) {
super();
this.writeScope = writeScope;
const groupsByScope = {
global: buildGroups(resolvedPaths.global, agentDir),
project: buildGroups(resolvedPaths.project, agentDir),
};
// Add header
this.addChild(new Spacer(1));
this.addChild(new DynamicBorder());
this.addChild(new Spacer(1));
this.header = new ConfigSelectorHeader(this.writeScope, projectModeAvailable);
this.addChild(this.header);
this.addChild(new Spacer(1));
// Resource list
this.resourceList = new ResourceList(
groupsByScope,
settingsManager,
cwd,
agentDir,
terminalHeight,
this.writeScope,
);
this.resourceList.onCancel = onClose;
this.resourceList.onExit = onExit;
this.resourceList.onToggle = () => requestRender();
if (projectModeAvailable) {
this.resourceList.onSwitchMode = () => {
this.switchWriteScope();
requestRender();
};
}
this.addChild(this.resourceList);
// Bottom border
this.addChild(new Spacer(1));
this.addChild(new DynamicBorder());
}
private switchWriteScope(): void {
this.writeScope = this.writeScope === "global" ? "project" : "global";
this.header.setWriteScope(this.writeScope);
this.resourceList.setWriteScope(this.writeScope);
}
getResourceList(): ResourceList {
return this.resourceList;
}
}
@@ -0,0 +1,39 @@
/**
* Reusable countdown timer for dialog components.
*/
import type { TUI } from "@earendil-works/pi-tui";
export class CountdownTimer {
private intervalId: ReturnType<typeof setInterval> | undefined;
private remainingSeconds: number;
private tui: TUI | undefined;
private onTick: (seconds: number) => void;
private onExpire: () => void;
constructor(timeoutMs: number, tui: TUI | undefined, onTick: (seconds: number) => void, onExpire: () => void) {
this.tui = tui;
this.onTick = onTick;
this.onExpire = onExpire;
this.remainingSeconds = Math.ceil(timeoutMs / 1000);
this.onTick(this.remainingSeconds);
this.intervalId = setInterval(() => {
this.remainingSeconds--;
this.onTick(this.remainingSeconds);
this.tui?.requestRender();
if (this.remainingSeconds <= 0) {
this.dispose();
this.onExpire();
}
}, 1000);
}
dispose(): void {
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = undefined;
}
}
}
@@ -0,0 +1,80 @@
import { Editor, type EditorOptions, type EditorTheme, type TUI } from "@earendil-works/pi-tui";
import type { AppKeybinding, KeybindingsManager } from "../../../core/keybindings.ts";
/**
* Custom editor that handles app-level keybindings for coding-agent.
*/
export class CustomEditor extends Editor {
private keybindings: KeybindingsManager;
public actionHandlers: Map<AppKeybinding, () => void> = new Map();
// Special handlers that can be dynamically replaced
public onEscape?: () => void;
public onCtrlD?: () => void;
public onPasteImage?: () => void;
/** Handler for extension-registered shortcuts. Returns true if handled. */
public onExtensionShortcut?: (data: string) => boolean;
constructor(tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager, options?: EditorOptions) {
super(tui, theme, options);
this.keybindings = keybindings;
}
/**
* Register a handler for an app action.
*/
onAction(action: AppKeybinding, handler: () => void): void {
this.actionHandlers.set(action, handler);
}
handleInput(data: string): void {
// Check extension-registered shortcuts first
if (this.onExtensionShortcut?.(data)) {
return;
}
// Check for clipboard paste keybinding
if (this.keybindings.matches(data, "app.clipboard.pasteImage")) {
this.onPasteImage?.();
return;
}
// Check app keybindings first
// Escape/interrupt - only if autocomplete is NOT active
if (this.keybindings.matches(data, "app.interrupt")) {
if (!this.isShowingAutocomplete()) {
// Use dynamic onEscape if set, otherwise registered handler
const handler = this.onEscape ?? this.actionHandlers.get("app.interrupt");
if (handler) {
handler();
return;
}
}
// Let parent handle escape for autocomplete cancellation
super.handleInput(data);
return;
}
// Exit (Ctrl+D) - only when editor is empty
if (this.keybindings.matches(data, "app.exit")) {
if (this.getText().length === 0) {
const handler = this.onCtrlD ?? this.actionHandlers.get("app.exit");
if (handler) handler();
return;
}
// Fall through to editor handling for delete-char-forward when not empty
}
// Check all other app actions
for (const [action, handler] of this.actionHandlers) {
if (action !== "app.interrupt" && action !== "app.exit" && this.keybindings.matches(data, action)) {
handler();
return;
}
}
// Pass to parent for editor handling
super.handleInput(data);
}
}
@@ -0,0 +1,62 @@
import type { Component } from "@earendil-works/pi-tui";
import { Box, Container, Spacer, Text } from "@earendil-works/pi-tui";
import type { EntryRenderer } from "../../../core/extensions/types.ts";
import type { CustomEntry } from "../../../core/session-manager.ts";
import { theme } from "../theme/theme.ts";
/**
* Component that renders a custom session entry from extensions.
* The host owns transcript spacing; renderer output should provide only its content.
*/
export class CustomEntryComponent extends Container {
private entry: CustomEntry<unknown>;
private renderer: EntryRenderer;
private customComponent?: Component;
private _expanded = false;
constructor(entry: CustomEntry<unknown>, renderer: EntryRenderer) {
super();
this.entry = entry;
this.renderer = renderer;
this.rebuild();
}
hasContent(): boolean {
return this.customComponent !== undefined;
}
setExpanded(expanded: boolean): void {
if (this._expanded !== expanded) {
this._expanded = expanded;
this.rebuild();
}
}
override invalidate(): void {
super.invalidate();
this.rebuild();
}
private rebuild(): void {
this.clear();
this.customComponent = undefined;
let component: Component | undefined;
try {
component = this.renderer(this.entry, { expanded: this._expanded }, theme);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
box.addChild(new Text(theme.fg("error", `[${this.entry.customType}] renderer failed: ${message}`), 0, 0));
component = box;
}
if (!component) {
return;
}
this.customComponent = component;
this.addChild(new Spacer(1));
this.addChild(component);
}
}
@@ -0,0 +1,99 @@
import type { TextContent } from "@earendil-works/pi-ai";
import type { Component } from "@earendil-works/pi-tui";
import { Box, Container, Markdown, type MarkdownTheme, Spacer, Text } from "@earendil-works/pi-tui";
import type { MessageRenderer } from "../../../core/extensions/types.ts";
import type { CustomMessage } from "../../../core/messages.ts";
import { getMarkdownTheme, theme } from "../theme/theme.ts";
/**
* Component that renders a custom message entry from extensions.
* Uses distinct styling to differentiate from user messages.
*/
export class CustomMessageComponent extends Container {
private message: CustomMessage<unknown>;
private customRenderer?: MessageRenderer;
private box: Box;
private customComponent?: Component;
private markdownTheme: MarkdownTheme;
private _expanded = false;
constructor(
message: CustomMessage<unknown>,
customRenderer?: MessageRenderer,
markdownTheme: MarkdownTheme = getMarkdownTheme(),
) {
super();
this.message = message;
this.customRenderer = customRenderer;
this.markdownTheme = markdownTheme;
this.addChild(new Spacer(1));
// Create box with purple background (used for default rendering)
this.box = new Box(1, 1, (t) => theme.bg("customMessageBg", t));
this.rebuild();
}
setExpanded(expanded: boolean): void {
if (this._expanded !== expanded) {
this._expanded = expanded;
this.rebuild();
}
}
override invalidate(): void {
super.invalidate();
this.rebuild();
}
private rebuild(): void {
// Remove previous content component
if (this.customComponent) {
this.removeChild(this.customComponent);
this.customComponent = undefined;
}
this.removeChild(this.box);
// Try custom renderer first - it handles its own styling
if (this.customRenderer) {
try {
const component = this.customRenderer(this.message, { expanded: this._expanded }, theme);
if (component) {
// Custom renderer provides its own styled component
this.customComponent = component;
this.addChild(component);
return;
}
} catch {
// Fall through to default rendering
}
}
// Default rendering uses our box
this.addChild(this.box);
this.box.clear();
// Default rendering: label + content
const label = theme.fg("customMessageLabel", `\x1b[1m[${this.message.customType}]\x1b[22m`);
this.box.addChild(new Text(label, 0, 0));
this.box.addChild(new Spacer(1));
// Extract text content
let text: string;
if (typeof this.message.content === "string") {
text = this.message.content;
} else {
text = this.message.content
.filter((c): c is TextContent => c.type === "text")
.map((c) => c.text)
.join("\n");
}
this.box.addChild(
new Markdown(text, 0, 0, this.markdownTheme, {
color: (text: string) => theme.fg("customMessageText", text),
}),
);
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,147 @@
import * as Diff from "diff";
import { theme } from "../theme/theme.ts";
/**
* Parse diff line to extract prefix, line number, and content.
* Format: "+123 content" or "-123 content" or " 123 content" or " ..."
*/
function parseDiffLine(line: string): { prefix: string; lineNum: string; content: string } | null {
const match = line.match(/^([+-\s])(\s*\d*)\s(.*)$/);
if (!match) return null;
return { prefix: match[1], lineNum: match[2], content: match[3] };
}
/**
* Replace tabs with spaces for consistent rendering.
*/
function replaceTabs(text: string): string {
return text.replace(/\t/g, " ");
}
/**
* Compute word-level diff and render with inverse on changed parts.
* Uses diffWords which groups whitespace with adjacent words for cleaner highlighting.
* Strips leading whitespace from inverse to avoid highlighting indentation.
*/
function renderIntraLineDiff(oldContent: string, newContent: string): { removedLine: string; addedLine: string } {
const wordDiff = Diff.diffWords(oldContent, newContent);
let removedLine = "";
let addedLine = "";
let isFirstRemoved = true;
let isFirstAdded = true;
for (const part of wordDiff) {
if (part.removed) {
let value = part.value;
// Strip leading whitespace from the first removed part
if (isFirstRemoved) {
const leadingWs = value.match(/^(\s*)/)?.[1] || "";
value = value.slice(leadingWs.length);
removedLine += leadingWs;
isFirstRemoved = false;
}
if (value) {
removedLine += theme.inverse(value);
}
} else if (part.added) {
let value = part.value;
// Strip leading whitespace from the first added part
if (isFirstAdded) {
const leadingWs = value.match(/^(\s*)/)?.[1] || "";
value = value.slice(leadingWs.length);
addedLine += leadingWs;
isFirstAdded = false;
}
if (value) {
addedLine += theme.inverse(value);
}
} else {
removedLine += part.value;
addedLine += part.value;
}
}
return { removedLine, addedLine };
}
export interface RenderDiffOptions {
/** File path (unused, kept for API compatibility) */
filePath?: string;
}
/**
* Render a diff string with colored lines and intra-line change highlighting.
* - Context lines: dim/gray
* - Removed lines: red, with inverse on changed tokens
* - Added lines: green, with inverse on changed tokens
*/
export function renderDiff(diffText: string, _options: RenderDiffOptions = {}): string {
const lines = diffText.split("\n");
const result: string[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
const parsed = parseDiffLine(line);
if (!parsed) {
result.push(theme.fg("toolDiffContext", line));
i++;
continue;
}
if (parsed.prefix === "-") {
// Collect consecutive removed lines
const removedLines: { lineNum: string; content: string }[] = [];
while (i < lines.length) {
const p = parseDiffLine(lines[i]);
if (!p || p.prefix !== "-") break;
removedLines.push({ lineNum: p.lineNum, content: p.content });
i++;
}
// Collect consecutive added lines
const addedLines: { lineNum: string; content: string }[] = [];
while (i < lines.length) {
const p = parseDiffLine(lines[i]);
if (!p || p.prefix !== "+") break;
addedLines.push({ lineNum: p.lineNum, content: p.content });
i++;
}
// Only do intra-line diffing when there's exactly one removed and one added line
// (indicating a single line modification). Otherwise, show lines as-is.
if (removedLines.length === 1 && addedLines.length === 1) {
const removed = removedLines[0];
const added = addedLines[0];
const { removedLine, addedLine } = renderIntraLineDiff(
replaceTabs(removed.content),
replaceTabs(added.content),
);
result.push(theme.fg("toolDiffRemoved", `-${removed.lineNum} ${removedLine}`));
result.push(theme.fg("toolDiffAdded", `+${added.lineNum} ${addedLine}`));
} else {
// Show all removed lines first, then all added lines
for (const removed of removedLines) {
result.push(theme.fg("toolDiffRemoved", `-${removed.lineNum} ${replaceTabs(removed.content)}`));
}
for (const added of addedLines) {
result.push(theme.fg("toolDiffAdded", `+${added.lineNum} ${replaceTabs(added.content)}`));
}
}
} else if (parsed.prefix === "+") {
// Standalone added line
result.push(theme.fg("toolDiffAdded", `+${parsed.lineNum} ${replaceTabs(parsed.content)}`));
i++;
} else {
// Context line
result.push(theme.fg("toolDiffContext", ` ${parsed.lineNum} ${replaceTabs(parsed.content)}`));
i++;
}
}
return result.join("\n");
}
@@ -0,0 +1,25 @@
import type { Component } from "@earendil-works/pi-tui";
import { theme } from "../theme/theme.ts";
/**
* Dynamic border component that adjusts to viewport width.
*
* Note: When used from extensions loaded via jiti, the global `theme` may be undefined
* because jiti creates a separate module cache. Always pass an explicit color
* function when using DynamicBorder in components exported for extension use.
*/
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)))];
}
}
@@ -0,0 +1,53 @@
import * as fs from "node:fs";
import { Container, Image, Spacer, Text } from "@earendil-works/pi-tui";
import { getBundledInteractiveAssetPath } from "../../../config.ts";
import { theme } from "../theme/theme.ts";
import { DynamicBorder } from "./dynamic-border.ts";
const BLOG_URL = "https://mariozechner.at/posts/2026-04-08-ive-sold-out/";
const IMAGE_FILENAME = "clankolas.png";
let cachedImageBase64: string | undefined;
let attemptedImageLoad = false;
function loadImageBase64(): string | undefined {
if (attemptedImageLoad) {
return cachedImageBase64;
}
attemptedImageLoad = true;
try {
cachedImageBase64 = fs.readFileSync(getBundledInteractiveAssetPath(IMAGE_FILENAME)).toString("base64");
} catch {
cachedImageBase64 = undefined;
}
return cachedImageBase64;
}
export class EarendilAnnouncementComponent extends Container {
constructor() {
super();
this.addChild(new DynamicBorder((text) => theme.fg("accent", text)));
this.addChild(new Text(theme.bold(theme.fg("accent", "pi has joined Earendil")), 1, 0));
this.addChild(new Spacer(1));
this.addChild(new Text(theme.fg("muted", "Read the blog post:"), 1, 0));
this.addChild(new Text(theme.fg("mdLink", BLOG_URL), 1, 0));
this.addChild(new Spacer(1));
const imageBase64 = loadImageBase64();
if (imageBase64) {
this.addChild(
new Image(
imageBase64,
"image/png",
{ fallbackColor: (text) => theme.fg("muted", text) },
{ maxWidthCells: 56, filename: IMAGE_FILENAME },
),
);
this.addChild(new Spacer(1));
}
this.addChild(new DynamicBorder((text) => theme.fg("accent", text)));
}
}
@@ -0,0 +1,167 @@
/**
* Multi-line editor component for extensions.
* Supports Ctrl+G for external editor.
*/
import { spawn } from "node:child_process";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import {
Container,
Editor,
type EditorOptions,
type Focusable,
getKeybindings,
Spacer,
Text,
type TUI,
} from "@earendil-works/pi-tui";
import type { KeybindingsManager } from "../../../core/keybindings.ts";
import { getEditorTheme, theme } from "../theme/theme.ts";
import { DynamicBorder } from "./dynamic-border.ts";
import { keyHint } from "./keybinding-hints.ts";
export class ExtensionEditorComponent extends Container implements Focusable {
private editor: Editor;
private onSubmitCallback: (value: string) => void;
private onCancelCallback: () => void;
private tui: TUI;
private keybindings: KeybindingsManager;
private externalEditorCommand: string | undefined;
private _focused = false;
get focused(): boolean {
return this._focused;
}
set focused(value: boolean) {
this._focused = value;
this.editor.focused = value;
}
constructor(
tui: TUI,
keybindings: KeybindingsManager,
title: string,
prefill: string | undefined,
onSubmit: (value: string) => void,
onCancel: () => void,
options?: EditorOptions,
externalEditorCommand?: string,
) {
super();
this.tui = tui;
this.keybindings = keybindings;
this.externalEditorCommand = externalEditorCommand;
this.onSubmitCallback = onSubmit;
this.onCancelCallback = onCancel;
// Add top border
this.addChild(new DynamicBorder());
this.addChild(new Spacer(1));
// Add title
this.addChild(new Text(theme.fg("accent", title), 1, 0));
this.addChild(new Spacer(1));
// Create editor
this.editor = new Editor(tui, getEditorTheme(), options);
if (prefill) {
this.editor.setText(prefill);
}
// Wire up Enter to submit (Shift+Enter for newlines, like the main editor)
this.editor.onSubmit = (text: string) => {
this.onSubmitCallback(text);
};
this.addChild(this.editor);
this.addChild(new Spacer(1));
// Add hint
const hasExternalEditor = !!this.getExternalEditorCommand();
const hint =
keyHint("tui.select.confirm", "submit") +
" " +
keyHint("tui.input.newLine", "newline") +
" " +
keyHint("tui.select.cancel", "cancel") +
(hasExternalEditor ? ` ${keyHint("app.editor.external", "external editor")}` : "");
this.addChild(new Text(hint, 1, 0));
this.addChild(new Spacer(1));
// Add bottom border
this.addChild(new DynamicBorder());
}
handleInput(keyData: string): void {
const kb = getKeybindings();
// Escape or Ctrl+C to cancel
if (kb.matches(keyData, "tui.select.cancel")) {
this.onCancelCallback();
return;
}
// External editor (app keybinding)
if (this.keybindings.matches(keyData, "app.editor.external")) {
this.openExternalEditor();
return;
}
// Forward to editor
this.editor.handleInput(keyData);
}
private getExternalEditorCommand(): string | undefined {
const editorCmd = this.externalEditorCommand || process.env.VISUAL || process.env.EDITOR;
if (editorCmd) {
return editorCmd;
}
return process.platform === "win32" ? "notepad" : "nano";
}
private async openExternalEditor(): Promise<void> {
const editorCmd = this.getExternalEditorCommand();
if (!editorCmd) {
return;
}
const currentText = this.editor.getText();
const tmpFile = path.join(os.tmpdir(), `pi-extension-editor-${Date.now()}.md`);
try {
fs.writeFileSync(tmpFile, currentText, "utf-8");
this.tui.stop();
const [editor, ...editorArgs] = editorCmd.split(" ");
process.stdout.write(`Launching external editor: ${editorCmd}\nPi will resume when the editor exits.\n`);
// Do not use spawnSync here. On Windows, synchronous child_process calls can keep
// Node/libuv's console input read active after tui.stop() pauses stdin, racing
// vim/nvim for the console input buffer until Ctrl+C cancels the pending read.
const status = await new Promise<number | null>((resolve) => {
const child = spawn(editor, [...editorArgs, tmpFile], {
stdio: "inherit",
shell: process.platform === "win32",
});
child.on("error", () => resolve(null));
child.on("close", (code) => resolve(code));
});
if (status === 0) {
const newContent = fs.readFileSync(tmpFile, "utf-8").replace(/\n$/, "");
this.editor.setText(newContent);
}
} finally {
try {
fs.unlinkSync(tmpFile);
} catch {
// Ignore cleanup errors
}
this.tui.start();
// Force full re-render since external editor uses alternate screen
this.tui.requestRender(true);
}
}
}
@@ -0,0 +1,87 @@
/**
* Simple text input component for extensions.
*/
import { Container, type Focusable, getKeybindings, Input, Spacer, Text, type TUI } from "@earendil-works/pi-tui";
import { theme } from "../theme/theme.ts";
import { CountdownTimer } from "./countdown-timer.ts";
import { DynamicBorder } from "./dynamic-border.ts";
import { keyHint } from "./keybinding-hints.ts";
export interface ExtensionInputOptions {
tui?: TUI;
timeout?: number;
}
export class ExtensionInputComponent extends Container implements Focusable {
private input: Input;
private onSubmitCallback: (value: string) => void;
private onCancelCallback: () => void;
private titleText: Text;
private baseTitle: string;
private countdown: CountdownTimer | undefined;
// Focusable implementation - propagate to input for IME cursor positioning
private _focused = false;
get focused(): boolean {
return this._focused;
}
set focused(value: boolean) {
this._focused = value;
this.input.focused = value;
}
constructor(
title: string,
_placeholder: string | undefined,
onSubmit: (value: string) => void,
onCancel: () => void,
opts?: ExtensionInputOptions,
) {
super();
this.onSubmitCallback = onSubmit;
this.onCancelCallback = onCancel;
this.baseTitle = title;
this.addChild(new DynamicBorder());
this.addChild(new Spacer(1));
this.titleText = new Text(theme.fg("accent", title), 1, 0);
this.addChild(this.titleText);
this.addChild(new Spacer(1));
if (opts?.timeout && opts.timeout > 0 && opts.tui) {
this.countdown = new CountdownTimer(
opts.timeout,
opts.tui,
(s) => this.titleText.setText(theme.fg("accent", `${this.baseTitle} (${s}s)`)),
() => this.onCancelCallback(),
);
}
this.input = new Input();
this.addChild(this.input);
this.addChild(new Spacer(1));
this.addChild(
new Text(`${keyHint("tui.select.confirm", "submit")} ${keyHint("tui.select.cancel", "cancel")}`, 1, 0),
);
this.addChild(new Spacer(1));
this.addChild(new DynamicBorder());
}
handleInput(keyData: string): void {
const kb = getKeybindings();
if (kb.matches(keyData, "tui.select.confirm") || keyData === "\n") {
this.onSubmitCallback(this.input.getValue());
} else if (kb.matches(keyData, "tui.select.cancel")) {
this.onCancelCallback();
} else {
this.input.handleInput(keyData);
}
}
dispose(): void {
this.countdown?.dispose();
}
}
@@ -0,0 +1,112 @@
/**
* Generic selector component for extensions.
* Displays a list of string options with keyboard navigation.
*/
import { Container, getKeybindings, Spacer, Text, type TUI } from "@earendil-works/pi-tui";
import { theme } from "../theme/theme.ts";
import { CountdownTimer } from "./countdown-timer.ts";
import { DynamicBorder } from "./dynamic-border.ts";
import { keyHint, rawKeyHint } from "./keybinding-hints.ts";
export interface ExtensionSelectorOptions {
tui?: TUI;
timeout?: number;
onToggleToolsExpanded?: () => void;
}
export class ExtensionSelectorComponent extends Container {
private options: string[];
private selectedIndex = 0;
private listContainer: Container;
private onSelectCallback: (option: string) => void;
private onCancelCallback: () => void;
private titleText: Text;
private baseTitle: string;
private countdown: CountdownTimer | undefined;
private onToggleToolsExpanded: (() => void) | undefined;
constructor(
title: string,
options: string[],
onSelect: (option: string) => void,
onCancel: () => void,
opts?: ExtensionSelectorOptions,
) {
super();
this.options = options;
this.onSelectCallback = onSelect;
this.onCancelCallback = onCancel;
this.onToggleToolsExpanded = opts?.onToggleToolsExpanded;
this.baseTitle = title;
this.addChild(new DynamicBorder());
this.addChild(new Spacer(1));
this.titleText = new Text(theme.fg("accent", theme.bold(title)), 1, 0);
this.addChild(this.titleText);
this.addChild(new Spacer(1));
if (opts?.timeout && opts.timeout > 0 && opts.tui) {
this.countdown = new CountdownTimer(
opts.timeout,
opts.tui,
(s) => this.titleText.setText(theme.fg("accent", theme.bold(`${this.baseTitle} (${s}s)`))),
() => this.onCancelCallback(),
);
}
this.listContainer = new Container();
this.addChild(this.listContainer);
this.addChild(new Spacer(1));
this.addChild(
new Text(
rawKeyHint("↑↓", "navigate") +
" " +
keyHint("tui.select.confirm", "select") +
" " +
keyHint("tui.select.cancel", "cancel"),
1,
0,
),
);
this.addChild(new Spacer(1));
this.addChild(new DynamicBorder());
this.updateList();
}
private updateList(): void {
this.listContainer.clear();
for (let i = 0; i < this.options.length; i++) {
const isSelected = i === this.selectedIndex;
const text = isSelected
? theme.fg("accent", "→ ") + theme.fg("accent", this.options[i])
: ` ${theme.fg("text", this.options[i])}`;
this.listContainer.addChild(new Text(text, 1, 0));
}
}
handleInput(keyData: string): void {
const kb = getKeybindings();
if (kb.matches(keyData, "app.tools.expand")) {
this.onToggleToolsExpanded?.();
} else if (kb.matches(keyData, "tui.select.up") || keyData === "k") {
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
this.updateList();
} else if (kb.matches(keyData, "tui.select.down") || keyData === "j") {
this.selectedIndex = Math.min(this.options.length - 1, this.selectedIndex + 1);
this.updateList();
} else if (kb.matches(keyData, "tui.select.confirm") || keyData === "\n") {
const selected = this.options[this.selectedIndex];
if (selected) this.onSelectCallback(selected);
} else if (kb.matches(keyData, "tui.select.cancel")) {
this.onCancelCallback();
}
}
dispose(): void {
this.countdown?.dispose();
}
}
@@ -0,0 +1,145 @@
import { Container, getKeybindings, Spacer, Text } from "@earendil-works/pi-tui";
import { APP_NAME } from "../../../config.ts";
import { type TerminalTheme, theme } from "../theme/theme.ts";
import { DynamicBorder } from "./dynamic-border.ts";
import { keyHint, rawKeyHint } from "./keybinding-hints.ts";
export interface FirstTimeSetupResult {
theme: TerminalTheme;
shareAnalytics: boolean;
}
export interface FirstTimeSetupOptions {
detectedTheme: TerminalTheme;
onThemePreview: (themeName: TerminalTheme) => void;
onSubmit: (result: FirstTimeSetupResult) => void;
onCancel: () => void;
}
const THEME_OPTIONS: Array<{ value: TerminalTheme; label: string }> = [
{ value: "dark", label: "Dark" },
{ value: "light", label: "Light" },
];
const ANALYTICS_OPTIONS: Array<{ value: boolean; label: string }> = [
{ value: true, label: "Share anonymous usage data" },
{ value: false, label: "Don't share" },
];
const SETUP_LOGO_LINES = ["██████", "██ ██", "████ ██", "██ ██"];
/** First-time setup dialog: theme choice and analytics opt-in. */
export class FirstTimeSetupComponent extends Container {
private step: "theme" | "analytics" = "theme";
private themeIndex: number;
private analyticsIndex = 0;
private readonly options: FirstTimeSetupOptions;
constructor(options: FirstTimeSetupOptions) {
super();
this.options = options;
this.themeIndex = Math.max(
0,
THEME_OPTIONS.findIndex((option) => option.value === options.detectedTheme),
);
this.update();
}
// Rebuild the whole dialog on every change so theme previews recolor all text.
private update(): void {
this.clear();
this.addChild(new DynamicBorder());
this.addChild(new Spacer(1));
this.addChild(new Text(theme.fg("accent", SETUP_LOGO_LINES.join("\n")), 1, 0));
this.addChild(new Spacer(1));
this.addChild(
new Text(theme.fg("accent", theme.bold(`Welcome to ${APP_NAME}, the minimal coding agent.`)), 1, 0),
);
this.addChild(new Spacer(1));
if (this.step === "theme") {
this.addChild(new Text(theme.fg("text", "Pick a theme."), 1, 0));
this.addChild(new Text(theme.fg("muted", `Detected system appearance: ${this.options.detectedTheme}`), 1, 0));
this.addChild(new Spacer(1));
this.addOptionList(
THEME_OPTIONS.map((option) => option.label),
this.themeIndex,
);
} else {
this.addChild(new Text(theme.fg("text", "Opt-in to anonymous usage data sharing?"), 1, 0));
this.addChild(
new Text(
theme.fg(
"muted",
"Opting in stores a tracking identifier in settings.json and enables anonymous\nusage analytics. This helps us to better debug, reproduce, and resolve issues\nand bugs within Pi. You can observe what is shared using /privacy and make\nchanges anytime in settings.json.",
),
1,
0,
),
);
this.addChild(new Spacer(1));
this.addOptionList(
ANALYTICS_OPTIONS.map((option) => option.label),
this.analyticsIndex,
);
}
this.addChild(new Spacer(1));
this.addChild(
new Text(
rawKeyHint("↑↓", "navigate") +
" " +
keyHint("tui.select.confirm", this.step === "theme" ? "continue" : "finish") +
" " +
keyHint("tui.select.cancel", "skip setup"),
1,
0,
),
);
this.addChild(new Spacer(1));
this.addChild(new DynamicBorder());
}
private addOptionList(labels: string[], selectedIndex: number): void {
for (let i = 0; i < labels.length; i++) {
const isSelected = i === selectedIndex;
const prefix = isSelected ? theme.fg("accent", "→ ") : " ";
const label = isSelected ? theme.fg("accent", labels[i]) : theme.fg("text", labels[i]);
this.addChild(new Text(`${prefix}${label}`, 1, 0));
}
}
private moveSelection(delta: number): void {
if (this.step === "theme") {
const next = Math.max(0, Math.min(THEME_OPTIONS.length - 1, this.themeIndex + delta));
if (next !== this.themeIndex) {
this.themeIndex = next;
this.options.onThemePreview(THEME_OPTIONS[this.themeIndex].value);
}
} else {
this.analyticsIndex = Math.max(0, Math.min(ANALYTICS_OPTIONS.length - 1, this.analyticsIndex + delta));
}
this.update();
}
handleInput(keyData: string): void {
const kb = getKeybindings();
if (kb.matches(keyData, "tui.select.up") || keyData === "k") {
this.moveSelection(-1);
} else if (kb.matches(keyData, "tui.select.down") || keyData === "j") {
this.moveSelection(1);
} else if (kb.matches(keyData, "tui.select.confirm") || keyData === "\n") {
if (this.step === "theme") {
this.step = "analytics";
this.update();
} else {
this.options.onSubmit({
theme: THEME_OPTIONS[this.themeIndex].value,
shareAnalytics: ANALYTICS_OPTIONS[this.analyticsIndex].value,
});
}
} else if (kb.matches(keyData, "tui.select.cancel")) {
this.options.onCancel();
}
}
}
@@ -0,0 +1,245 @@
import { isAbsolute, relative, resolve, sep } from "node:path";
import { type Component, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
import type { AgentSession } from "../../../core/agent-session.ts";
import { areExperimentalFeaturesEnabled } from "../../../core/experimental.ts";
import type { ReadonlyFooterDataProvider } from "../../../core/footer-data-provider.ts";
import { theme } from "../theme/theme.ts";
/**
* Sanitize text for display in a single-line status.
* Removes newlines, tabs, carriage returns, and other control characters.
*/
function sanitizeStatusText(text: string): string {
// Replace newlines, tabs, carriage returns with space, then collapse multiple spaces
return text
.replace(/[\r\n\t]/g, " ")
.replace(/ +/g, " ")
.trim();
}
/**
* Format token counts for compact footer display.
*/
export function 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`;
}
export function formatCwdForFooter(cwd: string, home: string | undefined): string {
if (!home) return cwd;
const resolvedCwd = resolve(cwd);
const resolvedHome = resolve(home);
const relativeToHome = relative(resolvedHome, resolvedCwd);
const isInsideHome =
relativeToHome === "" ||
(relativeToHome !== ".." && !relativeToHome.startsWith(`..${sep}`) && !isAbsolute(relativeToHome));
if (!isInsideHome) return cwd;
return relativeToHome === "" ? "~" : `~${sep}${relativeToHome}`;
}
/**
* Footer component that shows pwd, token stats, and context usage.
* Computes token/context stats from session, gets git branch and extension statuses from provider.
*/
export class FooterComponent implements Component {
private autoCompactEnabled = true;
private session: AgentSession;
private footerData: ReadonlyFooterDataProvider;
constructor(session: AgentSession, footerData: ReadonlyFooterDataProvider) {
this.session = session;
this.footerData = footerData;
}
setSession(session: AgentSession): void {
this.session = session;
}
setAutoCompactEnabled(enabled: boolean): void {
this.autoCompactEnabled = enabled;
}
/**
* No-op: git branch caching now handled by provider.
* Kept for compatibility with existing call sites in interactive-mode.
*/
invalidate(): void {
// No-op: git branch is cached/invalidated by provider
}
/**
* Clean up resources.
* Git watcher cleanup now handled by provider.
*/
dispose(): void {
// Git watcher cleanup handled by provider
}
render(width: number): string[] {
const state = this.session.state;
// Calculate cumulative usage from ALL session entries (not just post-compaction messages)
let totalInput = 0;
let totalOutput = 0;
let totalCacheRead = 0;
let totalCacheWrite = 0;
let totalCost = 0;
let latestCacheHitRate: number | undefined;
for (const entry of this.session.sessionManager.getEntries()) {
if (entry.type === "message" && entry.message.role === "assistant") {
totalInput += entry.message.usage.input;
totalOutput += entry.message.usage.output;
totalCacheRead += entry.message.usage.cacheRead;
totalCacheWrite += entry.message.usage.cacheWrite;
totalCost += entry.message.usage.cost.total;
const latestPromptTokens =
entry.message.usage.input + entry.message.usage.cacheRead + entry.message.usage.cacheWrite;
latestCacheHitRate =
latestPromptTokens > 0 ? (entry.message.usage.cacheRead / latestPromptTokens) * 100 : undefined;
}
}
// Calculate context usage from session (handles compaction correctly).
// After compaction, tokens are unknown until the next LLM response.
const contextUsage = this.session.getContextUsage();
const contextWindow = contextUsage?.contextWindow ?? state.model?.contextWindow ?? 0;
const contextPercentValue = contextUsage?.percent ?? 0;
const contextPercent = contextUsage?.percent !== null ? contextPercentValue.toFixed(1) : "?";
// Replace home directory with ~
let pwd = formatCwdForFooter(this.session.sessionManager.getCwd(), process.env.HOME || process.env.USERPROFILE);
// Add git branch if available
const branch = this.footerData.getGitBranch();
if (branch) {
pwd = `${pwd} (${branch})`;
}
// Add session name if set
const sessionName = this.session.sessionManager.getSessionName();
if (sessionName) {
pwd = `${pwd}${sessionName}`;
}
// 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)}`);
if ((totalCacheRead > 0 || totalCacheWrite > 0) && latestCacheHitRate !== undefined) {
statsParts.push(`CH${latestCacheHitRate.toFixed(1)}%`);
}
// Show cost with "(sub)" indicator if using OAuth subscription
const usingSubscription = state.model ? this.session.modelRegistry.isUsingOAuth(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}`
: `${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);
if (areExperimentalFeaturesEnabled()) {
statsParts.push(`${theme.fg("dim", "•")} ${theme.bold(theme.fg("warning", "xp"))}`);
}
let statsLeft = statsParts.join(" ");
// Add model name on the right side, plus thinking level if model supports it
const modelName = state.model?.id || "no-model";
let statsLeftWidth = visibleWidth(statsLeft);
// If statsLeft is too wide, truncate it
if (statsLeftWidth > width) {
statsLeft = truncateToWidth(statsLeft, width, "...");
statsLeftWidth = visibleWidth(statsLeft);
}
// Calculate available space for padding (minimum 2 spaces between stats and model)
const minPadding = 2;
// Add thinking level indicator if model supports reasoning
let rightSideWithoutProvider = modelName;
if (state.model?.reasoning) {
const thinkingLevel = state.thinkingLevel || "off";
rightSideWithoutProvider =
thinkingLevel === "off" ? `${modelName} • thinking off` : `${modelName}${thinkingLevel}`;
}
// Prepend the provider in parentheses if there are multiple providers and there's enough room
let rightSide = rightSideWithoutProvider;
if (this.footerData.getAvailableProviderCount() > 1 && state.model) {
rightSide = `(${state.model!.provider}) ${rightSideWithoutProvider}`;
if (statsLeftWidth + minPadding + visibleWidth(rightSide) > width) {
// Too wide, fall back
rightSide = rightSideWithoutProvider;
}
}
const rightSideWidth = visibleWidth(rightSide);
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 > 0) {
const truncatedRight = truncateToWidth(rightSide, availableForRight, "");
const truncatedRightWidth = visibleWidth(truncatedRight);
const padding = " ".repeat(Math.max(0, width - statsLeftWidth - truncatedRightWidth));
statsLine = statsLeft + padding + truncatedRight;
} else {
// Not enough space for right side at all
statsLine = statsLeft;
}
}
// Apply dim to each part separately. statsLeft may contain color codes (for context %)
// that end with a reset, which would clear an outer dim wrapper. So we dim the parts
// before and after the colored section independently.
const dimStatsLeft = theme.fg("dim", statsLeft);
const remainder = statsLine.slice(statsLeft.length); // padding + rightSide
const dimRemainder = theme.fg("dim", remainder);
const pwdLine = truncateToWidth(theme.fg("dim", pwd), width, theme.fg("dim", "..."));
const lines = [pwdLine, dimStatsLeft + dimRemainder];
// Add extension statuses on a single line, sorted by key alphabetically
const extensionStatuses = this.footerData.getExtensionStatuses();
if (extensionStatuses.size > 0) {
const sortedStatuses = Array.from(extensionStatuses.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([, text]) => sanitizeStatusText(text));
const statusLine = sortedStatuses.join(" ");
// Truncate to terminal width with dim ellipsis for consistency with footer style
lines.push(truncateToWidth(statusLine, width, theme.fg("dim", "...")));
}
return lines;
}
}
@@ -0,0 +1,38 @@
// UI Components for extensions
export { ArminComponent } from "./armin.ts";
export { AssistantMessageComponent } from "./assistant-message.ts";
export { BashExecutionComponent } from "./bash-execution.ts";
export { BorderedLoader } from "./bordered-loader.ts";
export { BranchSummaryMessageComponent } from "./branch-summary-message.ts";
export { CompactionSummaryMessageComponent } from "./compaction-summary-message.ts";
export { CustomEditor } from "./custom-editor.ts";
export { CustomMessageComponent } from "./custom-message.ts";
export { DaxnutsComponent } from "./daxnuts.ts";
export { type RenderDiffOptions, renderDiff } from "./diff.ts";
export { DynamicBorder } from "./dynamic-border.ts";
export { ExtensionEditorComponent } from "./extension-editor.ts";
export { ExtensionInputComponent } from "./extension-input.ts";
export { ExtensionSelectorComponent } from "./extension-selector.ts";
export {
FirstTimeSetupComponent,
type FirstTimeSetupOptions,
type FirstTimeSetupResult,
} from "./first-time-setup.ts";
export { FooterComponent } from "./footer.ts";
export { keyHint, keyText, rawKeyHint } from "./keybinding-hints.ts";
export { LoginDialogComponent } from "./login-dialog.ts";
export { ModelSelectorComponent } from "./model-selector.ts";
export { OAuthSelectorComponent } from "./oauth-selector.ts";
export { type ModelsCallbacks, type ModelsConfig, ScopedModelsSelectorComponent } from "./scoped-models-selector.ts";
export { SessionSelectorComponent } from "./session-selector.ts";
export { type SettingsCallbacks, type SettingsConfig, SettingsSelectorComponent } from "./settings-selector.ts";
export { ShowImagesSelectorComponent } from "./show-images-selector.ts";
export { SkillInvocationMessageComponent } from "./skill-invocation-message.ts";
export { ThemeSelectorComponent } from "./theme-selector.ts";
export { ThinkingSelectorComponent } from "./thinking-selector.ts";
export { ToolExecutionComponent, type ToolExecutionOptions } from "./tool-execution.ts";
export { TreeSelectorComponent } from "./tree-selector.ts";
export { TrustSelectorComponent } from "./trust-selector.ts";
export { UserMessageComponent } from "./user-message.ts";
export { UserMessageSelectorComponent } from "./user-message-selector.ts";
export { truncateToVisualLines, type VisualTruncateResult } from "./visual-truncate.ts";
@@ -0,0 +1,48 @@
/**
* Utilities for formatting keybinding hints in the UI.
*/
import { getKeybindings, type Keybinding, type KeyId } from "@earendil-works/pi-tui";
import { theme } from "../theme/theme.ts";
export interface KeyTextFormatOptions {
capitalize?: boolean;
}
function formatKeyPart(part: string, options: KeyTextFormatOptions): string {
const displayPart = process.platform === "darwin" && part.toLowerCase() === "alt" ? "option" : part;
return options.capitalize ? displayPart.charAt(0).toUpperCase() + displayPart.slice(1) : displayPart;
}
export function formatKeyText(key: string, options: KeyTextFormatOptions = {}): string {
return key
.split("/")
.map((k) =>
k
.split("+")
.map((part) => formatKeyPart(part, options))
.join("+"),
)
.join("/");
}
function formatKeys(keys: KeyId[], options: KeyTextFormatOptions = {}): string {
if (keys.length === 0) return "";
return formatKeyText(keys.join("/"), options);
}
export function keyText(keybinding: Keybinding): string {
return formatKeys(getKeybindings().getKeys(keybinding));
}
export function keyDisplayText(keybinding: Keybinding): string {
return formatKeys(getKeybindings().getKeys(keybinding), { capitalize: true });
}
export function keyHint(keybinding: Keybinding, description: string): string {
return theme.fg("dim", keyText(keybinding)) + theme.fg("muted", ` ${description}`);
}
export function rawKeyHint(key: string, description: string): string {
return theme.fg("dim", formatKeyText(key)) + theme.fg("muted", ` ${description}`);
}
@@ -0,0 +1,230 @@
import { getOAuthProviders, type OAuthDeviceCodeInfo } from "@earendil-works/pi-ai/oauth";
import { Container, type Focusable, getKeybindings, Input, Spacer, Text, type TUI } from "@earendil-works/pi-tui";
import { openBrowser } from "../../../utils/open-browser.ts";
import { theme } from "../theme/theme.ts";
import { DynamicBorder } from "./dynamic-border.ts";
import { keyHint } from "./keybinding-hints.ts";
/**
* Login dialog component - replaces editor during OAuth login flow
*/
export class LoginDialogComponent extends Container implements Focusable {
private contentContainer: Container;
private input: Input;
private tui: TUI;
private abortController = new AbortController();
private inputResolver?: (value: string) => void;
private inputRejecter?: (error: Error) => void;
private onComplete: (success: boolean, message?: string) => void;
// Focusable implementation - propagate to input for IME cursor positioning
private _focused = false;
get focused(): boolean {
return this._focused;
}
set focused(value: boolean) {
this._focused = value;
this.input.focused = value;
}
constructor(
tui: TUI,
providerId: string,
onComplete: (success: boolean, message?: string) => void,
providerNameOverride?: string,
titleOverride?: string,
) {
super();
this.tui = tui;
this.onComplete = onComplete;
const providerInfo = getOAuthProviders().find((p) => p.id === providerId);
const providerName = providerNameOverride || providerInfo?.name || providerId;
const title = titleOverride ?? `Login to ${providerName}`;
// Top border
this.addChild(new DynamicBorder());
// Title
this.addChild(new Text(theme.fg("accent", theme.bold(title)), 1, 0));
// Dynamic content area
this.contentContainer = new Container();
this.addChild(this.contentContainer);
// Input (always present, used when needed)
this.input = new Input();
this.input.onSubmit = () => {
if (this.inputResolver) {
const value = this.input.getValue();
this.replaceInputWithSubmittedText(value);
this.inputResolver(value);
this.inputResolver = undefined;
this.inputRejecter = undefined;
}
};
this.input.onEscape = () => {
this.cancel();
};
// Bottom border
this.addChild(new DynamicBorder());
}
get signal(): AbortSignal {
return this.abortController.signal;
}
private replaceInputWithSubmittedText(value: string): void {
this.contentContainer.children = this.contentContainer.children.map((child) =>
child === this.input ? new Text(`> ${value}`, 0, 0) : child,
);
}
private cancel(): void {
this.abortController.abort();
if (this.inputRejecter) {
this.inputRejecter(new Error("Login cancelled"));
this.inputResolver = undefined;
this.inputRejecter = undefined;
}
this.onComplete(false, "Login cancelled");
}
/**
* Called by onAuth callback - show URL and optional instructions
*/
showAuth(url: string, instructions?: string): void {
this.contentContainer.clear();
this.contentContainer.addChild(new Spacer(1));
const linkedUrl = `\x1b]8;;${url}\x07${url}\x1b]8;;\x07`;
this.contentContainer.addChild(new Text(theme.fg("accent", linkedUrl), 1, 0));
const clickHint = process.platform === "darwin" ? "Cmd+click to open" : "Ctrl+click to open";
const hyperlink = `\x1b]8;;${url}\x07${clickHint}\x1b]8;;\x07`;
this.contentContainer.addChild(new Text(theme.fg("dim", hyperlink), 1, 0));
if (instructions) {
this.contentContainer.addChild(new Spacer(1));
this.contentContainer.addChild(new Text(theme.fg("warning", instructions), 1, 0));
}
openBrowser(url);
this.tui.requestRender();
}
/**
* Called by onDeviceCode callback - show URL and user code.
*/
showDeviceCode(info: OAuthDeviceCodeInfo): void {
this.contentContainer.clear();
this.contentContainer.addChild(new Spacer(1));
const linkedUrl = `\x1b]8;;${info.verificationUri}\x07${info.verificationUri}\x1b]8;;\x07`;
this.contentContainer.addChild(new Text(theme.fg("accent", linkedUrl), 1, 0));
const clickHint = process.platform === "darwin" ? "Cmd+click to open" : "Ctrl+click to open";
const hyperlink = `\x1b]8;;${info.verificationUri}\x07${clickHint}\x1b]8;;\x07`;
this.contentContainer.addChild(new Text(theme.fg("dim", hyperlink), 1, 0));
this.contentContainer.addChild(new Spacer(1));
this.contentContainer.addChild(new Text(theme.fg("warning", `Enter code: ${info.userCode}`), 1, 0));
this.tui.requestRender();
}
/**
* Show input for manual code/URL entry (for callback server providers)
*/
showManualInput(prompt: string): Promise<string> {
this.input.setValue("");
this.contentContainer.addChild(new Spacer(1));
this.contentContainer.addChild(new Text(theme.fg("dim", prompt), 1, 0));
this.contentContainer.addChild(this.input);
this.contentContainer.addChild(new Text(`(${keyHint("tui.select.cancel", "to cancel")})`, 1, 0));
this.tui.requestRender();
return new Promise((resolve, reject) => {
this.inputResolver = resolve;
this.inputRejecter = reject;
});
}
/**
* Called by onPrompt callback - show prompt and wait for input
* Note: Does NOT clear content, appends to existing (preserves URL from showAuth)
*/
showPrompt(message: string, placeholder?: string): Promise<string> {
this.contentContainer.addChild(new Spacer(1));
this.contentContainer.addChild(new Text(theme.fg("text", message), 1, 0));
if (placeholder) {
this.contentContainer.addChild(new Text(theme.fg("dim", `e.g., ${placeholder}`), 1, 0));
}
this.contentContainer.addChild(this.input);
this.contentContainer.addChild(
new Text(
`(${keyHint("tui.select.cancel", "to cancel,")} ${keyHint("tui.select.confirm", "to submit")})`,
1,
0,
),
);
this.input.setValue("");
this.tui.requestRender();
return new Promise((resolve, reject) => {
this.inputResolver = resolve;
this.inputRejecter = reject;
});
}
/**
* Show informational text before another login step.
*/
showDetails(lines: string[]): void {
this.contentContainer.clear();
this.contentContainer.addChild(new Spacer(1));
for (const line of lines) {
this.contentContainer.addChild(new Text(line, 1, 0));
}
this.tui.requestRender();
}
/**
* Show informational text without prompting for input.
*/
showInfo(lines: string[]): void {
this.showDetails(lines);
this.contentContainer.addChild(new Spacer(1));
this.contentContainer.addChild(new Text(`(${keyHint("tui.select.cancel", "to close")})`, 1, 0));
this.tui.requestRender();
}
/**
* Show waiting message (for polling flows like GitHub Copilot)
*/
showWaiting(message: string): void {
this.contentContainer.addChild(new Spacer(1));
this.contentContainer.addChild(new Text(theme.fg("dim", message), 1, 0));
this.contentContainer.addChild(new Text(`(${keyHint("tui.select.cancel", "to cancel")})`, 1, 0));
this.tui.requestRender();
}
/**
* Called by onProgress callback
*/
showProgress(message: string): void {
this.contentContainer.addChild(new Text(theme.fg("dim", message), 1, 0));
this.tui.requestRender();
}
handleInput(data: string): void {
const kb = getKeybindings();
if (kb.matches(data, "tui.select.cancel")) {
this.cancel();
return;
}
// Pass to input
this.input.handleInput(data);
}
}
@@ -0,0 +1,337 @@
import { type Model, modelsAreEqual } from "@earendil-works/pi-ai";
import {
Container,
type Focusable,
fuzzyFilter,
getKeybindings,
Input,
Spacer,
Text,
type TUI,
} from "@earendil-works/pi-tui";
import type { ModelRegistry } from "../../../core/model-registry.ts";
import type { SettingsManager } from "../../../core/settings-manager.ts";
import { getModelSelectorSearchText } from "../model-search.ts";
import { theme } from "../theme/theme.ts";
import { DynamicBorder } from "./dynamic-border.ts";
import { keyHint } from "./keybinding-hints.ts";
interface ModelItem {
provider: string;
id: string;
model: Model<any>;
}
interface ScopedModelItem {
model: Model<any>;
thinkingLevel?: string;
}
type ModelScope = "all" | "scoped";
/**
* Component that renders a model selector with search
*/
export class ModelSelectorComponent extends Container implements Focusable {
private searchInput: Input;
// Focusable implementation - propagate to searchInput for IME cursor positioning
private _focused = false;
get focused(): boolean {
return this._focused;
}
set focused(value: boolean) {
this._focused = value;
this.searchInput.focused = value;
}
private listContainer: Container;
private allModels: ModelItem[] = [];
private scopedModelItems: ModelItem[] = [];
private activeModels: ModelItem[] = [];
private filteredModels: ModelItem[] = [];
private selectedIndex: number = 0;
private currentModel?: Model<any>;
private settingsManager: SettingsManager;
private modelRegistry: ModelRegistry;
private onSelectCallback: (model: Model<any>) => void;
private onCancelCallback: () => void;
private errorMessage?: string;
private tui: TUI;
private scopedModels: ReadonlyArray<ScopedModelItem>;
private scope: ModelScope = "all";
private scopeText?: Text;
private scopeHintText?: Text;
constructor(
tui: TUI,
currentModel: Model<any> | undefined,
settingsManager: SettingsManager,
modelRegistry: ModelRegistry,
scopedModels: ReadonlyArray<ScopedModelItem>,
onSelect: (model: Model<any>) => void,
onCancel: () => void,
initialSearchInput?: string,
) {
super();
this.tui = tui;
this.currentModel = currentModel;
this.settingsManager = settingsManager;
this.modelRegistry = modelRegistry;
this.scopedModels = scopedModels;
this.scope = scopedModels.length > 0 ? "scoped" : "all";
this.onSelectCallback = onSelect;
this.onCancelCallback = onCancel;
// Add top border
this.addChild(new DynamicBorder());
this.addChild(new Spacer(1));
// Add hint about model filtering
if (scopedModels.length > 0) {
this.scopeText = new Text(this.getScopeText(), 0, 0);
this.addChild(this.scopeText);
this.scopeHintText = new Text(this.getScopeHintText(), 0, 0);
this.addChild(this.scopeHintText);
} else {
const hintText = "Only showing models from configured providers. Use /login to add providers.";
this.addChild(new Text(theme.fg("warning", hintText), 0, 0));
}
this.addChild(new Spacer(1));
// Create search input
this.searchInput = new Input();
if (initialSearchInput) {
this.searchInput.setValue(initialSearchInput);
}
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(() => {
if (initialSearchInput) {
this.filterModels(initialSearchInput);
} else {
this.updateList();
}
// Request re-render after models are loaded
this.tui.requestRender();
});
}
private async loadModels(): Promise<void> {
let models: ModelItem[];
// Refresh to pick up any changes to models.json
this.modelRegistry.refresh();
// Check for models.json errors
const loadError = this.modelRegistry.getError();
if (loadError) {
this.errorMessage = loadError;
}
// Load available models (built-in models still work even if models.json failed)
try {
const availableModels = await this.modelRegistry.getAvailable();
models = availableModels.map((model: Model<any>) => ({
provider: model.provider,
id: model.id,
model,
}));
} catch (error) {
this.allModels = [];
this.scopedModelItems = [];
this.activeModels = [];
this.filteredModels = [];
this.errorMessage = error instanceof Error ? error.message : String(error);
return;
}
this.allModels = this.sortModels(models);
this.scopedModels = this.scopedModels.map((scoped) => {
const refreshed = this.modelRegistry.find(scoped.model.provider, scoped.model.id);
return refreshed ? { ...scoped, model: refreshed } : scoped;
});
this.scopedModelItems = this.scopedModels.map((scoped) => ({
provider: scoped.model.provider,
id: scoped.model.id,
model: scoped.model,
}));
this.activeModels = this.scope === "scoped" ? this.scopedModelItems : this.allModels;
this.filteredModels = this.activeModels;
const currentIndex = this.filteredModels.findIndex((item) => modelsAreEqual(this.currentModel, item.model));
this.selectedIndex =
currentIndex >= 0 ? currentIndex : Math.min(this.selectedIndex, Math.max(0, this.filteredModels.length - 1));
}
private sortModels(models: ModelItem[]): ModelItem[] {
const sorted = [...models];
// Sort: current model first, then by provider
sorted.sort((a, b) => {
const aIsCurrent = modelsAreEqual(this.currentModel, a.model);
const bIsCurrent = modelsAreEqual(this.currentModel, b.model);
if (aIsCurrent && !bIsCurrent) return -1;
if (!aIsCurrent && bIsCurrent) return 1;
return a.provider.localeCompare(b.provider);
});
return sorted;
}
private getScopeText(): string {
const allText = this.scope === "all" ? theme.fg("accent", "all") : theme.fg("muted", "all");
const scopedText = this.scope === "scoped" ? theme.fg("accent", "scoped") : theme.fg("muted", "scoped");
return `${theme.fg("muted", "Scope: ")}${allText}${theme.fg("muted", " | ")}${scopedText}`;
}
private getScopeHintText(): string {
return keyHint("tui.input.tab", "scope") + theme.fg("muted", " (all/scoped)");
}
private setScope(scope: ModelScope): void {
if (this.scope === scope) return;
this.scope = scope;
this.activeModels = this.scope === "scoped" ? this.scopedModelItems : this.allModels;
const currentIndex = this.activeModels.findIndex((item) => modelsAreEqual(this.currentModel, item.model));
this.selectedIndex = currentIndex >= 0 ? currentIndex : 0;
this.filterModels(this.searchInput.getValue());
if (this.scopeText) {
this.scopeText.setText(this.getScopeText());
}
}
private filterModels(query: string): void {
this.filteredModels = query
? fuzzyFilter(this.activeModels, query, ({ id, provider, model }) =>
getModelSelectorSearchText({ id, provider, name: model.name }),
)
: this.activeModels;
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 = modelsAreEqual(this.currentModel, item.model);
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));
} else {
const selected = this.filteredModels[this.selectedIndex];
this.listContainer.addChild(new Spacer(1));
this.listContainer.addChild(new Text(theme.fg("muted", ` Model Name: ${selected.model.name}`), 0, 0));
}
}
handleInput(keyData: string): void {
const kb = getKeybindings();
if (kb.matches(keyData, "tui.input.tab")) {
if (this.scopedModelItems.length > 0) {
const nextScope: ModelScope = this.scope === "all" ? "scoped" : "all";
this.setScope(nextScope);
if (this.scopeHintText) {
this.scopeHintText.setText(this.getScopeHintText());
}
}
return;
}
// Up arrow - wrap to bottom when at top
if (kb.matches(keyData, "tui.select.up")) {
if (this.filteredModels.length === 0) return;
this.selectedIndex = this.selectedIndex === 0 ? this.filteredModels.length - 1 : this.selectedIndex - 1;
this.updateList();
}
// Down arrow - wrap to top when at bottom
else if (kb.matches(keyData, "tui.select.down")) {
if (this.filteredModels.length === 0) return;
this.selectedIndex = this.selectedIndex === this.filteredModels.length - 1 ? 0 : this.selectedIndex + 1;
this.updateList();
}
// Enter
else if (kb.matches(keyData, "tui.select.confirm")) {
const selectedModel = this.filteredModels[this.selectedIndex];
if (selectedModel) {
this.handleSelect(selectedModel.model);
}
}
// Escape or Ctrl+C
else if (kb.matches(keyData, "tui.select.cancel")) {
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,221 @@
import {
Container,
type Focusable,
fuzzyFilter,
getKeybindings,
Input,
Spacer,
TruncatedText,
} from "@earendil-works/pi-tui";
import type { AuthStatus, AuthStorage } from "../../../core/auth-storage.ts";
import { theme } from "../theme/theme.ts";
import { DynamicBorder } from "./dynamic-border.ts";
export type AuthSelectorProvider = {
id: string;
name: string;
authType: "oauth" | "api_key";
};
export function formatAuthSelectorProviderType(authType: AuthSelectorProvider["authType"]): string {
return authType === "oauth" ? "subscription" : "API key";
}
/**
* Component that renders an auth provider selector
*/
export class OAuthSelectorComponent extends Container implements Focusable {
private searchInput: Input;
// Focusable implementation - propagate to search input for IME cursor positioning
private _focused = false;
get focused(): boolean {
return this._focused;
}
set focused(value: boolean) {
this._focused = value;
this.searchInput.focused = value;
}
private listContainer: Container;
private allProviders: AuthSelectorProvider[];
private filteredProviders: AuthSelectorProvider[];
private selectedIndex: number = 0;
private mode: "login" | "logout";
private authStorage: AuthStorage;
private getAuthStatus: (providerId: string) => AuthStatus;
private onSelectCallback: (providerId: string, authType: AuthSelectorProvider["authType"]) => void;
private onCancelCallback: () => void;
private showAuthTypeLabels: boolean;
constructor(
mode: "login" | "logout",
authStorage: AuthStorage,
providers: AuthSelectorProvider[],
onSelect: (providerId: string, authType: AuthSelectorProvider["authType"]) => void,
onCancel: () => void,
getAuthStatus?: (providerId: string) => AuthStatus,
initialSearchInput?: string,
) {
super();
this.mode = mode;
this.authStorage = authStorage;
this.getAuthStatus = getAuthStatus ?? ((providerId) => this.authStorage.getAuthStatus(providerId));
this.allProviders = providers;
this.filteredProviders = providers;
this.showAuthTypeLabels = new Set(providers.map((provider) => provider.authType)).size > 1;
this.onSelectCallback = onSelect;
this.onCancelCallback = onCancel;
// Add top border
this.addChild(new DynamicBorder());
this.addChild(new Spacer(1));
// Add title
const title = mode === "login" ? "Select provider to configure:" : "Select provider to logout:";
this.addChild(new TruncatedText(theme.fg("accent", theme.bold(title)), 1, 0));
this.addChild(new Spacer(1));
this.searchInput = new Input();
if (initialSearchInput) {
this.searchInput.setValue(initialSearchInput);
}
this.searchInput.onSubmit = () => {
const selectedProvider = this.filteredProviders[this.selectedIndex];
if (selectedProvider) {
this.onSelectCallback(selectedProvider.id, selectedProvider.authType);
}
};
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());
// Initial render
this.filterProviders(initialSearchInput ?? "");
}
private filterProviders(query: string): void {
this.filteredProviders = query
? fuzzyFilter(this.allProviders, query, (provider) => `${provider.name} ${provider.id} ${provider.authType}`)
: this.allProviders;
this.selectedIndex = Math.max(0, Math.min(this.selectedIndex, Math.max(0, this.filteredProviders.length - 1)));
this.updateList();
}
private updateList(): void {
this.listContainer.clear();
const maxVisible = 8;
const startIndex = Math.max(
0,
Math.min(this.selectedIndex - Math.floor(maxVisible / 2), this.filteredProviders.length - maxVisible),
);
const endIndex = Math.min(startIndex + maxVisible, this.filteredProviders.length);
for (let i = startIndex; i < endIndex; i++) {
const provider = this.filteredProviders[i];
if (!provider) continue;
const isSelected = i === this.selectedIndex;
const statusIndicator = this.formatStatusIndicator(provider);
const authTypeLabel = this.showAuthTypeLabels
? theme.fg("muted", ` [${formatAuthSelectorProviderType(provider.authType)}]`)
: "";
let line = "";
if (isSelected) {
const prefix = theme.fg("accent", "→ ");
const text = theme.fg("accent", provider.name);
line = prefix + text + authTypeLabel + statusIndicator;
} else {
const text = ` ${theme.fg("text", provider.name)}`;
line = text + authTypeLabel + statusIndicator;
}
this.listContainer.addChild(new TruncatedText(line, 1, 0));
}
if (startIndex > 0 || endIndex < this.filteredProviders.length) {
const scrollInfo = theme.fg("muted", ` (${this.selectedIndex + 1}/${this.filteredProviders.length})`);
this.listContainer.addChild(new TruncatedText(scrollInfo, 1, 0));
}
// Show "no providers" if empty
if (this.filteredProviders.length === 0) {
const message =
this.allProviders.length === 0
? this.mode === "login"
? "No providers available"
: "No providers logged in. Use /login first."
: "No matching providers";
this.listContainer.addChild(new TruncatedText(theme.fg("muted", ` ${message}`), 1, 0));
}
}
private formatStatusIndicator(provider: AuthSelectorProvider): string {
const credential = this.authStorage.get(provider.id);
if (credential?.type === provider.authType) return theme.fg("success", " ✓ configured");
if (credential) {
const label = credential.type === "oauth" ? "subscription configured" : "API key configured";
return theme.fg("muted", " • ") + theme.fg("warning", label);
}
if (provider.authType !== "api_key") return theme.fg("muted", " • unconfigured");
const status = this.getAuthStatus(provider.id);
switch (status.source) {
case "environment":
return theme.fg("success", ` ✓ env: ${status.label ?? "API key"}`);
case "runtime":
return theme.fg("success", " ✓ runtime API key");
case "fallback":
return theme.fg("success", " ✓ custom API key");
case "models_json_key":
return theme.fg("success", " ✓ key in models.json");
case "models_json_command":
return theme.fg("success", " ✓ command in models.json");
default:
return theme.fg("muted", " • unconfigured");
}
}
handleInput(keyData: string): void {
const kb = getKeybindings();
// Up arrow
if (kb.matches(keyData, "tui.select.up")) {
if (this.filteredProviders.length === 0) return;
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
this.updateList();
}
// Down arrow
else if (kb.matches(keyData, "tui.select.down")) {
if (this.filteredProviders.length === 0) return;
this.selectedIndex = Math.min(this.filteredProviders.length - 1, this.selectedIndex + 1);
this.updateList();
}
// Enter
else if (kb.matches(keyData, "tui.select.confirm")) {
const selectedProvider = this.filteredProviders[this.selectedIndex];
if (selectedProvider) {
this.onSelectCallback(selectedProvider.id, selectedProvider.authType);
}
}
// Escape or Ctrl+C
else if (kb.matches(keyData, "tui.select.cancel")) {
this.onCancelCallback();
}
// Pass everything else to search input
else {
this.searchInput.handleInput(keyData);
this.filterProviders(this.searchInput.getValue());
}
}
}
@@ -0,0 +1,360 @@
import type { Model } from "@earendil-works/pi-ai";
import {
Container,
type Focusable,
fuzzyFilter,
getKeybindings,
Input,
Key,
matchesKey,
Spacer,
Text,
} from "@earendil-works/pi-tui";
import { getModelSearchText } from "../model-search.ts";
import { theme } from "../theme/theme.ts";
import { DynamicBorder } from "./dynamic-border.ts";
import { keyText } from "./keybinding-hints.ts";
// EnabledIds: null = all enabled (no filter), string[] = explicit ordered list
type EnabledIds = string[] | null;
function isEnabled(enabledIds: EnabledIds, id: string): boolean {
return enabledIds === null || enabledIds.includes(id);
}
function toggle(enabledIds: EnabledIds, id: string): EnabledIds {
if (enabledIds === null) return [id]; // First toggle: start with only this one
const index = enabledIds.indexOf(id);
if (index >= 0) return [...enabledIds.slice(0, index), ...enabledIds.slice(index + 1)];
return [...enabledIds, id];
}
function enableAll(enabledIds: EnabledIds, allIds: string[], targetIds?: string[]): EnabledIds {
if (enabledIds === null) return null; // Already all enabled
const targets = targetIds ?? allIds;
const result = [...enabledIds];
for (const id of targets) {
if (!result.includes(id)) result.push(id);
}
return result.length === allIds.length ? null : result;
}
function clearAll(enabledIds: EnabledIds, allIds: string[], targetIds?: string[]): EnabledIds {
if (enabledIds === null) {
return targetIds ? allIds.filter((id) => !targetIds.includes(id)) : [];
}
const targets = new Set(targetIds ?? enabledIds);
return enabledIds.filter((id) => !targets.has(id));
}
function move(enabledIds: EnabledIds, id: string, delta: number): EnabledIds {
if (enabledIds === null) return null;
const list = [...enabledIds];
const index = list.indexOf(id);
if (index < 0) return list;
const newIndex = index + delta;
if (newIndex < 0 || newIndex >= list.length) return list;
const result = [...list];
[result[index], result[newIndex]] = [result[newIndex], result[index]];
return result;
}
function getSortedIds(enabledIds: EnabledIds, allIds: string[]): string[] {
if (enabledIds === null) return allIds;
const enabledSet = new Set(enabledIds);
return [...enabledIds, ...allIds.filter((id) => !enabledSet.has(id))];
}
interface ModelItem {
fullId: string;
model: Model<any>;
enabled: boolean;
}
export interface ModelsConfig {
allModels: Model<any>[];
enabledModelIds: string[] | null;
}
export interface ModelsCallbacks {
/** Called whenever the enabled model set or order changes (session-only, no persist) */
onChange: (enabledModelIds: string[] | null) => void | Promise<void>;
/** Called when user wants to persist current selection to settings */
onPersist: (enabledModelIds: string[] | null) => void | Promise<void>;
onCancel: () => void;
}
/**
* Component for enabling/disabling models for Ctrl+P cycling.
* Changes are session-only until explicitly persisted with Ctrl+S.
*/
export class ScopedModelsSelectorComponent extends Container implements Focusable {
private modelsById: Map<string, Model<any>> = new Map();
private allIds: string[] = [];
private enabledIds: EnabledIds = null;
private filteredItems: ModelItem[] = [];
private selectedIndex = 0;
private searchInput: Input;
// Focusable implementation - propagate to searchInput for IME cursor positioning
private _focused = false;
get focused(): boolean {
return this._focused;
}
set focused(value: boolean) {
this._focused = value;
this.searchInput.focused = value;
}
private listContainer: Container;
private footerText: Text;
private callbacks: ModelsCallbacks;
private maxVisible = 8;
private isDirty = false;
constructor(config: ModelsConfig, callbacks: ModelsCallbacks) {
super();
this.callbacks = callbacks;
for (const model of config.allModels) {
const fullId = `${model.provider}/${model.id}`;
this.modelsById.set(fullId, model);
this.allIds.push(fullId);
}
this.enabledIds = config.enabledModelIds === null ? null : [...config.enabledModelIds];
this.filteredItems = this.buildItems();
// Header
this.addChild(new DynamicBorder());
this.addChild(new Spacer(1));
this.addChild(new Text(theme.fg("accent", theme.bold("Model Configuration")), 0, 0));
this.addChild(
new Text(theme.fg("muted", `Session-only. ${keyText("app.models.save")} to save to settings.`), 0, 0),
);
this.addChild(new Spacer(1));
// Search input
this.searchInput = new Input();
this.addChild(this.searchInput);
this.addChild(new Spacer(1));
// List container
this.listContainer = new Container();
this.addChild(this.listContainer);
// Footer hint
this.addChild(new Spacer(1));
this.footerText = new Text(this.getFooterText(), 0, 0);
this.addChild(this.footerText);
this.addChild(new DynamicBorder());
this.updateList();
}
private buildItems(): ModelItem[] {
// Filter out IDs that no longer have a corresponding model (e.g., after logout)
return getSortedIds(this.enabledIds, this.allIds)
.filter((id) => this.modelsById.has(id))
.map((id) => ({
fullId: id,
model: this.modelsById.get(id)!,
enabled: isEnabled(this.enabledIds, id),
}));
}
private getFooterText(): string {
const enabledCount = this.enabledIds?.length ?? this.allIds.length;
const allEnabled = this.enabledIds === null;
const countText = allEnabled ? "all enabled" : `${enabledCount}/${this.allIds.length} enabled`;
const parts = [
`${keyText("tui.select.confirm")} toggle`,
`${keyText("app.models.enableAll")} all`,
`${keyText("app.models.clearAll")} clear`,
`${keyText("app.models.toggleProvider")} provider`,
`${keyText("app.models.reorderUp")}/${keyText("app.models.reorderDown")} reorder`,
`${keyText("app.models.save")} save`,
countText,
];
return this.isDirty
? theme.fg("dim", ` ${parts.join(" · ")} `) + theme.fg("warning", "(unsaved)")
: theme.fg("dim", ` ${parts.join(" · ")}`);
}
private refresh(): void {
const query = this.searchInput.getValue();
const items = this.buildItems();
this.filteredItems = query
? fuzzyFilter(items, query, (i) =>
getModelSearchText({ id: i.model.id, provider: i.model.provider, name: i.model.name }),
)
: items;
this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredItems.length - 1));
this.updateList();
this.footerText.setText(this.getFooterText());
}
private notifyChange(): void {
this.callbacks.onChange(this.enabledIds === null ? null : [...this.enabledIds]);
}
private updateList(): void {
this.listContainer.clear();
if (this.filteredItems.length === 0) {
this.listContainer.addChild(new Text(theme.fg("muted", " No matching models"), 0, 0));
return;
}
const startIndex = Math.max(
0,
Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.filteredItems.length - this.maxVisible),
);
const endIndex = Math.min(startIndex + this.maxVisible, this.filteredItems.length);
const allEnabled = this.enabledIds === null;
for (let i = startIndex; i < endIndex; i++) {
const item = this.filteredItems[i]!;
const isSelected = i === this.selectedIndex;
const prefix = isSelected ? theme.fg("accent", "→ ") : " ";
const modelText = isSelected ? theme.fg("accent", item.model.id) : item.model.id;
const providerBadge = theme.fg("muted", ` [${item.model.provider}]`);
const status = allEnabled ? "" : item.enabled ? theme.fg("success", " ✓") : theme.fg("dim", " ✗");
this.listContainer.addChild(new Text(`${prefix}${modelText}${providerBadge}${status}`, 0, 0));
}
// Add scroll indicator if needed
if (startIndex > 0 || endIndex < this.filteredItems.length) {
this.listContainer.addChild(
new Text(theme.fg("muted", ` (${this.selectedIndex + 1}/${this.filteredItems.length})`), 0, 0),
);
}
if (this.filteredItems.length > 0) {
const selected = this.filteredItems[this.selectedIndex];
this.listContainer.addChild(new Spacer(1));
this.listContainer.addChild(new Text(theme.fg("muted", ` Model Name: ${selected.model.name}`), 0, 0));
}
}
handleInput(data: string): void {
const kb = getKeybindings();
// Navigation
if (kb.matches(data, "tui.select.up")) {
if (this.filteredItems.length === 0) return;
this.selectedIndex = this.selectedIndex === 0 ? this.filteredItems.length - 1 : this.selectedIndex - 1;
this.updateList();
return;
}
if (kb.matches(data, "tui.select.down")) {
if (this.filteredItems.length === 0) return;
this.selectedIndex = this.selectedIndex === this.filteredItems.length - 1 ? 0 : this.selectedIndex + 1;
this.updateList();
return;
}
// Reorder enabled models
const reorderUp = kb.matches(data, "app.models.reorderUp");
const reorderDown = kb.matches(data, "app.models.reorderDown");
if (reorderUp || reorderDown) {
if (this.enabledIds === null) return;
const item = this.filteredItems[this.selectedIndex];
if (item && isEnabled(this.enabledIds, item.fullId)) {
const delta = reorderUp ? -1 : 1;
const currentIndex = this.enabledIds.indexOf(item.fullId);
const newIndex = currentIndex + delta;
// Only move if within bounds
if (newIndex >= 0 && newIndex < this.enabledIds.length) {
this.enabledIds = move(this.enabledIds, item.fullId, delta);
this.isDirty = true;
this.selectedIndex += delta;
this.refresh();
this.notifyChange();
}
}
return;
}
// Toggle on Enter
if (kb.matches(data, "tui.select.confirm")) {
const item = this.filteredItems[this.selectedIndex];
if (item) {
this.enabledIds = toggle(this.enabledIds, item.fullId);
this.isDirty = true;
this.refresh();
this.notifyChange();
}
return;
}
// Enable all (filtered if search active, otherwise all)
if (kb.matches(data, "app.models.enableAll")) {
const targetIds = this.searchInput.getValue() ? this.filteredItems.map((i) => i.fullId) : undefined;
this.enabledIds = enableAll(this.enabledIds, this.allIds, targetIds);
this.isDirty = true;
this.refresh();
this.notifyChange();
return;
}
// Clear all (filtered if search active, otherwise all)
if (kb.matches(data, "app.models.clearAll")) {
const targetIds = this.searchInput.getValue() ? this.filteredItems.map((i) => i.fullId) : undefined;
this.enabledIds = clearAll(this.enabledIds, this.allIds, targetIds);
this.isDirty = true;
this.refresh();
this.notifyChange();
return;
}
// Toggle provider of current item
if (kb.matches(data, "app.models.toggleProvider")) {
const item = this.filteredItems[this.selectedIndex];
if (item) {
const provider = item.model.provider;
const providerIds = this.allIds.filter((id) => this.modelsById.get(id)!.provider === provider);
const allEnabled = providerIds.every((id) => isEnabled(this.enabledIds, id));
this.enabledIds = allEnabled
? clearAll(this.enabledIds, this.allIds, providerIds)
: enableAll(this.enabledIds, this.allIds, providerIds);
this.isDirty = true;
this.refresh();
this.notifyChange();
}
return;
}
// Save/persist to settings
if (kb.matches(data, "app.models.save")) {
this.callbacks.onPersist(this.enabledIds === null ? null : [...this.enabledIds]);
this.isDirty = false;
this.footerText.setText(this.getFooterText());
return;
}
// Ctrl+C - clear search or cancel if empty
if (matchesKey(data, Key.ctrl("c"))) {
if (this.searchInput.getValue()) {
this.searchInput.setValue("");
this.refresh();
} else {
this.callbacks.onCancel();
}
return;
}
// Escape - cancel
if (matchesKey(data, Key.escape)) {
this.callbacks.onCancel();
return;
}
// Pass everything else to search input
this.searchInput.handleInput(data);
this.refresh();
}
getSearchInput(): Input {
return this.searchInput;
}
}
@@ -0,0 +1,194 @@
import { fuzzyMatch } from "@earendil-works/pi-tui";
import type { SessionInfo } from "../../../core/session-manager.ts";
export type SortMode = "threaded" | "recent" | "relevance";
export type NameFilter = "all" | "named";
export interface ParsedSearchQuery {
mode: "tokens" | "regex";
tokens: { kind: "fuzzy" | "phrase"; value: string }[];
regex: RegExp | null;
/** If set, parsing failed and we should treat query as non-matching. */
error?: string;
}
export interface MatchResult {
matches: boolean;
/** Lower is better; only meaningful when matches === true */
score: number;
}
function normalizeWhitespaceLower(text: string): string {
return text.toLowerCase().replace(/\s+/g, " ").trim();
}
function getSessionSearchText(session: SessionInfo): string {
return `${session.id} ${session.name ?? ""} ${session.allMessagesText} ${session.cwd}`;
}
export function hasSessionName(session: SessionInfo): boolean {
return Boolean(session.name?.trim());
}
function matchesNameFilter(session: SessionInfo, filter: NameFilter): boolean {
if (filter === "all") return true;
return hasSessionName(session);
}
export function parseSearchQuery(query: string): ParsedSearchQuery {
const trimmed = query.trim();
if (!trimmed) {
return { mode: "tokens", tokens: [], regex: null };
}
// Regex mode: re:<pattern>
if (trimmed.startsWith("re:")) {
const pattern = trimmed.slice(3).trim();
if (!pattern) {
return { mode: "regex", tokens: [], regex: null, error: "Empty regex" };
}
try {
return { mode: "regex", tokens: [], regex: new RegExp(pattern, "i") };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return { mode: "regex", tokens: [], regex: null, error: msg };
}
}
// Token mode with quote support.
// Example: foo "node cve" bar
const tokens: { kind: "fuzzy" | "phrase"; value: string }[] = [];
let buf = "";
let inQuote = false;
let hadUnclosedQuote = false;
const flush = (kind: "fuzzy" | "phrase"): void => {
const v = buf.trim();
buf = "";
if (!v) return;
tokens.push({ kind, value: v });
};
for (let i = 0; i < trimmed.length; i++) {
const ch = trimmed[i]!;
if (ch === '"') {
if (inQuote) {
flush("phrase");
inQuote = false;
} else {
flush("fuzzy");
inQuote = true;
}
continue;
}
if (!inQuote && /\s/.test(ch)) {
flush("fuzzy");
continue;
}
buf += ch;
}
if (inQuote) {
hadUnclosedQuote = true;
}
// If quotes were unbalanced, fall back to plain whitespace tokenization.
if (hadUnclosedQuote) {
return {
mode: "tokens",
tokens: trimmed
.split(/\s+/)
.map((t) => t.trim())
.filter((t) => t.length > 0)
.map((t) => ({ kind: "fuzzy" as const, value: t })),
regex: null,
};
}
flush(inQuote ? "phrase" : "fuzzy");
return { mode: "tokens", tokens, regex: null };
}
export function matchSession(session: SessionInfo, parsed: ParsedSearchQuery): MatchResult {
const text = getSessionSearchText(session);
if (parsed.mode === "regex") {
if (!parsed.regex) {
return { matches: false, score: 0 };
}
const idx = text.search(parsed.regex);
if (idx < 0) return { matches: false, score: 0 };
return { matches: true, score: idx * 0.1 };
}
if (parsed.tokens.length === 0) {
return { matches: true, score: 0 };
}
let totalScore = 0;
let normalizedText: string | null = null;
for (const token of parsed.tokens) {
if (token.kind === "phrase") {
if (normalizedText === null) {
normalizedText = normalizeWhitespaceLower(text);
}
const phrase = normalizeWhitespaceLower(token.value);
if (!phrase) continue;
const idx = normalizedText.indexOf(phrase);
if (idx < 0) return { matches: false, score: 0 };
totalScore += idx * 0.1;
continue;
}
const m = fuzzyMatch(token.value, text);
if (!m.matches) return { matches: false, score: 0 };
totalScore += m.score;
}
return { matches: true, score: totalScore };
}
export function filterAndSortSessions(
sessions: SessionInfo[],
query: string,
sortMode: SortMode,
nameFilter: NameFilter = "all",
): SessionInfo[] {
const nameFiltered =
nameFilter === "all" ? sessions : sessions.filter((session) => matchesNameFilter(session, nameFilter));
const trimmed = query.trim();
if (!trimmed) return nameFiltered;
const parsed = parseSearchQuery(query);
if (parsed.error) return [];
// Recent mode: filter only, keep incoming order.
if (sortMode === "recent") {
const filtered: SessionInfo[] = [];
for (const s of nameFiltered) {
const res = matchSession(s, parsed);
if (res.matches) filtered.push(s);
}
return filtered;
}
// Relevance mode: sort by score, tie-break by modified desc.
const scored: { session: SessionInfo; score: number }[] = [];
for (const s of nameFiltered) {
const res = matchSession(s, parsed);
if (!res.matches) continue;
scored.push({ session: s, score: res.score });
}
scored.sort((a, b) => {
if (a.score !== b.score) return a.score - b.score;
return b.session.modified.getTime() - a.session.modified.getTime();
});
return scored.map((r) => r.session);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,838 @@
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import type { Transport } from "@earendil-works/pi-ai";
import {
type Component,
Container,
getCapabilities,
type SelectItem,
SelectList,
type SelectListLayoutOptions,
type SettingItem,
SettingsList,
Spacer,
Text,
} from "@earendil-works/pi-tui";
import { formatHttpIdleTimeoutMs, HTTP_IDLE_TIMEOUT_CHOICES } from "../../../core/http-dispatcher.ts";
import type { DefaultProjectTrust, WarningSettings } from "../../../core/settings-manager.ts";
import {
getSelectListTheme,
getSettingsListTheme,
parseAutoThemeSetting,
type TerminalTheme,
theme,
} from "../theme/theme.ts";
import { DynamicBorder } from "./dynamic-border.ts";
import { keyDisplayText } from "./keybinding-hints.ts";
const SETTINGS_SUBMENU_SELECT_LIST_LAYOUT: SelectListLayoutOptions = {
minPrimaryColumnWidth: 12,
maxPrimaryColumnWidth: 32,
};
const THINKING_DESCRIPTIONS: Record<ThinkingLevel, string> = {
off: "No reasoning",
minimal: "Very brief reasoning (~1k tokens)",
low: "Light reasoning (~2k tokens)",
medium: "Moderate reasoning (~8k tokens)",
high: "Deep reasoning (~16k tokens)",
xhigh: "Extra-high reasoning (~32k tokens)",
max: "Maximum reasoning",
};
const DEFAULT_PROJECT_TRUST_LABELS: Record<DefaultProjectTrust, string> = {
ask: "Ask",
always: "Always trust",
never: "Never trust",
};
const DEFAULT_PROJECT_TRUST_BY_LABEL = new Map(
Object.entries(DEFAULT_PROJECT_TRUST_LABELS).map(([value, label]) => [label, value as DefaultProjectTrust]),
);
export interface SettingsConfig {
autoCompact: boolean;
showImages: boolean;
imageWidthCells: number;
autoResizeImages: boolean;
blockImages: boolean;
enableSkillCommands: boolean;
steeringMode: "all" | "one-at-a-time";
followUpMode: "all" | "one-at-a-time";
transport: Transport;
httpIdleTimeoutMs: number;
thinkingLevel: ThinkingLevel;
availableThinkingLevels: ThinkingLevel[];
currentTheme: string;
terminalTheme: TerminalTheme;
availableThemes: string[];
hideThinkingBlock: boolean;
showCacheMissNotices: boolean;
collapseChangelog: boolean;
enableInstallTelemetry: boolean;
doubleEscapeAction: "fork" | "tree" | "none";
treeFilterMode: "default" | "no-tools" | "user-only" | "labeled-only" | "all";
showHardwareCursor: boolean;
editorPaddingX: number;
outputPad: 0 | 1;
autocompleteMaxVisible: number;
quietStartup: boolean;
defaultProjectTrust: DefaultProjectTrust;
clearOnShrink: boolean;
showTerminalProgress: boolean;
warnings: WarningSettings;
}
export interface SettingsCallbacks {
onAutoCompactChange: (enabled: boolean) => void;
onShowImagesChange: (enabled: boolean) => void;
onImageWidthCellsChange: (width: number) => void;
onAutoResizeImagesChange: (enabled: boolean) => void;
onBlockImagesChange: (blocked: boolean) => void;
onEnableSkillCommandsChange: (enabled: boolean) => void;
onSteeringModeChange: (mode: "all" | "one-at-a-time") => void;
onFollowUpModeChange: (mode: "all" | "one-at-a-time") => void;
onTransportChange: (transport: Transport) => void;
onHttpIdleTimeoutMsChange: (timeoutMs: number) => void;
onThinkingLevelChange: (level: ThinkingLevel) => void;
onThemeChange: (theme: string) => void;
onThemePreview?: (theme: string) => void;
onHideThinkingBlockChange: (hidden: boolean) => void;
onShowCacheMissNoticesChange: (shown: boolean) => void;
onCollapseChangelogChange: (collapsed: boolean) => void;
onEnableInstallTelemetryChange: (enabled: boolean) => void;
onDoubleEscapeActionChange: (action: "fork" | "tree" | "none") => void;
onTreeFilterModeChange: (mode: "default" | "no-tools" | "user-only" | "labeled-only" | "all") => void;
onShowHardwareCursorChange: (enabled: boolean) => void;
onEditorPaddingXChange: (padding: number) => void;
onOutputPadChange: (padding: 0 | 1) => void;
onAutocompleteMaxVisibleChange: (maxVisible: number) => void;
onQuietStartupChange: (enabled: boolean) => void;
onDefaultProjectTrustChange: (defaultProjectTrust: DefaultProjectTrust) => void;
onClearOnShrinkChange: (enabled: boolean) => void;
onShowTerminalProgressChange: (enabled: boolean) => void;
onWarningsChange: (warnings: WarningSettings) => void;
onCancel: () => void;
}
/**
* A submenu component for selecting from a list of options.
*/
class WarningSettingsSubmenu extends Container {
private settingsList: SettingsList;
private state: WarningSettings;
constructor(warnings: WarningSettings, onChange: (warnings: WarningSettings) => void, onCancel: () => void) {
super();
this.state = { ...warnings };
const items: SettingItem[] = [
{
id: "anthropic-extra-usage",
label: "Anthropic extra usage",
description: "Warn when Anthropic subscription auth may use paid extra usage",
currentValue: (this.state.anthropicExtraUsage ?? true) ? "true" : "false",
values: ["true", "false"],
},
];
this.settingsList = new SettingsList(
items,
Math.min(items.length, 10),
getSettingsListTheme(),
(id, newValue) => {
switch (id) {
case "anthropic-extra-usage":
this.state = { ...this.state, anthropicExtraUsage: newValue === "true" };
onChange({ ...this.state });
break;
}
},
onCancel,
);
this.addChild(this.settingsList);
}
handleInput(data: string): void {
this.settingsList.handleInput(data);
}
}
class SelectSubmenu extends Container {
private selectList: SelectList;
constructor(
title: string,
description: string,
options: SelectItem[],
currentValue: string,
onSelect: (value: string) => void,
onCancel: () => void,
onSelectionChange?: (value: string) => void,
) {
super();
// Title
this.addChild(new Text(theme.bold(theme.fg("accent", title)), 0, 0));
// Description
if (description) {
this.addChild(new Spacer(1));
this.addChild(new Text(theme.fg("muted", description), 0, 0));
}
// Spacer
this.addChild(new Spacer(1));
// Select list
this.selectList = new SelectList(
options,
Math.min(options.length, 10),
getSelectListTheme(),
SETTINGS_SUBMENU_SELECT_LIST_LAYOUT,
);
// Pre-select current value
const currentIndex = options.findIndex((o) => o.value === currentValue);
if (currentIndex !== -1) {
this.selectList.setSelectedIndex(currentIndex);
}
this.selectList.onSelect = (item) => {
onSelect(item.value);
};
this.selectList.onCancel = onCancel;
if (onSelectionChange) {
this.selectList.onSelectionChange = (item) => {
onSelectionChange(item.value);
};
}
this.addChild(this.selectList);
// Hint
this.addChild(new Spacer(1));
this.addChild(new Text(theme.fg("dim", " Enter to select · Esc to go back"), 0, 0));
}
handleInput(data: string): void {
this.selectList.handleInput(data);
}
}
function themeItems(availableThemes: string[]): SelectItem[] {
return availableThemes.map((name) => ({ value: name, label: name }));
}
const AUTOMATIC_THEME_VALUE = "/";
function singleModeThemeItems(availableThemes: string[]): SelectItem[] {
return [
{
value: AUTOMATIC_THEME_VALUE,
label: "Automatic",
description: "Use separate themes for light and dark terminal appearance",
},
...themeItems(availableThemes),
];
}
function preferredTheme(availableThemes: string[], preferred: string | undefined, fallback: string): string {
if (preferred && availableThemes.includes(preferred)) return preferred;
if (availableThemes.includes(fallback)) return fallback;
return availableThemes[0] ?? fallback;
}
function defaultAutomaticThemes(
currentThemeSetting: string,
availableThemes: string[],
): { lightTheme: string; darkTheme: string } {
const autoTheme = parseAutoThemeSetting(currentThemeSetting);
if (autoTheme) return autoTheme;
const currentFixedTheme = currentThemeSetting.includes("/") ? undefined : currentThemeSetting;
const themeName = preferredTheme(availableThemes, currentFixedTheme, "dark");
return { lightTheme: themeName, darkTheme: themeName };
}
class ThemeSubmenu extends Container {
private inputComponent: Component | undefined;
private readonly callbacks: SettingsCallbacks;
private readonly availableThemes: string[];
private readonly terminalTheme: TerminalTheme;
private readonly onDone: (selectedValue?: string) => void;
private readonly originalThemeSetting: string;
private mode: "single" | "automatic";
private singleTheme: string;
private lightTheme: string;
private darkTheme: string;
constructor(
currentThemeSetting: string,
terminalTheme: TerminalTheme,
availableThemes: string[],
callbacks: SettingsCallbacks,
onDone: (selectedValue?: string) => void,
) {
super();
this.callbacks = callbacks;
this.availableThemes = availableThemes;
this.terminalTheme = terminalTheme;
this.onDone = onDone;
this.originalThemeSetting = currentThemeSetting;
const autoTheme = parseAutoThemeSetting(currentThemeSetting);
const automaticThemes = defaultAutomaticThemes(currentThemeSetting, availableThemes);
const fixedTheme = autoTheme || currentThemeSetting.includes("/") ? undefined : currentThemeSetting;
this.mode = autoTheme ? "automatic" : "single";
this.lightTheme = automaticThemes.lightTheme;
this.darkTheme = automaticThemes.darkTheme;
this.singleTheme = preferredTheme(
availableThemes,
fixedTheme ?? (autoTheme ? this.getActiveAutomaticTheme() : undefined),
"dark",
);
if (this.mode === "automatic") {
this.showAutomaticMenu();
} else {
this.showSingleMenu();
}
}
handleInput(data: string): void {
this.inputComponent?.handleInput?.(data);
}
private setContent(renderComponent: Component, inputComponent: Component = renderComponent): void {
this.clear();
this.addChild(renderComponent);
this.inputComponent = inputComponent;
}
private showSingleMenu(): void {
this.mode = "single";
const menu = new SelectSubmenu(
"Theme",
"Select a theme, or choose Automatic to follow terminal appearance.",
singleModeThemeItems(this.availableThemes),
this.singleTheme,
(value) => {
if (value === AUTOMATIC_THEME_VALUE) {
this.mode = "automatic";
this.callbacks.onThemePreview?.(this.getThemeSetting());
this.showAutomaticMenu();
return;
}
this.singleTheme = value;
this.apply(value);
},
() => this.cancel(),
(value) => {
this.callbacks.onThemePreview?.(value === AUTOMATIC_THEME_VALUE ? this.getAutomaticThemeSetting() : value);
},
);
this.setContent(menu);
}
private showAutomaticMenu(): void {
this.mode = "automatic";
const content = new Container();
content.addChild(new Text(theme.bold(theme.fg("accent", "Automatic Theme")), 0, 0));
content.addChild(new Spacer(1));
content.addChild(new Text(theme.fg("muted", "Choose themes for terminal light and dark appearance."), 0, 0));
content.addChild(new Text(theme.fg("muted", "Light/dark detection requires terminal support."), 0, 0));
content.addChild(new Spacer(1));
const items: SettingItem[] = [
{
id: "light-theme",
label: "Light theme",
description: "Theme to use in automatic mode when the terminal is light",
currentValue: this.lightTheme,
submenu: (currentValue, done) =>
this.createThemeSelect(
"Light Theme",
"Select the theme to use for light terminal appearance",
currentValue,
done,
(value) => {
this.lightTheme = value;
this.callbacks.onThemePreview?.(this.getThemeSetting());
done(value);
},
),
},
{
id: "dark-theme",
label: "Dark theme",
description: "Theme to use in automatic mode when the terminal is dark",
currentValue: this.darkTheme,
submenu: (currentValue, done) =>
this.createThemeSelect(
"Dark Theme",
"Select the theme to use for dark terminal appearance",
currentValue,
done,
(value) => {
this.darkTheme = value;
this.callbacks.onThemePreview?.(this.getThemeSetting());
done(value);
},
),
},
{
id: "apply",
label: "Apply",
description: "Save and go back",
currentValue: "save and go back",
values: ["save and go back"],
},
{
id: "single-mode",
label: "Change mode",
description: "Switch to one theme for light and dark",
currentValue: "switch to single theme",
values: ["switch to single theme"],
},
];
const settingsList = new SettingsList(
items,
Math.min(items.length, 10),
getSettingsListTheme(),
(id) => {
switch (id) {
case "single-mode":
this.mode = "single";
this.singleTheme = this.getActiveAutomaticTheme();
this.callbacks.onThemePreview?.(this.singleTheme);
this.showSingleMenu();
break;
case "apply":
this.apply(this.getAutomaticThemeSetting());
break;
}
},
() => this.cancel(),
);
content.addChild(settingsList);
this.setContent(content, settingsList);
}
private createThemeSelect(
title: string,
description: string,
currentValue: string,
done: (selectedValue?: string) => void,
onSelect: (value: string) => void,
): SelectSubmenu {
return new SelectSubmenu(
title,
description,
themeItems(this.availableThemes),
currentValue,
onSelect,
() => {
this.callbacks.onThemePreview?.(this.getThemeSetting());
done();
},
(value) => this.callbacks.onThemePreview?.(value),
);
}
private getThemeSetting(): string {
return this.mode === "automatic" ? this.getAutomaticThemeSetting() : this.singleTheme;
}
private getActiveAutomaticTheme(): string {
return this.terminalTheme === "light" ? this.lightTheme : this.darkTheme;
}
private getAutomaticThemeSetting(): string {
return `${this.lightTheme}/${this.darkTheme}`;
}
private apply(themeSetting: string): void {
this.onDone(themeSetting);
}
private cancel(): void {
this.callbacks.onThemePreview?.(this.originalThemeSetting);
this.onDone();
}
}
/**
* Main settings selector component.
*/
export class SettingsSelectorComponent extends Container {
private settingsList: SettingsList;
constructor(config: SettingsConfig, callbacks: SettingsCallbacks) {
super();
const supportsImages = getCapabilities().images;
const followUpKey = keyDisplayText("app.message.followUp");
let currentWarnings = { ...config.warnings };
const items: SettingItem[] = [
{
id: "autocompact",
label: "Auto-compact",
description: "Automatically compact context when it gets too large",
currentValue: config.autoCompact ? "true" : "false",
values: ["true", "false"],
},
{
id: "steering-mode",
label: "Steering mode",
description:
"Enter while streaming queues steering messages. 'one-at-a-time': deliver one, wait for response. 'all': deliver all at once.",
currentValue: config.steeringMode,
values: ["one-at-a-time", "all"],
},
{
id: "follow-up-mode",
label: "Follow-up mode",
description: `${followUpKey} queues follow-up messages until agent stops. 'one-at-a-time': deliver one, wait for response. 'all': deliver all at once.`,
currentValue: config.followUpMode,
values: ["one-at-a-time", "all"],
},
{
id: "transport",
label: "Transport",
description: "Preferred transport for providers that support multiple transports",
currentValue: config.transport,
values: ["sse", "websocket", "websocket-cached", "auto"],
},
{
id: "http-idle-timeout",
label: "HTTP idle timeout",
description:
"Maximum idle gap while waiting for HTTP headers or body chunks. Disable for local models that pause longer than five minutes.",
currentValue: formatHttpIdleTimeoutMs(config.httpIdleTimeoutMs),
values: HTTP_IDLE_TIMEOUT_CHOICES.map((choice) => choice.label),
},
{
id: "hide-thinking",
label: "Hide thinking",
description: "Hide thinking blocks in assistant responses",
currentValue: config.hideThinkingBlock ? "true" : "false",
values: ["true", "false"],
},
{
id: "cache-miss-notices",
label: "Cache miss notices",
description: "Show transcript notices for significant prompt-cache misses",
currentValue: config.showCacheMissNotices ? "true" : "false",
values: ["true", "false"],
},
{
id: "collapse-changelog",
label: "Collapse changelog",
description: "Show condensed changelog after updates",
currentValue: config.collapseChangelog ? "true" : "false",
values: ["true", "false"],
},
{
id: "quiet-startup",
label: "Quiet startup",
description: "Disable verbose printing at startup",
currentValue: config.quietStartup ? "true" : "false",
values: ["true", "false"],
},
{
id: "install-telemetry",
label: "Install telemetry",
description: "Send an anonymous version/update ping after changelog-detected updates",
currentValue: config.enableInstallTelemetry ? "true" : "false",
values: ["true", "false"],
},
{
id: "default-project-trust",
label: "Default project trust",
description: "Fallback behavior when no extension or saved trust decision decides project trust",
currentValue: DEFAULT_PROJECT_TRUST_LABELS[config.defaultProjectTrust],
values: Object.values(DEFAULT_PROJECT_TRUST_LABELS),
},
{
id: "double-escape-action",
label: "Double-escape action",
description: "Action when pressing Escape twice with empty editor",
currentValue: config.doubleEscapeAction,
values: ["tree", "fork", "none"],
},
{
id: "tree-filter-mode",
label: "Tree filter mode",
description: "Default filter when opening /tree",
currentValue: config.treeFilterMode,
values: ["default", "no-tools", "user-only", "labeled-only", "all"],
},
{
id: "warnings",
label: "Warnings",
description: "Enable or disable individual warnings",
currentValue: "configure",
submenu: (_currentValue, done) =>
new WarningSettingsSubmenu(
currentWarnings,
(warnings) => {
currentWarnings = warnings;
callbacks.onWarningsChange(warnings);
},
() => done(),
),
},
{
id: "thinking",
label: "Thinking level",
description: "Reasoning depth for thinking-capable models",
currentValue: config.thinkingLevel,
submenu: (currentValue, done) =>
new SelectSubmenu(
"Thinking Level",
"Select reasoning depth for thinking-capable models",
config.availableThinkingLevels.map((level) => ({
value: level,
label: level,
description: THINKING_DESCRIPTIONS[level],
})),
currentValue,
(value) => {
callbacks.onThinkingLevelChange(value as ThinkingLevel);
done(value);
},
() => done(),
),
},
{
id: "theme",
label: "Theme",
description: "Color theme for the interface",
currentValue: config.currentTheme,
submenu: (currentValue, done) =>
new ThemeSubmenu(currentValue, config.terminalTheme, config.availableThemes, callbacks, done),
},
];
// Only show image toggle if terminal supports it
if (supportsImages) {
// Insert after autocompact
items.splice(1, 0, {
id: "show-images",
label: "Show images",
description: "Render images inline in terminal",
currentValue: config.showImages ? "true" : "false",
values: ["true", "false"],
});
items.splice(2, 0, {
id: "image-width-cells",
label: "Image width",
description: "Preferred inline image width in terminal cells",
currentValue: String(config.imageWidthCells),
values: ["60", "80", "120"],
});
}
// Image auto-resize toggle (always available, affects both attached and read images)
items.splice(supportsImages ? 3 : 1, 0, {
id: "auto-resize-images",
label: "Auto-resize images",
description: "Resize large images to 2000x2000 max for better model compatibility",
currentValue: config.autoResizeImages ? "true" : "false",
values: ["true", "false"],
});
// Block images toggle (always available, insert after auto-resize-images)
const autoResizeIndex = items.findIndex((item) => item.id === "auto-resize-images");
items.splice(autoResizeIndex + 1, 0, {
id: "block-images",
label: "Block images",
description: "Prevent images from being sent to LLM providers",
currentValue: config.blockImages ? "true" : "false",
values: ["true", "false"],
});
// Skill commands toggle (insert after block-images)
const blockImagesIndex = items.findIndex((item) => item.id === "block-images");
items.splice(blockImagesIndex + 1, 0, {
id: "skill-commands",
label: "Skill commands",
description: "Register skills as /skill:name commands",
currentValue: config.enableSkillCommands ? "true" : "false",
values: ["true", "false"],
});
// Hardware cursor toggle (insert after skill-commands)
const skillCommandsIndex = items.findIndex((item) => item.id === "skill-commands");
items.splice(skillCommandsIndex + 1, 0, {
id: "show-hardware-cursor",
label: "Show hardware cursor",
description: "Show the terminal cursor while still positioning it for IME support",
currentValue: config.showHardwareCursor ? "true" : "false",
values: ["true", "false"],
});
// Editor padding toggle (insert after show-hardware-cursor)
const hardwareCursorIndex = items.findIndex((item) => item.id === "show-hardware-cursor");
items.splice(hardwareCursorIndex + 1, 0, {
id: "editor-padding",
label: "Editor padding",
description: "Horizontal padding for input editor (0-3)",
currentValue: String(config.editorPaddingX),
values: ["0", "1", "2", "3"],
});
// Output padding toggle (insert after editor-padding)
const editorPaddingIndex = items.findIndex((item) => item.id === "editor-padding");
items.splice(editorPaddingIndex + 1, 0, {
id: "output-padding",
label: "Output padding",
description: "Horizontal padding for user messages, assistant messages, and thinking",
currentValue: String(config.outputPad),
values: ["0", "1"],
});
// Autocomplete max visible toggle (insert after output-padding)
const outputPaddingIndex = items.findIndex((item) => item.id === "output-padding");
items.splice(outputPaddingIndex + 1, 0, {
id: "autocomplete-max-visible",
label: "Autocomplete max items",
description: "Max visible items in autocomplete dropdown (3-20)",
currentValue: String(config.autocompleteMaxVisible),
values: ["3", "5", "7", "10", "15", "20"],
});
// Clear on shrink toggle (insert after autocomplete-max-visible)
const autocompleteIndex = items.findIndex((item) => item.id === "autocomplete-max-visible");
items.splice(autocompleteIndex + 1, 0, {
id: "clear-on-shrink",
label: "Clear on shrink",
description: "Clear empty rows when content shrinks (may cause flicker)",
currentValue: config.clearOnShrink ? "true" : "false",
values: ["true", "false"],
});
// Terminal progress toggle (insert after clear-on-shrink)
const clearOnShrinkIndex = items.findIndex((item) => item.id === "clear-on-shrink");
items.splice(clearOnShrinkIndex + 1, 0, {
id: "terminal-progress",
label: "Terminal progress",
description: "Show OSC 9;4 progress indicators in the terminal tab bar",
currentValue: config.showTerminalProgress ? "true" : "false",
values: ["true", "false"],
});
// Add borders
this.addChild(new DynamicBorder());
this.settingsList = new SettingsList(
items,
10,
getSettingsListTheme(),
(id, newValue) => {
switch (id) {
case "autocompact":
callbacks.onAutoCompactChange(newValue === "true");
break;
case "show-images":
callbacks.onShowImagesChange(newValue === "true");
break;
case "image-width-cells":
callbacks.onImageWidthCellsChange(parseInt(newValue, 10));
break;
case "auto-resize-images":
callbacks.onAutoResizeImagesChange(newValue === "true");
break;
case "block-images":
callbacks.onBlockImagesChange(newValue === "true");
break;
case "skill-commands":
callbacks.onEnableSkillCommandsChange(newValue === "true");
break;
case "steering-mode":
callbacks.onSteeringModeChange(newValue as "all" | "one-at-a-time");
break;
case "follow-up-mode":
callbacks.onFollowUpModeChange(newValue as "all" | "one-at-a-time");
break;
case "transport":
callbacks.onTransportChange(newValue as Transport);
break;
case "http-idle-timeout": {
const choice = HTTP_IDLE_TIMEOUT_CHOICES.find((item) => item.label === newValue);
if (choice) {
callbacks.onHttpIdleTimeoutMsChange(choice.timeoutMs);
}
break;
}
case "hide-thinking":
callbacks.onHideThinkingBlockChange(newValue === "true");
break;
case "cache-miss-notices":
callbacks.onShowCacheMissNoticesChange(newValue === "true");
break;
case "collapse-changelog":
callbacks.onCollapseChangelogChange(newValue === "true");
break;
case "quiet-startup":
callbacks.onQuietStartupChange(newValue === "true");
break;
case "install-telemetry":
callbacks.onEnableInstallTelemetryChange(newValue === "true");
break;
case "default-project-trust": {
const defaultProjectTrust = DEFAULT_PROJECT_TRUST_BY_LABEL.get(newValue);
if (defaultProjectTrust) {
callbacks.onDefaultProjectTrustChange(defaultProjectTrust);
}
break;
}
case "double-escape-action":
callbacks.onDoubleEscapeActionChange(newValue as "fork" | "tree");
break;
case "tree-filter-mode":
callbacks.onTreeFilterModeChange(
newValue as "default" | "no-tools" | "user-only" | "labeled-only" | "all",
);
break;
case "show-hardware-cursor":
callbacks.onShowHardwareCursorChange(newValue === "true");
break;
case "editor-padding":
callbacks.onEditorPaddingXChange(parseInt(newValue, 10));
break;
case "output-padding":
callbacks.onOutputPadChange(newValue === "0" ? 0 : 1);
break;
case "autocomplete-max-visible":
callbacks.onAutocompleteMaxVisibleChange(parseInt(newValue, 10));
break;
case "clear-on-shrink":
callbacks.onClearOnShrinkChange(newValue === "true");
break;
case "terminal-progress":
callbacks.onShowTerminalProgressChange(newValue === "true");
break;
case "theme":
callbacks.onThemeChange(newValue);
break;
}
},
callbacks.onCancel,
{ enableSearch: true },
);
this.addChild(this.settingsList);
this.addChild(new DynamicBorder());
}
getSettingsList(): SettingsList {
return this.settingsList;
}
}
@@ -0,0 +1,50 @@
import { Container, type SelectItem, SelectList, type SelectListLayoutOptions } from "@earendil-works/pi-tui";
import { getSelectListTheme } from "../theme/theme.ts";
import { DynamicBorder } from "./dynamic-border.ts";
const SHOW_IMAGES_SELECT_LIST_LAYOUT: SelectListLayoutOptions = {
minPrimaryColumnWidth: 12,
maxPrimaryColumnWidth: 32,
};
/**
* Component that renders a show images selector with borders
*/
export class ShowImagesSelectorComponent extends Container {
private selectList: SelectList;
constructor(currentValue: boolean, onSelect: (show: boolean) => void, onCancel: () => void) {
super();
const items: SelectItem[] = [
{ value: "yes", label: "Yes", description: "Show images inline in terminal" },
{ value: "no", label: "No", description: "Show text placeholder instead" },
];
// Add top border
this.addChild(new DynamicBorder());
// Create selector
this.selectList = new SelectList(items, 5, getSelectListTheme(), SHOW_IMAGES_SELECT_LIST_LAYOUT);
// Preselect current value
this.selectList.setSelectedIndex(currentValue ? 0 : 1);
this.selectList.onSelect = (item) => {
onSelect(item.value === "yes");
};
this.selectList.onCancel = () => {
onCancel();
};
this.addChild(this.selectList);
// Add bottom border
this.addChild(new DynamicBorder());
}
getSelectList(): SelectList {
return this.selectList;
}
}
@@ -0,0 +1,55 @@
import { Box, Markdown, type MarkdownTheme, Text } from "@earendil-works/pi-tui";
import type { ParsedSkillBlock } from "../../../core/agent-session.ts";
import { getMarkdownTheme, theme } from "../theme/theme.ts";
import { keyText } from "./keybinding-hints.ts";
/**
* Component that renders a skill invocation message with collapsed/expanded state.
* Uses same background color as custom messages for visual consistency.
* Only renders the skill block itself - user message is rendered separately.
*/
export class SkillInvocationMessageComponent extends Box {
private expanded = false;
private skillBlock: ParsedSkillBlock;
private markdownTheme: MarkdownTheme;
constructor(skillBlock: ParsedSkillBlock, markdownTheme: MarkdownTheme = getMarkdownTheme()) {
super(1, 1, (t) => theme.bg("customMessageBg", t));
this.skillBlock = skillBlock;
this.markdownTheme = markdownTheme;
this.updateDisplay();
}
setExpanded(expanded: boolean): void {
this.expanded = expanded;
this.updateDisplay();
}
override invalidate(): void {
super.invalidate();
this.updateDisplay();
}
private updateDisplay(): void {
this.clear();
if (this.expanded) {
// Expanded: label + skill name header + full content
const label = theme.fg("customMessageLabel", `\x1b[1m[skill]\x1b[22m`);
this.addChild(new Text(label, 0, 0));
const header = `**${this.skillBlock.name}**\n\n`;
this.addChild(
new Markdown(header + this.skillBlock.content, 0, 0, this.markdownTheme, {
color: (text: string) => theme.fg("customMessageText", text),
}),
);
} else {
// Collapsed: single line - [skill] name (hint to expand)
const line =
theme.fg("customMessageLabel", `\x1b[1m[skill]\x1b[22m `) +
theme.fg("customMessageText", this.skillBlock.name) +
theme.fg("dim", ` (${keyText("app.tools.expand")} to expand)`);
this.addChild(new Text(line, 0, 0));
}
}
}
@@ -0,0 +1,114 @@
import { type Component, Loader, type TUI } from "@earendil-works/pi-tui";
import type { WorkingIndicatorOptions } from "../../../core/extensions/index.ts";
import { theme } from "../theme/theme.ts";
import { CountdownTimer } from "./countdown-timer.ts";
import { keyText } from "./keybinding-hints.ts";
export type StatusIndicatorKind = "working" | "retry" | "compaction" | "branchSummary";
export class StatusIndicator extends Loader {
readonly kind: StatusIndicatorKind;
constructor(
kind: StatusIndicatorKind,
ui: TUI,
spinnerColorFn: (str: string) => string,
messageColorFn: (str: string) => string,
message: string,
indicator?: WorkingIndicatorOptions,
) {
super(ui, spinnerColorFn, messageColorFn, message, indicator);
this.kind = kind;
}
dispose(): void {
this.stop();
}
}
export class WorkingStatusIndicator extends StatusIndicator {
constructor(ui: TUI, message: string, indicator?: WorkingIndicatorOptions) {
super(
"working",
ui,
(spinner) => theme.fg("accent", spinner),
(text) => theme.fg("muted", text),
message,
indicator,
);
}
}
export class RetryStatusIndicator extends StatusIndicator {
private countdown: CountdownTimer | undefined;
constructor(ui: TUI, attempt: number, maxAttempts: number, delayMs: number) {
const retryMessage = (seconds: number) =>
`Retrying (${attempt}/${maxAttempts}) in ${seconds}s... (${keyText("app.interrupt")} to cancel)`;
super(
"retry",
ui,
(spinner) => theme.fg("warning", spinner),
(text) => theme.fg("muted", text),
retryMessage(Math.ceil(delayMs / 1000)),
);
this.countdown = new CountdownTimer(
delayMs,
ui,
(seconds) => {
this.setMessage(retryMessage(seconds));
},
() => {
this.countdown = undefined;
},
);
}
override dispose(): void {
this.countdown?.dispose();
this.countdown = undefined;
super.dispose();
}
}
export type CompactionStatusReason = "manual" | "threshold" | "overflow";
export class CompactionStatusIndicator extends StatusIndicator {
constructor(ui: TUI, reason: CompactionStatusReason) {
const cancelHint = `(${keyText("app.interrupt")} to cancel)`;
const label =
reason === "manual"
? `Compacting context... ${cancelHint}`
: `${reason === "overflow" ? "Context overflow detected, " : ""}Auto-compacting... ${cancelHint}`;
super(
"compaction",
ui,
(spinner) => theme.fg("accent", spinner),
(text) => theme.fg("muted", text),
label,
);
}
}
export class BranchSummaryStatusIndicator extends StatusIndicator {
constructor(ui: TUI) {
super(
"branchSummary",
ui,
(spinner) => theme.fg("accent", spinner),
(text) => theme.fg("muted", text),
`Summarizing branch... (${keyText("app.interrupt")} to cancel)`,
);
}
}
export class IdleStatus implements Component {
invalidate(): void {
// No cached state to invalidate.
}
render(width: number): string[] {
const emptyLine = " ".repeat(width);
return [emptyLine, emptyLine];
}
}
@@ -0,0 +1,67 @@
import { Container, type SelectItem, SelectList, type SelectListLayoutOptions } from "@earendil-works/pi-tui";
import { getAvailableThemes, getSelectListTheme } from "../theme/theme.ts";
import { DynamicBorder } from "./dynamic-border.ts";
const THEME_SELECT_LIST_LAYOUT: SelectListLayoutOptions = {
minPrimaryColumnWidth: 12,
maxPrimaryColumnWidth: 32,
};
/**
* 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(), THEME_SELECT_LIST_LAYOUT);
// 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,75 @@
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import { Container, type SelectItem, SelectList, type SelectListLayoutOptions } from "@earendil-works/pi-tui";
import { getSelectListTheme } from "../theme/theme.ts";
import { DynamicBorder } from "./dynamic-border.ts";
const THINKING_SELECT_LIST_LAYOUT: SelectListLayoutOptions = {
minPrimaryColumnWidth: 12,
maxPrimaryColumnWidth: 32,
};
const LEVEL_DESCRIPTIONS: Record<ThinkingLevel, string> = {
off: "No reasoning",
minimal: "Very brief reasoning (~1k tokens)",
low: "Light reasoning (~2k tokens)",
medium: "Moderate reasoning (~8k tokens)",
high: "Deep reasoning (~16k tokens)",
xhigh: "Extra-high reasoning (~32k tokens)",
max: "Maximum reasoning",
};
/**
* Component that renders a thinking level selector with borders
*/
export class ThinkingSelectorComponent extends Container {
private selectList: SelectList;
constructor(
currentLevel: ThinkingLevel,
availableLevels: ThinkingLevel[],
onSelect: (level: ThinkingLevel) => void,
onCancel: () => void,
) {
super();
const thinkingLevels: SelectItem[] = availableLevels.map((level) => ({
value: level,
label: level,
description: LEVEL_DESCRIPTIONS[level],
}));
// Add top border
this.addChild(new DynamicBorder());
// Create selector
this.selectList = new SelectList(
thinkingLevels,
thinkingLevels.length,
getSelectListTheme(),
THINKING_SELECT_LIST_LAYOUT,
);
// 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,377 @@
import { Box, type Component, Container, getCapabilities, Image, Spacer, Text, type TUI } from "@earendil-works/pi-tui";
import type { ToolDefinition, ToolRenderContext } from "../../../core/extensions/types.ts";
import { createAllToolDefinitions, type ToolName } from "../../../core/tools/index.ts";
import { getTextOutput as getRenderedTextOutput } from "../../../core/tools/render-utils.ts";
import { convertToPng } from "../../../utils/image-convert.ts";
import { theme } from "../theme/theme.ts";
export interface ToolExecutionOptions {
showImages?: boolean;
imageWidthCells?: number;
}
export class ToolExecutionComponent extends Container {
private contentBox: Box;
private contentText: Text;
private selfRenderContainer: Container;
private callRendererComponent?: Component;
private resultRendererComponent?: Component;
private rendererState: any = {};
private imageComponents: Image[] = [];
private imageSpacers: Spacer[] = [];
private toolName: string;
private toolCallId: string;
private args: any;
private expanded = false;
private showImages: boolean;
private imageWidthCells: number;
private isPartial = true;
private toolDefinition?: ToolDefinition<any, any>;
private builtInToolDefinition?: ToolDefinition<any, any>;
private ui: TUI;
private cwd: string;
private executionStarted = false;
private argsComplete = false;
private result?: {
content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
isError: boolean;
details?: any;
};
private convertedImages: Map<number, { data: string; mimeType: string }> = new Map();
private hideComponent = false;
constructor(
toolName: string,
toolCallId: string,
args: any,
options: ToolExecutionOptions = {},
toolDefinition: ToolDefinition<any, any> | undefined,
ui: TUI,
cwd: string,
) {
super();
this.toolName = toolName;
this.toolCallId = toolCallId;
this.args = args;
this.toolDefinition = toolDefinition;
this.builtInToolDefinition = createAllToolDefinitions(cwd)[toolName as ToolName];
this.showImages = options.showImages ?? true;
this.imageWidthCells = options.imageWidthCells ?? 60;
this.ui = ui;
this.cwd = cwd;
this.addChild(new Spacer(1));
// Always create all shell variants. contentBox is used for default renderer-based composition.
// selfRenderContainer is used when the tool renders its own framing.
// contentText is reserved for generic fallback rendering when no tool definition exists.
this.contentBox = new Box(1, 1, (text: string) => theme.bg("toolPendingBg", text));
this.contentText = new Text("", 1, 1, (text: string) => theme.bg("toolPendingBg", text));
this.selfRenderContainer = new Container();
if (this.hasRendererDefinition()) {
this.addChild(this.getRenderShell() === "self" ? this.selfRenderContainer : this.contentBox);
} else {
this.addChild(this.contentText);
}
this.updateDisplay();
}
private getCallRenderer(): ToolDefinition<any, any>["renderCall"] | undefined {
if (!this.builtInToolDefinition) {
return this.toolDefinition?.renderCall;
}
if (!this.toolDefinition) {
return this.builtInToolDefinition.renderCall;
}
return this.toolDefinition.renderCall ?? this.builtInToolDefinition.renderCall;
}
private getResultRenderer(): ToolDefinition<any, any>["renderResult"] | undefined {
if (!this.builtInToolDefinition) {
return this.toolDefinition?.renderResult;
}
if (!this.toolDefinition) {
return this.builtInToolDefinition.renderResult;
}
return this.toolDefinition.renderResult ?? this.builtInToolDefinition.renderResult;
}
private hasRendererDefinition(): boolean {
return this.builtInToolDefinition !== undefined || this.toolDefinition !== undefined;
}
private getRenderShell(): "default" | "self" {
if (!this.builtInToolDefinition) {
return this.toolDefinition?.renderShell ?? "default";
}
if (!this.toolDefinition) {
return this.builtInToolDefinition.renderShell ?? "default";
}
return this.toolDefinition.renderShell ?? this.builtInToolDefinition.renderShell ?? "default";
}
private getRenderContext(lastComponent: Component | undefined): ToolRenderContext {
return {
args: this.args,
toolCallId: this.toolCallId,
invalidate: () => {
this.invalidate();
this.ui.requestRender();
},
lastComponent,
state: this.rendererState,
cwd: this.cwd,
executionStarted: this.executionStarted,
argsComplete: this.argsComplete,
isPartial: this.isPartial,
expanded: this.expanded,
showImages: this.showImages,
isError: this.result?.isError ?? false,
};
}
private createCallFallback(): Component {
return new Text(theme.fg("toolTitle", theme.bold(this.toolName)), 0, 0);
}
private createResultFallback(): Component | undefined {
const output = this.getTextOutput();
if (!output) {
return undefined;
}
return new Text(theme.fg("toolOutput", output), 0, 0);
}
updateArgs(args: any): void {
this.args = args;
this.updateDisplay();
}
markExecutionStarted(): void {
this.executionStarted = true;
this.updateDisplay();
this.ui.requestRender();
}
setArgsComplete(): void {
this.argsComplete = true;
this.updateDisplay();
this.ui.requestRender();
}
updateResult(
result: {
content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
details?: any;
isError: boolean;
},
isPartial = false,
): void {
this.result = result;
this.isPartial = isPartial;
this.updateDisplay();
this.maybeConvertImagesForKitty();
}
private maybeConvertImagesForKitty(): void {
const caps = getCapabilities();
if (caps.images !== "kitty") return;
if (!this.result) return;
const imageBlocks = this.result.content.filter((c) => c.type === "image");
for (let i = 0; i < imageBlocks.length; i++) {
const img = imageBlocks[i];
if (!img.data || !img.mimeType) continue;
if (img.mimeType === "image/png") continue;
if (this.convertedImages.has(i)) continue;
const index = i;
convertToPng(img.data, img.mimeType).then((converted) => {
if (converted) {
this.convertedImages.set(index, converted);
this.updateDisplay();
this.ui.requestRender();
}
});
}
}
setExpanded(expanded: boolean): void {
this.expanded = expanded;
this.updateDisplay();
}
setShowImages(show: boolean): void {
this.showImages = show;
this.updateDisplay();
}
setImageWidthCells(width: number): void {
this.imageWidthCells = Math.max(1, Math.floor(width));
this.updateDisplay();
}
override invalidate(): void {
super.invalidate();
this.updateDisplay();
}
override render(width: number): string[] {
if (this.hideComponent) {
return [];
}
if (this.hasRendererDefinition() && this.getRenderShell() === "self") {
const contentLines = this.selfRenderContainer.render(width);
if (contentLines.length === 0 && this.imageComponents.length === 0) {
return [];
}
const lines: string[] = [];
if (contentLines.length > 0) {
lines.push("");
lines.push(...contentLines);
}
for (let i = 0; i < this.imageComponents.length; i++) {
const spacer = this.imageSpacers[i];
if (spacer) {
lines.push(...spacer.render(width));
}
const imageComponent = this.imageComponents[i];
if (imageComponent) {
lines.push(...imageComponent.render(width));
}
}
return lines;
}
return super.render(width);
}
private updateDisplay(): void {
const bgFn = this.isPartial
? (text: string) => theme.bg("toolPendingBg", text)
: this.result?.isError
? (text: string) => theme.bg("toolErrorBg", text)
: (text: string) => theme.bg("toolSuccessBg", text);
let hasContent = false;
this.hideComponent = false;
if (this.hasRendererDefinition()) {
const renderContainer = this.getRenderShell() === "self" ? this.selfRenderContainer : this.contentBox;
if (renderContainer instanceof Box) {
renderContainer.setBgFn(bgFn);
}
renderContainer.clear();
const callRenderer = this.getCallRenderer();
if (!callRenderer) {
renderContainer.addChild(this.createCallFallback());
hasContent = true;
} else {
try {
const component = callRenderer(this.args, theme, this.getRenderContext(this.callRendererComponent));
this.callRendererComponent = component;
renderContainer.addChild(component);
hasContent = true;
} catch {
this.callRendererComponent = undefined;
renderContainer.addChild(this.createCallFallback());
hasContent = true;
}
}
if (this.result) {
const resultRenderer = this.getResultRenderer();
if (!resultRenderer) {
const component = this.createResultFallback();
if (component) {
renderContainer.addChild(component);
hasContent = true;
}
} else {
try {
const component = resultRenderer(
{ content: this.result.content as any, details: this.result.details },
{ expanded: this.expanded, isPartial: this.isPartial },
theme,
this.getRenderContext(this.resultRendererComponent),
);
this.resultRendererComponent = component;
renderContainer.addChild(component);
hasContent = true;
} catch {
this.resultRendererComponent = undefined;
const component = this.createResultFallback();
if (component) {
renderContainer.addChild(component);
hasContent = true;
}
}
}
}
} else {
this.contentText.setCustomBgFn(bgFn);
this.contentText.setText(this.formatToolExecution());
hasContent = true;
}
for (const img of this.imageComponents) {
this.removeChild(img);
}
this.imageComponents = [];
for (const spacer of this.imageSpacers) {
this.removeChild(spacer);
}
this.imageSpacers = [];
if (this.result) {
const imageBlocks = this.result.content.filter((c) => c.type === "image");
const caps = getCapabilities();
for (let i = 0; i < imageBlocks.length; i++) {
const img = imageBlocks[i];
if (caps.images && this.showImages && img.data && img.mimeType) {
const converted = this.convertedImages.get(i);
const imageData = converted?.data ?? img.data;
const imageMimeType = converted?.mimeType ?? img.mimeType;
if (caps.images === "kitty" && imageMimeType !== "image/png") continue;
const spacer = new Spacer(1);
this.addChild(spacer);
this.imageSpacers.push(spacer);
const imageComponent = new Image(
imageData,
imageMimeType,
{ fallbackColor: (s: string) => theme.fg("toolOutput", s) },
{ maxWidthCells: this.imageWidthCells },
);
this.imageComponents.push(imageComponent);
this.addChild(imageComponent);
}
}
}
if (this.hasRendererDefinition() && !hasContent && this.imageComponents.length === 0) {
this.hideComponent = true;
}
}
private getTextOutput(): string {
return getRenderedTextOutput(this.result, this.showImages);
}
private formatToolExecution(): string {
let text = theme.fg("toolTitle", theme.bold(this.toolName));
const content = JSON.stringify(this.args, null, 2);
if (content) {
text += `\n\n${content}`;
}
const output = this.getTextOutput();
if (output) {
text += `\n${output}`;
}
return text;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,134 @@
import { Container, getKeybindings, Spacer, Text } from "@earendil-works/pi-tui";
import {
getProjectTrustOptions,
type ProjectTrustOption,
type ProjectTrustStoreEntry,
} from "../../../core/trust-manager.ts";
import { theme } from "../theme/theme.ts";
import { DynamicBorder } from "./dynamic-border.ts";
import { keyHint, rawKeyHint } from "./keybinding-hints.ts";
export type TrustSelection = Pick<ProjectTrustOption, "trusted" | "updates">;
export interface TrustSelectorOptions {
cwd: string;
savedDecision: ProjectTrustStoreEntry | null;
projectTrusted: boolean;
onSelect: (selection: TrustSelection) => void;
onCancel: () => void;
}
function formatDecision(trustPath: string | undefined, decision: ProjectTrustStoreEntry | null): string {
if (decision === null) {
return "none";
}
const label = decision.decision ? "trusted" : "untrusted";
if (trustPath !== undefined && decision.path !== trustPath) {
return `${label} (inherited from ${decision.path})`;
}
return `${label} (${decision.path})`;
}
export class TrustSelectorComponent extends Container {
private selectedIndex: number;
private readonly listContainer: Container;
private readonly trustOptions: ProjectTrustOption[];
private readonly savedDecision: ProjectTrustStoreEntry | null;
private readonly onSelectCallback: (selection: TrustSelection) => void;
private readonly onCancelCallback: () => void;
constructor(options: TrustSelectorOptions) {
super();
this.savedDecision = options.savedDecision;
this.trustOptions = getProjectTrustOptions(options.cwd);
this.selectedIndex = Math.max(
0,
this.trustOptions.findIndex((option) => this.isSavedOption(option)),
);
this.onSelectCallback = options.onSelect;
this.onCancelCallback = options.onCancel;
this.addChild(new DynamicBorder());
this.addChild(new Spacer(1));
this.addChild(new Text(theme.fg("accent", theme.bold("Project trust")), 1, 0));
this.addChild(new Text(theme.fg("muted", options.cwd), 1, 0));
this.addChild(new Spacer(1));
this.addChild(
new Text(
theme.fg(
"muted",
`Saved decision: ${formatDecision(this.trustOptions[0]?.savedPath, options.savedDecision)}`,
),
1,
0,
),
);
this.addChild(
new Text(theme.fg("muted", `Current session: ${options.projectTrusted ? "trusted" : "untrusted"}`), 1, 0),
);
this.addChild(new Spacer(1));
this.listContainer = new Container();
this.addChild(this.listContainer);
this.addChild(new Spacer(1));
this.addChild(
new Text(
rawKeyHint("↑↓", "navigate") +
" " +
keyHint("tui.select.confirm", "save") +
" " +
keyHint("tui.select.cancel", "cancel"),
1,
0,
),
);
this.addChild(new Spacer(1));
this.addChild(new DynamicBorder());
this.updateList();
}
private isSavedOption(option: ProjectTrustOption): boolean {
return (
option.savedPath !== undefined &&
this.savedDecision?.decision === option.trusted &&
this.savedDecision.path === option.savedPath
);
}
private updateList(): void {
this.listContainer.clear();
for (let i = 0; i < this.trustOptions.length; i++) {
const option = this.trustOptions[i];
if (!option) {
continue;
}
const isSelected = i === this.selectedIndex;
const isCurrent = this.isSavedOption(option);
const checkmark = isCurrent ? theme.fg("success", " ✓") : "";
const prefix = isSelected ? theme.fg("accent", "→ ") : " ";
const label = isSelected ? theme.fg("accent", option.label) : theme.fg("text", option.label);
this.listContainer.addChild(new Text(`${prefix}${label}${checkmark}`, 1, 0));
}
}
handleInput(keyData: string): void {
const kb = getKeybindings();
if (kb.matches(keyData, "tui.select.up") || keyData === "k") {
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
this.updateList();
} else if (kb.matches(keyData, "tui.select.down") || keyData === "j") {
this.selectedIndex = Math.min(this.trustOptions.length - 1, this.selectedIndex + 1);
this.updateList();
} else if (kb.matches(keyData, "tui.select.confirm") || keyData === "\n") {
const selected = this.trustOptions[this.selectedIndex];
if (selected) {
this.onSelectCallback({ trusted: selected.trusted, updates: selected.updates });
}
} else if (kb.matches(keyData, "tui.select.cancel")) {
this.onCancelCallback();
}
}
}
@@ -0,0 +1,155 @@
import { type Component, Container, getKeybindings, Spacer, Text, truncateToWidth } from "@earendil-works/pi-tui";
import { theme } from "../theme/theme.ts";
import { DynamicBorder } from "./dynamic-border.ts";
interface UserMessageItem {
id: string; // Entry ID in the session
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?: (entryId: string) => void;
public onCancel?: () => void;
private maxVisible: number = 10; // Max messages visible
constructor(messages: UserMessageItem[], initialSelectedId?: string) {
// Store messages in chronological order (oldest to newest)
this.messages = messages;
const initialIndex = initialSelectedId ? messages.findIndex((message) => message.id === initialSelectedId) : -1;
// Start with selected message if provided, else default to the most recent
this.selectedIndex = initialIndex >= 0 ? initialIndex : 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 {
const kb = getKeybindings();
// Up arrow - go to previous (older) message, wrap to bottom when at top
if (kb.matches(keyData, "tui.select.up")) {
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 (kb.matches(keyData, "tui.select.down")) {
this.selectedIndex = this.selectedIndex === this.messages.length - 1 ? 0 : this.selectedIndex + 1;
}
// Enter - select message and branch
else if (kb.matches(keyData, "tui.select.confirm")) {
const selected = this.messages[this.selectedIndex];
if (selected && this.onSelect) {
this.onSelect(selected.id);
}
}
// Escape - cancel
else if (kb.matches(keyData, "tui.select.cancel")) {
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: (entryId: string) => void,
onCancel: () => void,
initialSelectedId?: string,
) {
super();
// Add header
this.addChild(new Spacer(1));
this.addChild(new Text(theme.bold("Fork from Message"), 1, 0));
this.addChild(
new Text(
theme.fg("muted", "Select a user message to copy the active path up to that point into a new session"),
1,
0,
),
);
this.addChild(new Spacer(1));
this.addChild(new DynamicBorder());
this.addChild(new Spacer(1));
// Create message list
this.messageList = new UserMessageList(messages, initialSelectedId);
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
if (messages.length === 0) {
setTimeout(() => onCancel(), 100);
}
}
getMessageList(): UserMessageList {
return this.messageList;
}
}
@@ -0,0 +1,57 @@
import { Box, Container, Markdown, type MarkdownTheme } from "@earendil-works/pi-tui";
import { getMarkdownTheme, theme } from "../theme/theme.ts";
const OSC133_ZONE_START = "\x1b]133;A\x07";
const OSC133_ZONE_END = "\x1b]133;B\x07";
const OSC133_ZONE_FINAL = "\x1b]133;C\x07";
/**
* Component that renders a user message
*/
export class UserMessageComponent extends Container {
private text: string;
private markdownTheme: MarkdownTheme;
private outputPad: number;
constructor(text: string, markdownTheme: MarkdownTheme = getMarkdownTheme(), outputPad = 1) {
super();
this.text = text;
this.markdownTheme = markdownTheme;
this.outputPad = outputPad;
this.rebuild();
}
setOutputPad(padding: number): void {
this.outputPad = padding;
this.rebuild();
}
private rebuild(): void {
this.clear();
const contentBox = new Box(this.outputPad, 1, (content: string) => theme.bg("userMessageBg", content));
contentBox.addChild(
new Markdown(
this.text,
0,
0,
this.markdownTheme,
{
color: (content: string) => theme.fg("userMessageText", content),
},
{ preserveOrderedListMarkers: true, preserveBackslashEscapes: true },
),
);
this.addChild(contentBox);
}
override render(width: number): string[] {
const lines = super.render(width);
if (lines.length === 0) {
return lines;
}
lines[0] = OSC133_ZONE_START + lines[0];
lines[lines.length - 1] = OSC133_ZONE_END + OSC133_ZONE_FINAL + lines[lines.length - 1];
return lines;
}
}
@@ -0,0 +1,50 @@
/**
* Shared utility for truncating text to visual lines (accounting for line wrapping).
* Used by both tool-execution.ts and bash-execution.ts for consistent behavior.
*/
import { Text } from "@earendil-works/pi-tui";
export interface VisualTruncateResult {
/** The visual lines to display */
visualLines: string[];
/** Number of visual lines that were skipped (hidden) */
skippedCount: number;
}
/**
* Truncate text to a maximum number of visual lines (from the end).
* This accounts for line wrapping based on terminal width.
*
* @param text - The text content (may contain newlines)
* @param maxVisualLines - Maximum number of visual lines to show
* @param width - Terminal/render width
* @param paddingX - Horizontal padding for Text component (default 0).
* Use 0 when result will be placed in a Box (Box adds its own padding).
* Use 1 when result will be placed in a plain Container.
* @returns The truncated visual lines and count of skipped lines
*/
export function truncateToVisualLines(
text: string,
maxVisualLines: number,
width: number,
paddingX: number = 0,
): VisualTruncateResult {
if (!text) {
return { visualLines: [], skippedCount: 0 };
}
// Create a temporary Text component to render and get visual lines
const tempText = new Text(text, paddingX, 0);
const allVisualLines = tempText.render(width);
if (allVisualLines.length <= maxVisualLines) {
return { visualLines: allVisualLines, skippedCount: 0 };
}
// Take the last N visual lines
const truncatedLines = allVisualLines.slice(-maxVisualLines);
const skippedCount = allVisualLines.length - maxVisualLines;
return { visualLines: truncatedLines, skippedCount };
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,21 @@
export interface ModelSearchItem {
id: string;
provider: string;
name?: string;
}
export function getModelSearchText(item: ModelSearchItem): string {
const { id, provider } = item;
const name = item.name ? ` ${item.name}` : "";
return `${id} ${provider} ${provider}/${id} ${provider} ${id}${name}`;
}
/**
* The /model selector search should rank exact provider-prefixed queries before proxy-provider IDs
* like openrouter/openai/gpt-5, so keep the bare model ID out of the leading position.
*/
export function getModelSelectorSearchText(item: ModelSearchItem): string {
const { id, provider } = item;
const name = item.name ? ` ${item.name}` : "";
return `${provider} ${provider}/${id} ${provider} ${id}${name}`;
}
@@ -0,0 +1,87 @@
{
"$schema": "https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
"name": "dark",
"vars": {
"cyan": "#00d7ff",
"blue": "#5f87ff",
"green": "#b5bd68",
"red": "#cc6666",
"yellow": "#ffff00",
"text": "#d4d4d4",
"gray": "#808080",
"dimGray": "#666666",
"darkGray": "#505050",
"accent": "#8abeb7",
"selectedBg": "#3a3a4a",
"userMsgBg": "#343541",
"toolPendingBg": "#282832",
"toolSuccessBg": "#283228",
"toolErrorBg": "#3c2828",
"customMsgBg": "#2d2838"
},
"colors": {
"accent": "accent",
"border": "blue",
"borderAccent": "cyan",
"borderMuted": "darkGray",
"success": "green",
"error": "red",
"warning": "yellow",
"muted": "gray",
"dim": "dimGray",
"text": "text",
"thinkingText": "gray",
"selectedBg": "selectedBg",
"userMessageBg": "userMsgBg",
"userMessageText": "text",
"customMessageBg": "customMsgBg",
"customMessageText": "text",
"customMessageLabel": "#9575cd",
"toolPendingBg": "toolPendingBg",
"toolSuccessBg": "toolSuccessBg",
"toolErrorBg": "toolErrorBg",
"toolTitle": "text",
"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": "#6A9955",
"syntaxKeyword": "#569CD6",
"syntaxFunction": "#DCDCAA",
"syntaxVariable": "#9CDCFE",
"syntaxString": "#CE9178",
"syntaxNumber": "#B5CEA8",
"syntaxType": "#4EC9B0",
"syntaxOperator": "#D4D4D4",
"syntaxPunctuation": "#D4D4D4",
"thinkingOff": "darkGray",
"thinkingMinimal": "#6e6e6e",
"thinkingLow": "#5f87af",
"thinkingMedium": "#81a2be",
"thinkingHigh": "#b294bb",
"thinkingXhigh": "#d183e8",
"thinkingMax": "#ff5fff",
"bashMode": "green"
},
"export": {
"pageBg": "#18181e",
"cardBg": "#1e1e24",
"infoBg": "#3c3728"
}
}
@@ -0,0 +1,86 @@
{
"$schema": "https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
"name": "light",
"vars": {
"teal": "#5a8080",
"blue": "#547da7",
"green": "#588458",
"red": "#aa5555",
"yellow": "#9a7326",
"text": "#1f2328",
"mediumGray": "#6c6c6c",
"dimGray": "#767676",
"lightGray": "#b0b0b0",
"selectedBg": "#d0d0e0",
"userMsgBg": "#e8e8e8",
"toolPendingBg": "#e8e8f0",
"toolSuccessBg": "#e8f0e8",
"toolErrorBg": "#f0e8e8",
"customMsgBg": "#ede7f6"
},
"colors": {
"accent": "teal",
"border": "blue",
"borderAccent": "teal",
"borderMuted": "lightGray",
"success": "green",
"error": "red",
"warning": "yellow",
"muted": "mediumGray",
"dim": "dimGray",
"text": "text",
"thinkingText": "mediumGray",
"selectedBg": "selectedBg",
"userMessageBg": "userMsgBg",
"userMessageText": "text",
"customMessageBg": "customMsgBg",
"customMessageText": "text",
"customMessageLabel": "#7e57c2",
"toolPendingBg": "toolPendingBg",
"toolSuccessBg": "toolSuccessBg",
"toolErrorBg": "toolErrorBg",
"toolTitle": "text",
"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": "#008000",
"syntaxKeyword": "#0000FF",
"syntaxFunction": "#795E26",
"syntaxVariable": "#001080",
"syntaxString": "#A31515",
"syntaxNumber": "#098658",
"syntaxType": "#267F99",
"syntaxOperator": "#000000",
"syntaxPunctuation": "#000000",
"thinkingOff": "lightGray",
"thinkingMinimal": "#767676",
"thinkingLow": "blue",
"thinkingMedium": "teal",
"thinkingHigh": "#875f87",
"thinkingXhigh": "#8b008b",
"thinkingMax": "#af005f",
"bashMode": "green"
},
"export": {
"pageBg": "#f8f8f8",
"cardBg": "#ffffff",
"infoBg": "#fffae6"
}
}
@@ -0,0 +1,126 @@
import type { TUI } from "@earendil-works/pi-tui";
import type { SettingsManager } from "../../../core/settings-manager.ts";
import {
detectTerminalBackgroundFromEnv,
detectTerminalBackgroundTheme,
detectTerminalThemeForAuto,
initTheme,
parseAutoThemeSetting,
resolveThemeSetting,
setTheme,
setThemeInstance,
type TerminalTheme,
type Theme,
} from "./theme.ts";
type ThemeResult = { success: boolean; error?: string };
export class InteractiveThemeController {
private readonly ui: TUI;
private readonly settingsManager: SettingsManager;
private readonly showError: (message: string) => void;
private readonly onChanged: () => void;
private terminalTheme: TerminalTheme = detectTerminalBackgroundFromEnv().theme;
private activeThemeName: string | undefined;
private autoSyncEnabled = false;
constructor(ui: TUI, settingsManager: SettingsManager, showError: (message: string) => void, onChanged: () => void) {
this.ui = ui;
this.settingsManager = settingsManager;
this.showError = showError;
this.onChanged = onChanged;
this.activeThemeName = resolveThemeSetting(this.settingsManager.getThemeSetting(), this.terminalTheme);
initTheme(this.activeThemeName, true);
this.ui.onTerminalColorSchemeChange((terminalTheme) => this.applyTerminalTheme(terminalTheme));
}
async applyFromSettings(): Promise<void> {
const themeSetting = this.settingsManager.getThemeSetting();
const autoTheme = parseAutoThemeSetting(themeSetting);
if (autoTheme) {
this.terminalTheme = await detectTerminalThemeForAuto({ ui: this.ui, timeoutMs: 100 });
this.setAutoSync(true);
this.applyThemeName(this.terminalTheme === "light" ? autoTheme.lightTheme : autoTheme.darkTheme, true);
return;
}
this.setAutoSync(false);
if (themeSetting !== undefined) {
this.applyThemeName(themeSetting, true);
return;
}
const detection = await detectTerminalBackgroundTheme({ ui: this.ui, timeoutMs: 100 });
this.terminalTheme = detection.theme;
if (!this.applyThemeName(detection.theme).success) return;
if (detection.confidence === "high") {
this.settingsManager.setTheme(detection.theme);
await this.settingsManager.flush();
}
}
setThemeName(themeName: string, showError = false): ThemeResult {
this.setAutoSync(false);
return this.applyThemeName(themeName, showError);
}
setThemeInstance(themeInstance: Theme): ThemeResult {
this.setAutoSync(false);
setThemeInstance(themeInstance);
this.activeThemeName = "<in-memory>";
this.notifyChanged();
return { success: true };
}
preview(themeSettingOrName: string): void {
const themeName = resolveThemeSetting(themeSettingOrName, this.terminalTheme) ?? this.activeThemeName;
if (!themeName) return;
if (setTheme(themeName, true).success) {
this.ui.invalidate();
this.ui.requestRender();
}
}
disableAutoSync(): void {
this.setAutoSync(false);
}
getTerminalTheme(): TerminalTheme {
return this.terminalTheme;
}
private applyThemeName(themeName: string, showError = false): ThemeResult {
const result = setTheme(themeName, true);
this.activeThemeName = result.success ? themeName : "dark";
this.notifyChanged();
if (!result.success && showError) {
this.showError(`Failed to load theme "${themeName}": ${result.error}\nFell back to dark theme.`);
}
return result;
}
private notifyChanged(): void {
this.ui.invalidate();
this.onChanged();
}
private setAutoSync(enabled: boolean): void {
if (this.autoSyncEnabled === enabled) return;
this.autoSyncEnabled = enabled;
this.ui.setTerminalColorSchemeNotifications(enabled);
}
private applyTerminalTheme(terminalTheme: TerminalTheme): void {
if (!this.autoSyncEnabled) return;
this.terminalTheme = terminalTheme;
const autoTheme = parseAutoThemeSetting(this.settingsManager.getThemeSetting());
if (!autoTheme) {
this.setAutoSync(false);
return;
}
const themeName = terminalTheme === "light" ? autoTheme.lightTheme : autoTheme.darkTheme;
if (themeName !== this.activeThemeName) {
this.applyThemeName(themeName);
}
}
}
@@ -0,0 +1,340 @@
{
"$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",
"pattern": "^[^/]+$",
"description": "Theme name. Must not contain '/' because it is reserved for automatic light/dark theme settings."
},
"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 (thinkingMax is optional and falls back to thinkingXhigh)",
"required": [
"accent",
"border",
"borderAccent",
"borderMuted",
"success",
"error",
"warning",
"muted",
"dim",
"text",
"thinkingText",
"selectedBg",
"userMessageBg",
"userMessageText",
"customMessageBg",
"customMessageText",
"customMessageLabel",
"toolPendingBg",
"toolSuccessBg",
"toolErrorBg",
"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"
],
"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)"
},
"thinkingText": {
"$ref": "#/$defs/colorValue",
"description": "Thinking block text color"
},
"selectedBg": {
"$ref": "#/$defs/colorValue",
"description": "Selected item background"
},
"userMessageBg": {
"$ref": "#/$defs/colorValue",
"description": "User message background"
},
"userMessageText": {
"$ref": "#/$defs/colorValue",
"description": "User message text color"
},
"customMessageBg": {
"$ref": "#/$defs/colorValue",
"description": "Custom message background (hook-injected messages)"
},
"customMessageText": {
"$ref": "#/$defs/colorValue",
"description": "Custom message text color"
},
"customMessageLabel": {
"$ref": "#/$defs/colorValue",
"description": "Custom message type label 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)"
},
"toolTitle": {
"$ref": "#/$defs/colorValue",
"description": "Tool execution box title color"
},
"toolOutput": {
"$ref": "#/$defs/colorValue",
"description": "Tool execution box output text color"
},
"mdHeading": {
"$ref": "#/$defs/colorValue",
"description": "Markdown heading text"
},
"mdLink": {
"$ref": "#/$defs/colorValue",
"description": "Markdown link text"
},
"mdLinkUrl": {
"$ref": "#/$defs/colorValue",
"description": "Markdown link URL"
},
"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"
},
"thinkingMax": {
"$ref": "#/$defs/colorValue",
"description": "Thinking level border: max (falls back to thinkingXhigh when omitted)"
},
"bashMode": {
"$ref": "#/$defs/colorValue",
"description": "Editor border color in bash mode"
}
},
"additionalProperties": false
},
"export": {
"type": "object",
"description": "Optional colors for HTML export (defaults derived from userMessageBg if not specified)",
"properties": {
"pageBg": {
"$ref": "#/$defs/colorValue",
"description": "Page background color"
},
"cardBg": {
"$ref": "#/$defs/colorValue",
"description": "Card/container background color"
},
"infoBg": {
"$ref": "#/$defs/colorValue",
"description": "Info sections background (system prompt, notices)"
}
},
"additionalProperties": false
}
},
"additionalProperties": false,
"$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)"
}
]
}
}
}
File diff suppressed because it is too large Load Diff