update
This commit is contained in:
1 parent
9779c4d1ca
commit
1b578c111a
719 files changed
+191593
-98
No files matched your search
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 "@mariozechner/pi-tui";
|
||||
import { theme } from "../theme/theme.js";
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
import type { AssistantMessage } from "@mariozechner/pi-ai";
|
||||
import { Container, Markdown, type MarkdownTheme, Spacer, Text } from "@mariozechner/pi-tui";
|
||||
import { getMarkdownTheme, theme } from "../theme/theme.js";
|
||||
|
||||
/**
|
||||
* Component that renders a complete assistant message
|
||||
*/
|
||||
export class AssistantMessageComponent extends Container {
|
||||
private contentContainer: Container;
|
||||
private hideThinkingBlock: boolean;
|
||||
private markdownTheme: MarkdownTheme;
|
||||
private hiddenThinkingLabel: string;
|
||||
private lastMessage?: AssistantMessage;
|
||||
|
||||
constructor(
|
||||
message?: AssistantMessage,
|
||||
hideThinkingBlock = false,
|
||||
markdownTheme: MarkdownTheme = getMarkdownTheme(),
|
||||
hiddenThinkingLabel = "Thinking...",
|
||||
) {
|
||||
super();
|
||||
|
||||
this.hideThinkingBlock = hideThinkingBlock;
|
||||
this.markdownTheme = markdownTheme;
|
||||
this.hiddenThinkingLabel = hiddenThinkingLabel;
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
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(), 1, 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)), 1, 0),
|
||||
);
|
||||
if (hasVisibleContentAfter) {
|
||||
this.contentContainer.addChild(new Spacer(1));
|
||||
}
|
||||
} else {
|
||||
// Thinking traces in thinkingText color, italic
|
||||
this.contentContainer.addChild(
|
||||
new Markdown(content.thinking.trim(), 1, 0, this.markdownTheme, {
|
||||
color: (text: string) => theme.fg("thinkingText", text),
|
||||
italic: true,
|
||||
}),
|
||||
);
|
||||
if (hasVisibleContentAfter) {
|
||||
this.contentContainer.addChild(new Spacer(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if aborted - show after partial content
|
||||
// But only if there are no tool calls (tool execution components will show the error)
|
||||
const hasToolCalls = message.content.some((c) => c.type === "toolCall");
|
||||
if (!hasToolCalls) {
|
||||
if (message.stopReason === "aborted") {
|
||||
const abortMessage =
|
||||
message.errorMessage && message.errorMessage !== "Request was aborted"
|
||||
? message.errorMessage
|
||||
: "Operation aborted";
|
||||
if (hasVisibleContent) {
|
||||
this.contentContainer.addChild(new Spacer(1));
|
||||
} else {
|
||||
this.contentContainer.addChild(new Spacer(1));
|
||||
}
|
||||
this.contentContainer.addChild(new Text(theme.fg("error", abortMessage), 1, 0));
|
||||
} else if (message.stopReason === "error") {
|
||||
const errorMsg = message.errorMessage || "Unknown error";
|
||||
this.contentContainer.addChild(new Spacer(1));
|
||||
this.contentContainer.addChild(new Text(theme.fg("error", `Error: ${errorMsg}`), 1, 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Component for displaying bash command execution with streaming output.
|
||||
*/
|
||||
|
||||
import { Container, Loader, Spacer, Text, type TUI } from "@mariozechner/pi-tui";
|
||||
import stripAnsi from "strip-ansi";
|
||||
import {
|
||||
DEFAULT_MAX_BYTES,
|
||||
DEFAULT_MAX_LINES,
|
||||
type TruncationResult,
|
||||
truncateTail,
|
||||
} from "../../../core/tools/truncate.js";
|
||||
import { theme } from "../theme/theme.js";
|
||||
import { DynamicBorder } from "./dynamic-border.js";
|
||||
import { keyHint, keyText } from "./keybinding-hints.js";
|
||||
import { truncateToVisualLines } from "./visual-truncate.js";
|
||||
|
||||
// Preview line limit when not expanded (matches tool execution behavior)
|
||||
const PREVIEW_LINES = 20;
|
||||
|
||||
export class BashExecutionComponent extends Container {
|
||||
private command: string;
|
||||
private outputLines: string[] = [];
|
||||
private status: "running" | "complete" | "cancelled" | "error" = "running";
|
||||
private exitCode: number | 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(`(${keyHint("app.tools.expand", "to collapse")})`);
|
||||
} else {
|
||||
statusParts.push(
|
||||
`${theme.fg("muted", `... ${hiddenLineCount} more lines`)} (${keyHint("app.tools.expand", "to expand")})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.status === "cancelled") {
|
||||
statusParts.push(theme.fg("warning", "(cancelled)"));
|
||||
} else if (this.status === "error") {
|
||||
statusParts.push(theme.fg("error", `(exit ${this.exitCode})`));
|
||||
}
|
||||
|
||||
// Add truncation warning (context truncation, not preview truncation)
|
||||
const wasTruncated = this.truncationResult?.truncated || contextTruncation.truncated;
|
||||
if (wasTruncated && this.fullOutputPath) {
|
||||
statusParts.push(theme.fg("warning", `Output truncated. Full output: ${this.fullOutputPath}`));
|
||||
}
|
||||
|
||||
if (statusParts.length > 0) {
|
||||
this.contentContainer.addChild(new Text(`\n${statusParts.join("\n")}`, 1, 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the raw output for creating BashExecutionMessage.
|
||||
*/
|
||||
getOutput(): string {
|
||||
return this.outputLines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the command that was executed.
|
||||
*/
|
||||
getCommand(): string {
|
||||
return this.command;
|
||||
}
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
import { CancellableLoader, Container, Loader, Spacer, Text, type TUI } from "@mariozechner/pi-tui";
|
||||
import type { Theme } from "../theme/theme.js";
|
||||
import { DynamicBorder } from "./dynamic-border.js";
|
||||
import { keyHint } from "./keybinding-hints.js";
|
||||
|
||||
/** 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
import { Box, Markdown, type MarkdownTheme, Spacer, Text } from "@mariozechner/pi-tui";
|
||||
import type { BranchSummaryMessage } from "../../../core/messages.js";
|
||||
import { getMarkdownTheme, theme } from "../theme/theme.js";
|
||||
import { keyText } from "./keybinding-hints.js";
|
||||
|
||||
/**
|
||||
* 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
import { Box, Markdown, type MarkdownTheme, Spacer, Text } from "@mariozechner/pi-tui";
|
||||
import type { CompactionSummaryMessage } from "../../../core/messages.js";
|
||||
import { getMarkdownTheme, theme } from "../theme/theme.js";
|
||||
import { keyText } from "./keybinding-hints.js";
|
||||
|
||||
/**
|
||||
* 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+592
@@ -0,0 +1,592 @@
|
||||
/**
|
||||
* TUI component for managing package resources (enable/disable)
|
||||
*/
|
||||
|
||||
import { basename, dirname, join, relative } from "node:path";
|
||||
import {
|
||||
type Component,
|
||||
Container,
|
||||
type Focusable,
|
||||
getKeybindings,
|
||||
Input,
|
||||
matchesKey,
|
||||
Spacer,
|
||||
truncateToWidth,
|
||||
visibleWidth,
|
||||
} from "@mariozechner/pi-tui";
|
||||
import { CONFIG_DIR_NAME } from "../../../config.js";
|
||||
import type { PathMetadata, ResolvedPaths, ResolvedResource } from "../../../core/package-manager.js";
|
||||
import type { PackageSource, SettingsManager } from "../../../core/settings-manager.js";
|
||||
import { theme } from "../theme/theme.js";
|
||||
import { DynamicBorder } from "./dynamic-border.js";
|
||||
import { rawKeyHint } from "./keybinding-hints.js";
|
||||
|
||||
type ResourceType = "extensions" | "skills" | "prompts" | "themes";
|
||||
|
||||
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 getGroupLabel(metadata: PathMetadata): string {
|
||||
if (metadata.origin === "package") {
|
||||
return `${metadata.source} (${metadata.scope})`;
|
||||
}
|
||||
// Top-level resources
|
||||
if (metadata.source === "auto") {
|
||||
return metadata.scope === "user" ? "User (~/.pi/agent/)" : "Project (.pi/)";
|
||||
}
|
||||
return metadata.scope === "user" ? "User settings" : "Project settings";
|
||||
}
|
||||
|
||||
function buildGroups(resolved: ResolvedPaths): 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}`;
|
||||
|
||||
if (!groupMap.has(groupKey)) {
|
||||
groupMap.set(groupKey, {
|
||||
key: groupKey,
|
||||
label: getGroupLabel(metadata),
|
||||
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 {
|
||||
invalidate(): void {}
|
||||
|
||||
render(width: number): string[] {
|
||||
const title = theme.bold("Resource Configuration");
|
||||
const sep = theme.fg("muted", " · ");
|
||||
const hint = rawKeyHint("space", "toggle") + sep + rawKeyHint("esc", "close");
|
||||
const hintWidth = visibleWidth(hint);
|
||||
const titleWidth = visibleWidth(title);
|
||||
const spacing = Math.max(1, width - titleWidth - hintWidth);
|
||||
|
||||
return [
|
||||
truncateToWidth(`${title}${" ".repeat(spacing)}${hint}`, width, ""),
|
||||
theme.fg("muted", "Type to filter resources"),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
class ResourceList implements Component, Focusable {
|
||||
private groups: ResourceGroup[];
|
||||
private flatItems: FlatEntry[] = [];
|
||||
private filteredItems: FlatEntry[] = [];
|
||||
private selectedIndex = 0;
|
||||
private searchInput: Input;
|
||||
private maxVisible = 15;
|
||||
private settingsManager: SettingsManager;
|
||||
private cwd: string;
|
||||
private agentDir: string;
|
||||
|
||||
public onCancel?: () => void;
|
||||
public onExit?: () => void;
|
||||
public onToggle?: (item: ResourceItem, newEnabled: boolean) => void;
|
||||
|
||||
private _focused = false;
|
||||
get focused(): boolean {
|
||||
return this._focused;
|
||||
}
|
||||
set focused(value: boolean) {
|
||||
this._focused = value;
|
||||
this.searchInput.focused = value;
|
||||
}
|
||||
|
||||
constructor(groups: ResourceGroup[], settingsManager: SettingsManager, cwd: string, agentDir: string) {
|
||||
this.groups = groups;
|
||||
this.settingsManager = settingsManager;
|
||||
this.cwd = cwd;
|
||||
this.agentDir = agentDir;
|
||||
this.searchInput = new Input();
|
||||
this.buildFlatList();
|
||||
this.filteredItems = [...this.flatItems];
|
||||
}
|
||||
|
||||
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 groupLine = theme.fg("accent", theme.bold(entry.group.label));
|
||||
lines.push(truncateToWidth(` ${groupLine}`, width, ""));
|
||||
} else if (entry.type === "subgroup") {
|
||||
// Subgroup header (indented, no cursor)
|
||||
const subgroupLine = theme.fg("muted", entry.subgroup.label);
|
||||
lines.push(truncateToWidth(` ${subgroupLine}`, width, ""));
|
||||
} else {
|
||||
// Resource item (cursor only on items)
|
||||
const item = entry.item;
|
||||
const cursor = isSelected ? "> " : " ";
|
||||
const checkbox = item.enabled ? theme.fg("success", "[x]") : theme.fg("dim", "[ ]");
|
||||
const name = isSelected ? theme.bold(item.displayName) : item.displayName;
|
||||
lines.push(truncateToWidth(`${cursor} ${checkbox} ${name}`, width, "..."));
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll indicator
|
||||
if (startIndex > 0 || endIndex < this.filteredItems.length) {
|
||||
lines.push(theme.fg("dim", ` (${this.selectedIndex + 1}/${this.filteredItems.length})`));
|
||||
}
|
||||
|
||||
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 (data === " " || kb.matches(data, "tui.select.confirm")) {
|
||||
const entry = this.filteredItems[this.selectedIndex];
|
||||
if (entry?.type === "item") {
|
||||
const newEnabled = !entry.item.enabled;
|
||||
this.toggleResource(entry.item, newEnabled);
|
||||
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, enabled: boolean): void {
|
||||
if (item.metadata.origin === "top-level") {
|
||||
this.toggleTopLevelResource(item, enabled);
|
||||
} else {
|
||||
this.togglePackageResource(item, 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 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 = 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 resourceList: ResourceList;
|
||||
|
||||
private _focused = false;
|
||||
get focused(): boolean {
|
||||
return this._focused;
|
||||
}
|
||||
set focused(value: boolean) {
|
||||
this._focused = value;
|
||||
this.resourceList.focused = value;
|
||||
}
|
||||
|
||||
constructor(
|
||||
resolvedPaths: ResolvedPaths,
|
||||
settingsManager: SettingsManager,
|
||||
cwd: string,
|
||||
agentDir: string,
|
||||
onClose: () => void,
|
||||
onExit: () => void,
|
||||
requestRender: () => void,
|
||||
) {
|
||||
super();
|
||||
|
||||
const groups = buildGroups(resolvedPaths);
|
||||
|
||||
// Add header
|
||||
this.addChild(new Spacer(1));
|
||||
this.addChild(new DynamicBorder());
|
||||
this.addChild(new Spacer(1));
|
||||
this.addChild(new ConfigSelectorHeader());
|
||||
this.addChild(new Spacer(1));
|
||||
|
||||
// Resource list
|
||||
this.resourceList = new ResourceList(groups, settingsManager, cwd, agentDir);
|
||||
this.resourceList.onCancel = onClose;
|
||||
this.resourceList.onExit = onExit;
|
||||
this.resourceList.onToggle = () => requestRender();
|
||||
this.addChild(this.resourceList);
|
||||
|
||||
// Bottom border
|
||||
this.addChild(new Spacer(1));
|
||||
this.addChild(new DynamicBorder());
|
||||
}
|
||||
|
||||
getResourceList(): ResourceList {
|
||||
return this.resourceList;
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Reusable countdown timer for dialog components.
|
||||
*/
|
||||
|
||||
import type { TUI } from "@mariozechner/pi-tui";
|
||||
|
||||
export class CountdownTimer {
|
||||
private intervalId: ReturnType<typeof setInterval> | undefined;
|
||||
private remainingSeconds: number;
|
||||
|
||||
constructor(
|
||||
timeoutMs: number,
|
||||
private tui: TUI | undefined,
|
||||
private onTick: (seconds: number) => void,
|
||||
private onExpire: () => void,
|
||||
) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
import { Editor, type EditorOptions, type EditorTheme, type TUI } from "@mariozechner/pi-tui";
|
||||
import type { AppKeybinding, KeybindingsManager } from "../../../core/keybindings.js";
|
||||
|
||||
/**
|
||||
* 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 paste image 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);
|
||||
}
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
import type { TextContent } from "@mariozechner/pi-ai";
|
||||
import type { Component } from "@mariozechner/pi-tui";
|
||||
import { Box, Container, Markdown, type MarkdownTheme, Spacer, Text } from "@mariozechner/pi-tui";
|
||||
import type { MessageRenderer } from "../../../core/extensions/types.js";
|
||||
import type { CustomMessage } from "../../../core/messages.js";
|
||||
import { getMarkdownTheme, theme } from "../theme/theme.js";
|
||||
|
||||
/**
|
||||
* 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),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* POWERED BY DAXNUTS - Easter egg for OpenCode + Kimi K2.5
|
||||
*
|
||||
* A heartfelt tribute to dax (@thdxr) for providing free Kimi K2.5 access via OpenCode.
|
||||
*/
|
||||
|
||||
import type { Component, TUI } from "@mariozechner/pi-tui";
|
||||
import { theme } from "../theme/theme.js";
|
||||
|
||||
// 32x32 RGB image of dax, hex encoded (3 bytes per pixel)
|
||||
const DAX_HEX =
|
||||
"bbbab8b9b9b6b9b8b5bcbbb8b8b7b4b7b5b2b6b5b2b8b7b4b7b6b3b6b4b1bdbcb8bab8b6bbb8b5b8b5b1bbb8b4c2bebbc1bebac0bdbabfbcb9c1bebabfbebbc0bfbcc0bdbabbb8b5c1bfbcbfbcb8bbb9b6bfbcb8c2bfbcc1bfbcbfbbb8bdb9b6b8b7b5b9b8b5b8b8b5b5b5b2b6b5b2b8b7b4b9b8b5b9b8b5b6b5b3bab8b5bcbab7bbb9b6bbb8b5bfb9b5bdb2abbcb0a8beb2aabeb5afbfbab6bebab7c0bfbcbebdbabebbb8c0bdbabfbebbc2bebbbdbab7c3c0bdc3c0bdc1bebbc2bebabfbcb8bab9b6b7b6b3b2b1aeb6b5b2b5b4b1b5b4b2b6b5b2b7b6b4b9b8b6b7b6b3bbbab7b2afaba5988fb49e90b09481b79a88b39683b09583b7a395bfb6b0c0bdbabdbbb8bebcb9c1bfbcc0bebbbdbab7bebbb8c2bfbcc0bdbac0bcb9bdb9b6c0bcb8b5b4b2b4b3b0bab9b6b9b9b6b5b4b1b5b4b1b6b5b3b9b8b5b9b8b6b9b8b6b2aeaa968174a6836eaa856eab846eaf8973ac8973b08f79b18f7ab39786b7a89dbbb3aebfbab6c2c0bdbebcb9bfbdbac3c1bdc2bebbc0bcb9bdb9b6c1bdbabfbbb8b4b3b0b9b8b5b8b7b5b4b3b1b5b4b1b8b7b4b8b7b5bab9b6bbbab7b1afad8c7a719d735ca47860a87d65a98069ae8972ae8c75af8d77aa826ba98067aa8974b39e90b6a79dbbb2adc0bdbac1bfbdbfbbb8c1bdb9bebab6c0bdb9bfbbb8c1bdbab4b2b0b7b6b4b7b6b3b4b2b0bab9b7b6b5b2b6b5b2bab9b6bab9b6958c87977663aa836bac8772b08f7aad8c77b2917db0917db0907cac8971a77d64a87f67ac8972b29887b8a89dbfbab5bfbdbac1bebac0bcb9c0bcb9c0bcb9c1bebabebab7b8b7b4b7b6b4b5b4b1b5b4b2b7b6b3b5b4b2bab9b7bab9b6b4b1ada88f7fad8973ae8d78b19684b19685b29786b69a89b29582b1917daa856ea87e66a97e66ad866ea9826baf9280b8ada6bdbbb8bebab7bfbbb8c1bdbabfbbb8bcb8b4bcb8b5b6b4b2b7b5b3b6b5b2b8b7b4b3b2afb8b7b4b6b5b2b3b2b0b3a59aab856fad8d78b0917eb19886b49b8bb49a89b39785b0917eaf8f7cab866fa77d65a77a61a87d64a9816ab08f79b5a296c1bcb8c3bfbcc2bebbbebab7bfbbb7bdbab6c2bebab8b7b4b7b6b4b6b5b3b7b6b3b6b5b2b9b8b6b4b3b1b6b1acac8f7ca9826bae8f7aaf9583b49c8cb49c8bb79d8cb59987b19380ad8e79ae8c77af8e78ac8771a3775faa826bae8972b39888bbb6b2bebbb8bfbbb8bfbbb8c0bdb9bebbb7c0bdb9b6b5b2b9b8b5b4b3b1b8b7b5b4b3b0b7b6b4b6b5b3b1a7a0aa8772a77d65a88570b49887b19b8d9c887c907a6d987f71aa907faf917daf8e7aad8c78ac8b77a8836ca9836cac8770b49b8abdb6b2c0bcb9c0bdb9bfbbb8bebab7bfbcb9bebab7b9b8b6b5b4b2b9b8b5b8b7b5b8b7b4b7b6b4b5b4b2b3a9a2ad8973a1755da9856fb398858c776a65544b776358725d526e594d9c7f6eb1907ba68672ad8e7aab8771ac856db18f79b3a092beb9b5c1bdbabdb9b5bebab7bfbbb7bebab7bcb9b6b7b6b4b6b6b3b8b7b4b5b4b2b8b6b4b7b6b3b4b3b0b4aba4a6826ba3775fb08e79b19584a88e7daa8e7db29481ad8f7c997e6da38674ac8d79ac8e7aae917f9a7c6a896a599a7c6ab3a398c1bdbabdb9b6bcb8b5bebab6bebab7bdb9b5bdb9b6b5b4b1b7b5b3b5b4b2b7b6b3b7b6b4b3b3b0b3b2b0b4aca5a7846fa97f68ae8f7bae9383b59c8bb2937fae8e79ac8b76af927eaf927eb29683b39885b2988891786a72594c6e594d978d86bdbab7bab7b3c0bcb9c0bcb9bebab7bebbb7bdb9b6b3b2b0b4b3b0b5b4b2b4b4b1b4b3b1b4b3b1b4b3b0b6ada5aa8670a57a62ad8e7ab29b8cb69d8dab856fa9826aa88069ab8771af907db49987b19684b29886b59987b39480b09787b5a9a1bcb8b5bebab7bdb9b5bebab7bfbbb8bfbbb7bbb7b4b3b2afb8b7b5b8b7b5b3b2b0b5b4b2b6b5b3b6b4b1afa299a98975a9826baf907cb39988b49a89af8e7aac8973aa856eaf8c74b1917dae907dac907db39988b29785b49785b7a090b9aca3bfbab7bcb8b5bdb9b6bcb8b4bcb8b5bdb9b5bcb8b4b5b4b2b6b5b3b4b3b0b4b3b0b9b8b5b8b6b4908b88887467aa8f7ea78976ad8973b08b74b59885b69e8eb29888b1917cb1917db1937fae907cb19686b39a8ab29886b59b8ab8a192b6aaa3b7b2afbcb8b4bcb8b5bbb7b4c0bcb9bebab7c0bcb9b6b5b2b6b5b3b4b3b0bab9b7b7b6b4b1b0ae7b716ba083709b806f716158967764b08870b29481b69b8ab69f8fb39a89b69f90b49d8db39a89b29988b49c8cb6a090b8a496baa49593867f8f8986bfbbb7bdb9b5bcb7b4bab6b3b9b5b2bab6b2b4b3b1b3b3b0b6b5b3b8b7b5b4b2b0a7a5a38f837dae917ea084725a504c63544da28370b39784b59e8db2a093a698909b918b998e8790857e95877dad998bb39c8cb5a091b9a2938d827c95908dbebab6bbb7b3bdbab7bbb7b4bdb9b6bbb7b4b4b3b0b5b4b1b8b7b5b6b5b3b8b8b5b4b2af968f8ab29a8bab9485544b483a323073655d96887f70655f61595547403e453e3c453f3d57504f655e5b90847db39c8db7a090b6a09189807aaba6a3bdb9b6c0bcb9bebab7bcb7b4bebab7bbb7b4b3b2b0b6b5b3b2b1afb7b6b4b8b7b4b5b4b1aeaba8b5a89fac998d4d44412d25244d46444e4744322b293a3230423937433a37352d2a59504c534b48524a48988a81b59f8fb19c8d827974b2afacbdb9b5bcb8b4bdb9b5bcb8b5bdb9b6bab6b2b8b7b5b5b4b2b6b6b3b9b8b5b7b6b3b6b5b2b8b6b3b9b4b1b2a9a26c64612d25242d2625312a28352d2c453d3a78675c8d7a6ea09792aea6a0615854332b29524a479f8e82b09d90a49b96c1bdb9bebab7bfbbb8bbb8b4b9b5b1b8b4b0b9b4b0b7b6b4b8b7b5b8b7b4b6b5b3b8b6b3bab9b6b9b8b5b4b3b0b7b5b2a5a29f453d3b261e1d261f1e2e2625413936857268977865b19482b5a69caca5a07c7572453d3b746963a0948cc5bfbbc0bbb8beb9b6bbb7b3bbb6b3b7b3afb8b4b0b9b5b1b7b6b3b6b5b3b5b4b2b5b4b2b7b6b3b7b6b3b8b6b3b4b2afb7b6b3b3b1ae6d6765251f1e1e18172a22212d2523443b3971625ab19888b09482a89182877e792c25243e3634766d6abeb9b5bfbbb7bebab6bcb7b3bbb6b3b9b5b1b7b3afb8b4b0b4b3b0b5b4b1b5b4b1b4b3b1b5b4b2b8b6b4b5b3b0b9b6b4b5b4b1b6b4b27f79762a2322221c1b2d2524221b1a443e3c47413f6f676281766f867971675e5a3e37352a222166605dbab7b3bdb9b5beb9b5bcb7b3bcb7b3b9b4b0bab6b2bab6b2b5b3b0b6b4b2b3b2afb7b6b3b4b4b1b4b3b0b6b4b1b5b4b1b4b3b0b9b6b29a8c8252474230292828201f181212322c2c231e1d1c16162c26252923222d26252d2523332b2a8e8885bcb8b5bcb7b3bbb6b2bcb7b3b9b4b1b9b5b1b7b2afb7b2ae7a838e9b9b9caeadacb3b2b0b3b2afb7b7b4b6b5b3b6b6b3b7b6b3b9ada4a991808e7b6f50453f2b24231a14142923221f19181d17161f18182620201d17162a22215d5654b7b3b0bbb7b3bbb6b2b8b4b0bab5b1bbb6b2bab5b1b8b4b0bab6b22c496Line truncated
|
||||
|
||||
const WIDTH = 32;
|
||||
const HEIGHT = 32;
|
||||
|
||||
function parseImage(): number[][][] {
|
||||
const pixels: number[][][] = [];
|
||||
for (let y = 0; y < HEIGHT; y++) {
|
||||
const row: number[][] = [];
|
||||
for (let x = 0; x < WIDTH; x++) {
|
||||
const idx = (y * WIDTH + x) * 6;
|
||||
const r = parseInt(DAX_HEX.slice(idx, idx + 2), 16);
|
||||
const g = parseInt(DAX_HEX.slice(idx + 2, idx + 4), 16);
|
||||
const b = parseInt(DAX_HEX.slice(idx + 4, idx + 6), 16);
|
||||
row.push([r, g, b]);
|
||||
}
|
||||
pixels.push(row);
|
||||
}
|
||||
return pixels;
|
||||
}
|
||||
|
||||
function rgb(r: number, g: number, b: number, bg = false): string {
|
||||
return `\x1b[${bg ? 48 : 38};2;${r};${g};${b}m`;
|
||||
}
|
||||
|
||||
const RESET = "\x1b[0m";
|
||||
|
||||
function buildImage(): string[] {
|
||||
const pixels = parseImage();
|
||||
const lines: string[] = [];
|
||||
|
||||
// Use half-block chars: ▄ with bg=top pixel, fg=bottom pixel
|
||||
for (let row = 0; row < HEIGHT; row += 2) {
|
||||
let line = "";
|
||||
for (let x = 0; x < WIDTH; x++) {
|
||||
const top = pixels[row][x];
|
||||
const bottom = pixels[row + 1]?.[x] ?? top;
|
||||
line += `${rgb(bottom[0], bottom[1], bottom[2])}${rgb(top[0], top[1], top[2], true)}▄`;
|
||||
}
|
||||
line += RESET;
|
||||
lines.push(line);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
export class DaxnutsComponent implements Component {
|
||||
private ui: TUI;
|
||||
private image: string[];
|
||||
private interval: ReturnType<typeof setInterval> | null = null;
|
||||
private tick = 0;
|
||||
private maxTicks = 25; // ~2 seconds at 80ms
|
||||
private cachedLines: string[] = [];
|
||||
private cachedWidth = 0;
|
||||
private cachedTick = -1;
|
||||
|
||||
constructor(ui: TUI) {
|
||||
this.ui = ui;
|
||||
this.image = buildImage();
|
||||
this.startAnimation();
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.cachedWidth = 0;
|
||||
}
|
||||
|
||||
private startAnimation(): void {
|
||||
this.interval = setInterval(() => {
|
||||
this.tick++;
|
||||
if (this.tick >= this.maxTicks) {
|
||||
this.stopAnimation();
|
||||
}
|
||||
this.cachedWidth = 0;
|
||||
this.ui.requestRender();
|
||||
}, 80);
|
||||
}
|
||||
|
||||
private stopAnimation(): void {
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval);
|
||||
this.interval = null;
|
||||
}
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
if (width === this.cachedWidth && this.cachedTick === this.tick) {
|
||||
return this.cachedLines;
|
||||
}
|
||||
|
||||
const t = theme;
|
||||
const lines: string[] = [];
|
||||
|
||||
const center = (s: string) => {
|
||||
const visible = s.replace(/\x1b\[[0-9;]*m/g, "").length;
|
||||
const left = Math.max(0, Math.floor((width - visible) / 2));
|
||||
return " ".repeat(left) + s;
|
||||
};
|
||||
|
||||
lines.push("");
|
||||
|
||||
// Scanline reveal effect: show rows progressively
|
||||
const revealedRows = Math.min(
|
||||
this.image.length,
|
||||
Math.floor((this.tick / this.maxTicks) * (this.image.length + 3)),
|
||||
);
|
||||
|
||||
for (let i = 0; i < this.image.length; i++) {
|
||||
if (i < revealedRows) {
|
||||
lines.push(center(this.image[i]));
|
||||
} else {
|
||||
// Show scan line
|
||||
if (i === revealedRows) {
|
||||
const scanline = "▓".repeat(WIDTH);
|
||||
lines.push(center(rgb(100, 200, 255) + scanline + RESET));
|
||||
} else {
|
||||
lines.push(center(" ".repeat(WIDTH)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
|
||||
// Fade in text after image is revealed
|
||||
const textPhase = Math.max(0, this.tick - this.maxTicks * 0.6);
|
||||
if (textPhase > 0 || this.tick >= this.maxTicks) {
|
||||
lines.push(center(t.fg("accent", "Free Kimi K2.5 via OpenCode Zen")));
|
||||
lines.push(center(t.fg("success", '"Powered by daxnuts"')));
|
||||
lines.push(center(t.fg("muted", "— @thdxr")));
|
||||
} else {
|
||||
lines.push("");
|
||||
lines.push("");
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
if (textPhase > 2 || this.tick >= this.maxTicks) {
|
||||
lines.push(center(t.fg("dim", "Try OpenCode")));
|
||||
lines.push(center(t.fg("mdLink", "https://mistral.ai/news/mistral-vibe-2-0")));
|
||||
} else {
|
||||
lines.push("");
|
||||
lines.push("");
|
||||
}
|
||||
lines.push("");
|
||||
|
||||
this.cachedLines = lines;
|
||||
this.cachedWidth = width;
|
||||
this.cachedTick = this.tick;
|
||||
return lines;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.stopAnimation();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import * as Diff from "diff";
|
||||
import { theme } from "../theme/theme.js";
|
||||
|
||||
/**
|
||||
* 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");
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import type { Component } from "@mariozechner/pi-tui";
|
||||
import { theme } from "../theme/theme.js";
|
||||
|
||||
/**
|
||||
* 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)))];
|
||||
}
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
import * as fs from "node:fs";
|
||||
import { Container, Image, Spacer, Text } from "@mariozechner/pi-tui";
|
||||
import { getBundledInteractiveAssetPath } from "../../../config.js";
|
||||
import { theme } from "../theme/theme.js";
|
||||
import { DynamicBorder } from "./dynamic-border.js";
|
||||
|
||||
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)));
|
||||
}
|
||||
}
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Multi-line editor component for extensions.
|
||||
* Supports Ctrl+G for external editor.
|
||||
*/
|
||||
|
||||
import { spawnSync } 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 "@mariozechner/pi-tui";
|
||||
import type { KeybindingsManager } from "../../../core/keybindings.js";
|
||||
import { getEditorTheme, theme } from "../theme/theme.js";
|
||||
import { DynamicBorder } from "./dynamic-border.js";
|
||||
import { keyHint } from "./keybinding-hints.js";
|
||||
|
||||
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 _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,
|
||||
) {
|
||||
super();
|
||||
|
||||
this.tui = tui;
|
||||
this.keybindings = keybindings;
|
||||
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 = !!(process.env.VISUAL || process.env.EDITOR);
|
||||
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 openExternalEditor(): void {
|
||||
const editorCmd = process.env.VISUAL || process.env.EDITOR;
|
||||
if (!editorCmd) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentText = this.editor.getText();
|
||||
const tmpFile = path.join(os.tmpdir(), `pi-extension-editor-${Date.now()}.md`);
|
||||
|
||||
try {
|
||||
fs.writeFileSync(tmpFile, currentText, "utf-8");
|
||||
this.tui.stop();
|
||||
|
||||
const [editor, ...editorArgs] = editorCmd.split(" ");
|
||||
const result = spawnSync(editor, [...editorArgs, tmpFile], {
|
||||
stdio: "inherit",
|
||||
shell: process.platform === "win32",
|
||||
});
|
||||
|
||||
if (result.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Simple text input component for extensions.
|
||||
*/
|
||||
|
||||
import { Container, type Focusable, getKeybindings, Input, Spacer, Text, type TUI } from "@mariozechner/pi-tui";
|
||||
import { theme } from "../theme/theme.js";
|
||||
import { CountdownTimer } from "./countdown-timer.js";
|
||||
import { DynamicBorder } from "./dynamic-border.js";
|
||||
import { keyHint } from "./keybinding-hints.js";
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Generic selector component for extensions.
|
||||
* Displays a list of string options with keyboard navigation.
|
||||
*/
|
||||
|
||||
import { Container, getKeybindings, Spacer, Text, type TUI } from "@mariozechner/pi-tui";
|
||||
import { theme } from "../theme/theme.js";
|
||||
import { CountdownTimer } from "./countdown-timer.js";
|
||||
import { DynamicBorder } from "./dynamic-border.js";
|
||||
import { keyHint, rawKeyHint } from "./keybinding-hints.js";
|
||||
|
||||
export interface ExtensionSelectorOptions {
|
||||
tui?: TUI;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
constructor(
|
||||
title: string,
|
||||
options: string[],
|
||||
onSelect: (option: string) => void,
|
||||
onCancel: () => void,
|
||||
opts?: ExtensionSelectorOptions,
|
||||
) {
|
||||
super();
|
||||
|
||||
this.options = options;
|
||||
this.onSelectCallback = onSelect;
|
||||
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.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, "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,220 @@
|
||||
import { type Component, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
||||
import type { AgentSession } from "../../../core/agent-session.js";
|
||||
import type { ReadonlyFooterDataProvider } from "../../../core/footer-data-provider.js";
|
||||
import { theme } from "../theme/theme.js";
|
||||
|
||||
/**
|
||||
* Sanitize text for display in a single-line status.
|
||||
* Removes newlines, tabs, carriage returns, and other control characters.
|
||||
*/
|
||||
function sanitizeStatusText(text: string): string {
|
||||
// Replace newlines, tabs, carriage returns with space, then collapse multiple spaces
|
||||
return text
|
||||
.replace(/[\r\n\t]/g, " ")
|
||||
.replace(/ +/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format token counts (similar to web-ui)
|
||||
*/
|
||||
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`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
constructor(
|
||||
private session: AgentSession,
|
||||
private footerData: ReadonlyFooterDataProvider,
|
||||
) {}
|
||||
|
||||
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;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// 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 = this.session.sessionManager.getCwd();
|
||||
const home = process.env.HOME || process.env.USERPROFILE;
|
||||
if (home && pwd.startsWith(home)) {
|
||||
pwd = `~${pwd.slice(home.length)}`;
|
||||
}
|
||||
|
||||
// Add git branch if available
|
||||
const branch = this.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)}`);
|
||||
|
||||
// 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);
|
||||
|
||||
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,32 @@
|
||||
// UI Components for extensions
|
||||
export { ArminComponent } from "./armin.js";
|
||||
export { AssistantMessageComponent } from "./assistant-message.js";
|
||||
export { BashExecutionComponent } from "./bash-execution.js";
|
||||
export { BorderedLoader } from "./bordered-loader.js";
|
||||
export { BranchSummaryMessageComponent } from "./branch-summary-message.js";
|
||||
export { CompactionSummaryMessageComponent } from "./compaction-summary-message.js";
|
||||
export { CustomEditor } from "./custom-editor.js";
|
||||
export { CustomMessageComponent } from "./custom-message.js";
|
||||
export { DaxnutsComponent } from "./daxnuts.js";
|
||||
export { type RenderDiffOptions, renderDiff } from "./diff.js";
|
||||
export { DynamicBorder } from "./dynamic-border.js";
|
||||
export { ExtensionEditorComponent } from "./extension-editor.js";
|
||||
export { ExtensionInputComponent } from "./extension-input.js";
|
||||
export { ExtensionSelectorComponent } from "./extension-selector.js";
|
||||
export { FooterComponent } from "./footer.js";
|
||||
export { keyHint, keyText, rawKeyHint } from "./keybinding-hints.js";
|
||||
export { LoginDialogComponent } from "./login-dialog.js";
|
||||
export { ModelSelectorComponent } from "./model-selector.js";
|
||||
export { OAuthSelectorComponent } from "./oauth-selector.js";
|
||||
export { type ModelsCallbacks, type ModelsConfig, ScopedModelsSelectorComponent } from "./scoped-models-selector.js";
|
||||
export { SessionSelectorComponent } from "./session-selector.js";
|
||||
export { type SettingsCallbacks, type SettingsConfig, SettingsSelectorComponent } from "./settings-selector.js";
|
||||
export { ShowImagesSelectorComponent } from "./show-images-selector.js";
|
||||
export { SkillInvocationMessageComponent } from "./skill-invocation-message.js";
|
||||
export { ThemeSelectorComponent } from "./theme-selector.js";
|
||||
export { ThinkingSelectorComponent } from "./thinking-selector.js";
|
||||
export { ToolExecutionComponent, type ToolExecutionOptions } from "./tool-execution.js";
|
||||
export { TreeSelectorComponent } from "./tree-selector.js";
|
||||
export { UserMessageComponent } from "./user-message.js";
|
||||
export { UserMessageSelectorComponent } from "./user-message-selector.js";
|
||||
export { truncateToVisualLines, type VisualTruncateResult } from "./visual-truncate.js";
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Utilities for formatting keybinding hints in the UI.
|
||||
*/
|
||||
|
||||
import { getKeybindings, type Keybinding, type KeyId } from "@mariozechner/pi-tui";
|
||||
import { theme } from "../theme/theme.js";
|
||||
|
||||
function formatKeys(keys: KeyId[]): string {
|
||||
if (keys.length === 0) return "";
|
||||
if (keys.length === 1) return keys[0]!;
|
||||
return keys.join("/");
|
||||
}
|
||||
|
||||
export function keyText(keybinding: Keybinding): string {
|
||||
return formatKeys(getKeybindings().getKeys(keybinding));
|
||||
}
|
||||
|
||||
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", key) + theme.fg("muted", ` ${description}`);
|
||||
}
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
import { getOAuthProviders } from "@mariozechner/pi-ai/oauth";
|
||||
import { Container, type Focusable, getKeybindings, Input, Spacer, Text, type TUI } from "@mariozechner/pi-tui";
|
||||
import { exec } from "child_process";
|
||||
import { theme } from "../theme/theme.js";
|
||||
import { DynamicBorder } from "./dynamic-border.js";
|
||||
import { keyHint } from "./keybinding-hints.js";
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
// 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,
|
||||
private onComplete: (success: boolean, message?: string) => void,
|
||||
) {
|
||||
super();
|
||||
this.tui = tui;
|
||||
|
||||
const providerInfo = getOAuthProviders().find((p) => p.id === providerId);
|
||||
const providerName = providerInfo?.name || providerId;
|
||||
|
||||
// Top border
|
||||
this.addChild(new DynamicBorder());
|
||||
|
||||
// Title
|
||||
this.addChild(new Text(theme.fg("warning", `Login to ${providerName}`), 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) {
|
||||
this.inputResolver(this.input.getValue());
|
||||
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 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));
|
||||
this.contentContainer.addChild(new Text(theme.fg("accent", url), 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));
|
||||
}
|
||||
|
||||
// Try to open browser
|
||||
const openCmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
||||
exec(`${openCmd} "${url}"`);
|
||||
|
||||
this.tui.requestRender();
|
||||
}
|
||||
|
||||
/**
|
||||
* Show input for manual code/URL entry (for callback server providers)
|
||||
*/
|
||||
showManualInput(prompt: string): Promise<string> {
|
||||
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 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);
|
||||
}
|
||||
}
|
||||
+338
@@ -0,0 +1,338 @@
|
||||
import { type Model, modelsAreEqual } from "@mariozechner/pi-ai";
|
||||
import {
|
||||
Container,
|
||||
type Focusable,
|
||||
fuzzyFilter,
|
||||
getKeybindings,
|
||||
Input,
|
||||
Spacer,
|
||||
Text,
|
||||
type TUI,
|
||||
} from "@mariozechner/pi-tui";
|
||||
import type { ModelRegistry } from "../../../core/model-registry.js";
|
||||
import type { SettingsManager } from "../../../core/settings-manager.js";
|
||||
import { theme } from "../theme/theme.js";
|
||||
import { DynamicBorder } from "./dynamic-border.js";
|
||||
import { keyHint } from "./keybinding-hints.js";
|
||||
|
||||
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 with configured API keys (see README for details)";
|
||||
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 }) => `${id} ${provider} ${provider}/${id} ${provider} ${id}`,
|
||||
)
|
||||
: 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;
|
||||
}
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
import type { OAuthProviderInterface } from "@mariozechner/pi-ai";
|
||||
import { getOAuthProviders } from "@mariozechner/pi-ai/oauth";
|
||||
import { Container, getKeybindings, Spacer, TruncatedText } from "@mariozechner/pi-tui";
|
||||
import type { AuthStorage } from "../../../core/auth-storage.js";
|
||||
import { theme } from "../theme/theme.js";
|
||||
import { DynamicBorder } from "./dynamic-border.js";
|
||||
|
||||
/**
|
||||
* Component that renders an OAuth provider selector
|
||||
*/
|
||||
export class OAuthSelectorComponent extends Container {
|
||||
private listContainer: Container;
|
||||
private allProviders: OAuthProviderInterface[] = [];
|
||||
private selectedIndex: number = 0;
|
||||
private mode: "login" | "logout";
|
||||
private authStorage: AuthStorage;
|
||||
private onSelectCallback: (providerId: string) => void;
|
||||
private onCancelCallback: () => void;
|
||||
|
||||
constructor(
|
||||
mode: "login" | "logout",
|
||||
authStorage: AuthStorage,
|
||||
onSelect: (providerId: string) => void,
|
||||
onCancel: () => void,
|
||||
) {
|
||||
super();
|
||||
|
||||
this.mode = mode;
|
||||
this.authStorage = authStorage;
|
||||
this.onSelectCallback = onSelect;
|
||||
this.onCancelCallback = onCancel;
|
||||
|
||||
// Load all OAuth providers
|
||||
this.loadProviders();
|
||||
|
||||
// Add top border
|
||||
this.addChild(new DynamicBorder());
|
||||
this.addChild(new Spacer(1));
|
||||
|
||||
// Add title
|
||||
const title = mode === "login" ? "Select provider to login:" : "Select provider to logout:";
|
||||
this.addChild(new TruncatedText(theme.bold(title)));
|
||||
this.addChild(new Spacer(1));
|
||||
|
||||
// Create list container
|
||||
this.listContainer = new Container();
|
||||
this.addChild(this.listContainer);
|
||||
|
||||
this.addChild(new Spacer(1));
|
||||
|
||||
// Add bottom border
|
||||
this.addChild(new DynamicBorder());
|
||||
|
||||
// Initial render
|
||||
this.updateList();
|
||||
}
|
||||
|
||||
private loadProviders(): void {
|
||||
this.allProviders = getOAuthProviders();
|
||||
}
|
||||
|
||||
private updateList(): void {
|
||||
this.listContainer.clear();
|
||||
|
||||
for (let i = 0; i < this.allProviders.length; i++) {
|
||||
const provider = this.allProviders[i];
|
||||
if (!provider) continue;
|
||||
|
||||
const isSelected = i === this.selectedIndex;
|
||||
|
||||
// Check if user is logged in for this provider
|
||||
const credentials = this.authStorage.get(provider.id);
|
||||
const isLoggedIn = credentials?.type === "oauth";
|
||||
const statusIndicator = isLoggedIn ? theme.fg("success", " ✓ logged in") : "";
|
||||
|
||||
let line = "";
|
||||
if (isSelected) {
|
||||
const prefix = theme.fg("accent", "→ ");
|
||||
const text = theme.fg("accent", provider.name);
|
||||
line = prefix + text + statusIndicator;
|
||||
} else {
|
||||
const text = ` ${provider.name}`;
|
||||
line = text + statusIndicator;
|
||||
}
|
||||
|
||||
this.listContainer.addChild(new TruncatedText(line, 0, 0));
|
||||
}
|
||||
|
||||
// Show "no providers" if empty
|
||||
if (this.allProviders.length === 0) {
|
||||
const message =
|
||||
this.mode === "login" ? "No OAuth providers available" : "No OAuth providers logged in. Use /login first.";
|
||||
this.listContainer.addChild(new TruncatedText(theme.fg("muted", ` ${message}`), 0, 0));
|
||||
}
|
||||
}
|
||||
|
||||
handleInput(keyData: string): void {
|
||||
const kb = getKeybindings();
|
||||
// Up arrow
|
||||
if (kb.matches(keyData, "tui.select.up")) {
|
||||
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
||||
this.updateList();
|
||||
}
|
||||
// Down arrow
|
||||
else if (kb.matches(keyData, "tui.select.down")) {
|
||||
this.selectedIndex = Math.min(this.allProviders.length - 1, this.selectedIndex + 1);
|
||||
this.updateList();
|
||||
}
|
||||
// Enter
|
||||
else if (kb.matches(keyData, "tui.select.confirm")) {
|
||||
const selectedProvider = this.allProviders[this.selectedIndex];
|
||||
if (selectedProvider) {
|
||||
this.onSelectCallback(selectedProvider.id);
|
||||
}
|
||||
}
|
||||
// Escape or Ctrl+C
|
||||
else if (kb.matches(keyData, "tui.select.cancel")) {
|
||||
this.onCancelCallback();
|
||||
}
|
||||
}
|
||||
}
|
||||
+341
@@ -0,0 +1,341 @@
|
||||
import type { Model } from "@mariozechner/pi-ai";
|
||||
import {
|
||||
Container,
|
||||
type Focusable,
|
||||
fuzzyFilter,
|
||||
getKeybindings,
|
||||
Input,
|
||||
Key,
|
||||
matchesKey,
|
||||
Spacer,
|
||||
Text,
|
||||
} from "@mariozechner/pi-tui";
|
||||
import { theme } from "../theme/theme.js";
|
||||
import { DynamicBorder } from "./dynamic-border.js";
|
||||
|
||||
// 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, allIds: string[], id: string, delta: number): EnabledIds {
|
||||
const list = enabledIds ?? [...allIds];
|
||||
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 = 15;
|
||||
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. Ctrl+S 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 = ["Enter toggle", "^A all", "^X clear", "^P provider", "Alt+↑↓ reorder", "^S 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) => `${i.model.id} ${i.model.provider}`) : 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;
|
||||
}
|
||||
|
||||
// Alt+Up/Down - Reorder enabled models
|
||||
if (matchesKey(data, Key.alt("up")) || matchesKey(data, Key.alt("down"))) {
|
||||
const item = this.filteredItems[this.selectedIndex];
|
||||
if (item && isEnabled(this.enabledIds, item.fullId)) {
|
||||
const delta = matchesKey(data, Key.alt("up")) ? -1 : 1;
|
||||
const enabledList = this.enabledIds ?? this.allIds;
|
||||
const currentIndex = enabledList.indexOf(item.fullId);
|
||||
const newIndex = currentIndex + delta;
|
||||
// Only move if within bounds
|
||||
if (newIndex >= 0 && newIndex < enabledList.length) {
|
||||
this.enabledIds = move(this.enabledIds, this.allIds, item.fullId, delta);
|
||||
this.isDirty = true;
|
||||
this.selectedIndex += delta;
|
||||
this.refresh();
|
||||
this.notifyChange();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Toggle on Enter
|
||||
if (matchesKey(data, Key.enter)) {
|
||||
const item = this.filteredItems[this.selectedIndex];
|
||||
if (item) {
|
||||
this.enabledIds = toggle(this.enabledIds, item.fullId);
|
||||
this.isDirty = true;
|
||||
this.refresh();
|
||||
this.notifyChange();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+A - Enable all (filtered if search active, otherwise all)
|
||||
if (matchesKey(data, Key.ctrl("a"))) {
|
||||
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;
|
||||
}
|
||||
|
||||
// Ctrl+X - Clear all (filtered if search active, otherwise all)
|
||||
if (matchesKey(data, Key.ctrl("x"))) {
|
||||
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;
|
||||
}
|
||||
|
||||
// Ctrl+P - Toggle provider of current item
|
||||
if (matchesKey(data, Key.ctrl("p"))) {
|
||||
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;
|
||||
}
|
||||
|
||||
// Ctrl+S - Save/persist to settings
|
||||
if (matchesKey(data, Key.ctrl("s"))) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
import { fuzzyMatch } from "@mariozechner/pi-tui";
|
||||
import type { SessionInfo } from "../../../core/session-manager.js";
|
||||
|
||||
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);
|
||||
}
|
||||
+1010
File diff suppressed because it is too large.
Load diff
+444
@@ -0,0 +1,444 @@
|
||||
import type { ThinkingLevel } from "@mariozechner/pi-agent-core";
|
||||
import type { Transport } from "@mariozechner/pi-ai";
|
||||
import {
|
||||
Container,
|
||||
getCapabilities,
|
||||
type SelectItem,
|
||||
SelectList,
|
||||
type SelectListLayoutOptions,
|
||||
type SettingItem,
|
||||
SettingsList,
|
||||
Spacer,
|
||||
Text,
|
||||
} from "@mariozechner/pi-tui";
|
||||
import { getSelectListTheme, getSettingsListTheme, theme } from "../theme/theme.js";
|
||||
import { DynamicBorder } from "./dynamic-border.js";
|
||||
|
||||
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: "Maximum reasoning (~32k tokens)",
|
||||
};
|
||||
|
||||
export interface SettingsConfig {
|
||||
autoCompact: boolean;
|
||||
showImages: boolean;
|
||||
autoResizeImages: boolean;
|
||||
blockImages: boolean;
|
||||
enableSkillCommands: boolean;
|
||||
steeringMode: "all" | "one-at-a-time";
|
||||
followUpMode: "all" | "one-at-a-time";
|
||||
transport: Transport;
|
||||
thinkingLevel: ThinkingLevel;
|
||||
availableThinkingLevels: ThinkingLevel[];
|
||||
currentTheme: string;
|
||||
availableThemes: string[];
|
||||
hideThinkingBlock: boolean;
|
||||
collapseChangelog: boolean;
|
||||
enableInstallTelemetry: boolean;
|
||||
doubleEscapeAction: "fork" | "tree" | "none";
|
||||
treeFilterMode: "default" | "no-tools" | "user-only" | "labeled-only" | "all";
|
||||
showHardwareCursor: boolean;
|
||||
editorPaddingX: number;
|
||||
autocompleteMaxVisible: number;
|
||||
quietStartup: boolean;
|
||||
clearOnShrink: boolean;
|
||||
}
|
||||
|
||||
export interface SettingsCallbacks {
|
||||
onAutoCompactChange: (enabled: boolean) => void;
|
||||
onShowImagesChange: (enabled: boolean) => 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;
|
||||
onThinkingLevelChange: (level: ThinkingLevel) => void;
|
||||
onThemeChange: (theme: string) => void;
|
||||
onThemePreview?: (theme: string) => void;
|
||||
onHideThinkingBlockChange: (hidden: 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;
|
||||
onAutocompleteMaxVisibleChange: (maxVisible: number) => void;
|
||||
onQuietStartupChange: (enabled: boolean) => void;
|
||||
onClearOnShrinkChange: (enabled: boolean) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A submenu component for selecting from a list of options.
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main settings selector component.
|
||||
*/
|
||||
export class SettingsSelectorComponent extends Container {
|
||||
private settingsList: SettingsList;
|
||||
|
||||
constructor(config: SettingsConfig, callbacks: SettingsCallbacks) {
|
||||
super();
|
||||
|
||||
const supportsImages = getCapabilities().images;
|
||||
|
||||
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:
|
||||
"Alt+Enter 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", "auto"],
|
||||
},
|
||||
{
|
||||
id: "hide-thinking",
|
||||
label: "Hide thinking",
|
||||
description: "Hide thinking blocks in assistant responses",
|
||||
currentValue: config.hideThinkingBlock ? "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: "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: "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 SelectSubmenu(
|
||||
"Theme",
|
||||
"Select color theme",
|
||||
config.availableThemes.map((t) => ({
|
||||
value: t,
|
||||
label: t,
|
||||
})),
|
||||
currentValue,
|
||||
(value) => {
|
||||
callbacks.onThemeChange(value);
|
||||
done(value);
|
||||
},
|
||||
() => {
|
||||
// Restore original theme on cancel
|
||||
callbacks.onThemePreview?.(currentValue);
|
||||
done();
|
||||
},
|
||||
(value) => {
|
||||
// Preview theme on selection change
|
||||
callbacks.onThemePreview?.(value);
|
||||
},
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// 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"],
|
||||
});
|
||||
}
|
||||
|
||||
// Image auto-resize toggle (always available, affects both attached and read images)
|
||||
items.splice(supportsImages ? 2 : 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"],
|
||||
});
|
||||
|
||||
// Autocomplete max visible toggle (insert after editor-padding)
|
||||
const editorPaddingIndex = items.findIndex((item) => item.id === "editor-padding");
|
||||
items.splice(editorPaddingIndex + 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"],
|
||||
});
|
||||
|
||||
// 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 "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 "hide-thinking":
|
||||
callbacks.onHideThinkingBlockChange(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 "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 "autocomplete-max-visible":
|
||||
callbacks.onAutocompleteMaxVisibleChange(parseInt(newValue, 10));
|
||||
break;
|
||||
case "clear-on-shrink":
|
||||
callbacks.onClearOnShrinkChange(newValue === "true");
|
||||
break;
|
||||
}
|
||||
},
|
||||
callbacks.onCancel,
|
||||
{ enableSearch: true },
|
||||
);
|
||||
|
||||
this.addChild(this.settingsList);
|
||||
this.addChild(new DynamicBorder());
|
||||
}
|
||||
|
||||
getSettingsList(): SettingsList {
|
||||
return this.settingsList;
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
import { Container, type SelectItem, SelectList, type SelectListLayoutOptions } from "@mariozechner/pi-tui";
|
||||
import { getSelectListTheme } from "../theme/theme.js";
|
||||
import { DynamicBorder } from "./dynamic-border.js";
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
import { Box, Markdown, type MarkdownTheme, Text } from "@mariozechner/pi-tui";
|
||||
import type { ParsedSkillBlock } from "../../../core/agent-session.js";
|
||||
import { getMarkdownTheme, theme } from "../theme/theme.js";
|
||||
import { keyText } from "./keybinding-hints.js";
|
||||
|
||||
/**
|
||||
* 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
import { Container, type SelectItem, SelectList, type SelectListLayoutOptions } from "@mariozechner/pi-tui";
|
||||
import { getAvailableThemes, getSelectListTheme } from "../theme/theme.js";
|
||||
import { DynamicBorder } from "./dynamic-border.js";
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
import type { ThinkingLevel } from "@mariozechner/pi-agent-core";
|
||||
import { Container, type SelectItem, SelectList, type SelectListLayoutOptions } from "@mariozechner/pi-tui";
|
||||
import { getSelectListTheme } from "../theme/theme.js";
|
||||
import { DynamicBorder } from "./dynamic-border.js";
|
||||
|
||||
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: "Maximum reasoning (~32k tokens)",
|
||||
};
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
+344
@@ -0,0 +1,344 @@
|
||||
import { Box, type Component, Container, getCapabilities, Image, Spacer, Text, type TUI } from "@mariozechner/pi-tui";
|
||||
import type { ToolDefinition, ToolRenderContext } from "../../../core/extensions/types.js";
|
||||
import { allToolDefinitions } from "../../../core/tools/index.js";
|
||||
import { getTextOutput as getRenderedTextOutput } from "../../../core/tools/render-utils.js";
|
||||
import { convertToPng } from "../../../utils/image-convert.js";
|
||||
import { theme } from "../theme/theme.js";
|
||||
|
||||
export interface ToolExecutionOptions {
|
||||
showImages?: boolean;
|
||||
}
|
||||
|
||||
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 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 = process.cwd(),
|
||||
) {
|
||||
super();
|
||||
this.toolName = toolName;
|
||||
this.toolCallId = toolCallId;
|
||||
this.args = args;
|
||||
this.toolDefinition = toolDefinition;
|
||||
this.builtInToolDefinition = allToolDefinitions[toolName as keyof typeof allToolDefinitions];
|
||||
this.showImages = options.showImages ?? true;
|
||||
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();
|
||||
}
|
||||
|
||||
override invalidate(): void {
|
||||
super.invalidate();
|
||||
this.updateDisplay();
|
||||
}
|
||||
|
||||
override render(width: number): string[] {
|
||||
if (this.hideComponent) {
|
||||
return [];
|
||||
}
|
||||
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: 60 },
|
||||
);
|
||||
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;
|
||||
}
|
||||
}
|
||||
+1239
File diff suppressed because it is too large.
Load diff
+143
@@ -0,0 +1,143 @@
|
||||
import { type Component, Container, getKeybindings, Spacer, Text, truncateToWidth } from "@mariozechner/pi-tui";
|
||||
import { theme } from "../theme/theme.js";
|
||||
import { DynamicBorder } from "./dynamic-border.js";
|
||||
|
||||
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[]) {
|
||||
// Store messages in chronological order (oldest to newest)
|
||||
this.messages = messages;
|
||||
// Start with the last (most recent) message selected
|
||||
this.selectedIndex = Math.max(0, messages.length - 1);
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
// No cached state to invalidate currently
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
const lines: string[] = [];
|
||||
|
||||
if (this.messages.length === 0) {
|
||||
lines.push(theme.fg("muted", " No user messages found"));
|
||||
return lines;
|
||||
}
|
||||
|
||||
// Calculate visible range with scrolling
|
||||
const startIndex = Math.max(
|
||||
0,
|
||||
Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.messages.length - this.maxVisible),
|
||||
);
|
||||
const endIndex = Math.min(startIndex + this.maxVisible, this.messages.length);
|
||||
|
||||
// Render visible messages (2 lines per message + blank line)
|
||||
for (let i = startIndex; i < endIndex; i++) {
|
||||
const message = this.messages[i];
|
||||
const isSelected = i === this.selectedIndex;
|
||||
|
||||
// Normalize message to single line
|
||||
const normalizedMessage = message.text.replace(/\n/g, " ").trim();
|
||||
|
||||
// First line: cursor + message
|
||||
const cursor = isSelected ? theme.fg("accent", "› ") : " ";
|
||||
const maxMsgWidth = width - 2; // Account for cursor (2 chars)
|
||||
const truncatedMsg = truncateToWidth(normalizedMessage, maxMsgWidth);
|
||||
const messageLine = cursor + (isSelected ? theme.bold(truncatedMsg) : truncatedMsg);
|
||||
|
||||
lines.push(messageLine);
|
||||
|
||||
// Second line: metadata (position in history)
|
||||
const position = i + 1;
|
||||
const metadata = ` Message ${position} of ${this.messages.length}`;
|
||||
const metadataLine = theme.fg("muted", metadata);
|
||||
lines.push(metadataLine);
|
||||
lines.push(""); // Blank line between messages
|
||||
}
|
||||
|
||||
// Add scroll indicator if needed
|
||||
if (startIndex > 0 || endIndex < this.messages.length) {
|
||||
const scrollInfo = theme.fg("muted", ` (${this.selectedIndex + 1}/${this.messages.length})`);
|
||||
lines.push(scrollInfo);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
handleInput(keyData: string): void {
|
||||
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) {
|
||||
super();
|
||||
|
||||
// Add header
|
||||
this.addChild(new Spacer(1));
|
||||
this.addChild(new Text(theme.bold("Branch from Message"), 1, 0));
|
||||
this.addChild(new Text(theme.fg("muted", "Select a message to create a new branch from that point"), 1, 0));
|
||||
this.addChild(new Spacer(1));
|
||||
this.addChild(new DynamicBorder());
|
||||
this.addChild(new Spacer(1));
|
||||
|
||||
// Create message list
|
||||
this.messageList = new UserMessageList(messages);
|
||||
this.messageList.onSelect = onSelect;
|
||||
this.messageList.onCancel = onCancel;
|
||||
|
||||
this.addChild(this.messageList);
|
||||
|
||||
// Add bottom border
|
||||
this.addChild(new Spacer(1));
|
||||
this.addChild(new DynamicBorder());
|
||||
|
||||
// Auto-cancel if no messages
|
||||
if (messages.length === 0) {
|
||||
setTimeout(() => onCancel(), 100);
|
||||
}
|
||||
}
|
||||
|
||||
getMessageList(): UserMessageList {
|
||||
return this.messageList;
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import { Container, Markdown, type MarkdownTheme, Spacer } from "@mariozechner/pi-tui";
|
||||
import { getMarkdownTheme, theme } from "../theme/theme.js";
|
||||
|
||||
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 {
|
||||
constructor(text: string, markdownTheme: MarkdownTheme = getMarkdownTheme()) {
|
||||
super();
|
||||
this.addChild(new Spacer(1));
|
||||
this.addChild(
|
||||
new Markdown(text, 1, 1, markdownTheme, {
|
||||
bgColor: (text: string) => theme.bg("userMessageBg", text),
|
||||
color: (text: string) => theme.fg("userMessageText", text),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
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] = lines[lines.length - 1] + OSC133_ZONE_END + OSC133_ZONE_FINAL;
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
+50
@@ -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 "@mariozechner/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,85 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
|
||||
"name": "dark",
|
||||
"vars": {
|
||||
"cyan": "#00d7ff",
|
||||
"blue": "#5f87ff",
|
||||
"green": "#b5bd68",
|
||||
"red": "#cc6666",
|
||||
"yellow": "#ffff00",
|
||||
"gray": "#808080",
|
||||
"dimGray": "#666666",
|
||||
"darkGray": "#505050",
|
||||
"accent": "#8abeb7",
|
||||
"selectedBg": "#3a3a4a",
|
||||
"userMsgBg": "#343541",
|
||||
"toolPendingBg": "#282832",
|
||||
"toolSuccessBg": "#283228",
|
||||
"toolErrorBg": "#3c2828",
|
||||
"customMsgBg": "#2d2838"
|
||||
},
|
||||
"colors": {
|
||||
"accent": "accent",
|
||||
"border": "blue",
|
||||
"borderAccent": "cyan",
|
||||
"borderMuted": "darkGray",
|
||||
"success": "green",
|
||||
"error": "red",
|
||||
"warning": "yellow",
|
||||
"muted": "gray",
|
||||
"dim": "dimGray",
|
||||
"text": "",
|
||||
"thinkingText": "gray",
|
||||
|
||||
"selectedBg": "selectedBg",
|
||||
"userMessageBg": "userMsgBg",
|
||||
"userMessageText": "",
|
||||
"customMessageBg": "customMsgBg",
|
||||
"customMessageText": "",
|
||||
"customMessageLabel": "#9575cd",
|
||||
"toolPendingBg": "toolPendingBg",
|
||||
"toolSuccessBg": "toolSuccessBg",
|
||||
"toolErrorBg": "toolErrorBg",
|
||||
"toolTitle": "",
|
||||
"toolOutput": "gray",
|
||||
|
||||
"mdHeading": "#f0c674",
|
||||
"mdLink": "#81a2be",
|
||||
"mdLinkUrl": "dimGray",
|
||||
"mdCode": "accent",
|
||||
"mdCodeBlock": "green",
|
||||
"mdCodeBlockBorder": "gray",
|
||||
"mdQuote": "gray",
|
||||
"mdQuoteBorder": "gray",
|
||||
"mdHr": "gray",
|
||||
"mdListBullet": "accent",
|
||||
|
||||
"toolDiffAdded": "green",
|
||||
"toolDiffRemoved": "red",
|
||||
"toolDiffContext": "gray",
|
||||
|
||||
"syntaxComment": "#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",
|
||||
|
||||
"bashMode": "green"
|
||||
},
|
||||
"export": {
|
||||
"pageBg": "#18181e",
|
||||
"cardBg": "#1e1e24",
|
||||
"infoBg": "#3c3728"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
|
||||
"name": "light",
|
||||
"vars": {
|
||||
"teal": "#5a8080",
|
||||
"blue": "#547da7",
|
||||
"green": "#588458",
|
||||
"red": "#aa5555",
|
||||
"yellow": "#9a7326",
|
||||
"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": "",
|
||||
"thinkingText": "mediumGray",
|
||||
|
||||
"selectedBg": "selectedBg",
|
||||
"userMessageBg": "userMsgBg",
|
||||
"userMessageText": "",
|
||||
"customMessageBg": "customMsgBg",
|
||||
"customMessageText": "",
|
||||
"customMessageLabel": "#7e57c2",
|
||||
"toolPendingBg": "toolPendingBg",
|
||||
"toolSuccessBg": "toolSuccessBg",
|
||||
"toolErrorBg": "toolErrorBg",
|
||||
"toolTitle": "",
|
||||
"toolOutput": "mediumGray",
|
||||
|
||||
"mdHeading": "yellow",
|
||||
"mdLink": "blue",
|
||||
"mdLinkUrl": "dimGray",
|
||||
"mdCode": "teal",
|
||||
"mdCodeBlock": "green",
|
||||
"mdCodeBlockBorder": "mediumGray",
|
||||
"mdQuote": "mediumGray",
|
||||
"mdQuoteBorder": "mediumGray",
|
||||
"mdHr": "mediumGray",
|
||||
"mdListBullet": "green",
|
||||
|
||||
"toolDiffAdded": "green",
|
||||
"toolDiffRemoved": "red",
|
||||
"toolDiffContext": "mediumGray",
|
||||
|
||||
"syntaxComment": "#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",
|
||||
|
||||
"bashMode": "green"
|
||||
},
|
||||
"export": {
|
||||
"pageBg": "#f8f8f8",
|
||||
"cardBg": "#ffffff",
|
||||
"infoBg": "#fffae6"
|
||||
}
|
||||
}
|
||||
+335
@@ -0,0 +1,335 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "Pi Coding Agent Theme",
|
||||
"description": "Theme schema for Pi coding agent",
|
||||
"type": "object",
|
||||
"required": ["name", "colors"],
|
||||
"properties": {
|
||||
"$schema": {
|
||||
"type": "string",
|
||||
"description": "JSON schema reference"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Theme name"
|
||||
},
|
||||
"vars": {
|
||||
"type": "object",
|
||||
"description": "Reusable color variables",
|
||||
"additionalProperties": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Hex color (#RRGGBB), variable reference, or empty string for terminal default"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 255,
|
||||
"description": "256-color palette index (0-255)"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"colors": {
|
||||
"type": "object",
|
||||
"description": "Theme color definitions (all required)",
|
||||
"required": [
|
||||
"accent",
|
||||
"border",
|
||||
"borderAccent",
|
||||
"borderMuted",
|
||||
"success",
|
||||
"error",
|
||||
"warning",
|
||||
"muted",
|
||||
"dim",
|
||||
"text",
|
||||
"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 (OpenAI codex-max only)"
|
||||
},
|
||||
"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
Reference in new issue
Block a user