mirror of
https://github.com/harivansh-afk/clanker-agent.git
synced 2026-04-18 03:00:39 +00:00
move pi-mono into companion-cloud as apps/companion-os
- Copy all pi-mono source into apps/companion-os/ - Update Dockerfile to COPY pre-built binary instead of downloading from GitHub Releases - Update deploy-staging.yml to build pi from source (bun compile) before Docker build - Add apps/companion-os/** to path triggers - No more cross-repo dispatch needed Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
0250f72976
579 changed files with 206942 additions and 0 deletions
233
packages/coding-agent/src/modes/daemon-mode.ts
Normal file
233
packages/coding-agent/src/modes/daemon-mode.ts
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
/**
|
||||
* Daemon mode (always-on background execution).
|
||||
*
|
||||
* Starts agent extensions, accepts messages from extension sources
|
||||
* (webhooks, queues, Telegram/Slack gateways, etc.), and stays alive
|
||||
* until explicitly stopped.
|
||||
*/
|
||||
|
||||
import type { ImageContent } from "@mariozechner/pi-ai";
|
||||
import type { AgentSession } from "../core/agent-session.js";
|
||||
import {
|
||||
GatewayRuntime,
|
||||
type GatewaySessionFactory,
|
||||
setActiveGatewayRuntime,
|
||||
} from "../core/gateway-runtime.js";
|
||||
import type { GatewaySettings } from "../core/settings-manager.js";
|
||||
|
||||
/**
|
||||
* Options for daemon mode.
|
||||
*/
|
||||
export interface DaemonModeOptions {
|
||||
/** First message to send at startup (can include @file content expansion by caller). */
|
||||
initialMessage?: string;
|
||||
/** Images to attach to the startup message. */
|
||||
initialImages?: ImageContent[];
|
||||
/** Additional startup messages (sent after initialMessage, one by one). */
|
||||
messages?: string[];
|
||||
/** Factory for creating additional gateway-owned sessions. */
|
||||
createSession: GatewaySessionFactory;
|
||||
/** Gateway config from settings/env. */
|
||||
gateway: GatewaySettings;
|
||||
}
|
||||
|
||||
export interface DaemonModeResult {
|
||||
reason: "shutdown";
|
||||
}
|
||||
|
||||
function createCommandContextActions(session: AgentSession) {
|
||||
return {
|
||||
waitForIdle: () => session.agent.waitForIdle(),
|
||||
newSession: async (options?: {
|
||||
parentSession?: string;
|
||||
setup?: (
|
||||
sessionManager: typeof session.sessionManager,
|
||||
) => Promise<void> | void;
|
||||
}) => {
|
||||
const success = await session.newSession({
|
||||
parentSession: options?.parentSession,
|
||||
});
|
||||
if (success && options?.setup) {
|
||||
await options.setup(session.sessionManager);
|
||||
}
|
||||
return { cancelled: !success };
|
||||
},
|
||||
fork: async (entryId: string) => {
|
||||
const result = await session.fork(entryId);
|
||||
return { cancelled: result.cancelled };
|
||||
},
|
||||
navigateTree: async (
|
||||
targetId: string,
|
||||
options?: {
|
||||
summarize?: boolean;
|
||||
customInstructions?: string;
|
||||
replaceInstructions?: boolean;
|
||||
label?: string;
|
||||
},
|
||||
) => {
|
||||
const result = await session.navigateTree(targetId, {
|
||||
summarize: options?.summarize,
|
||||
customInstructions: options?.customInstructions,
|
||||
replaceInstructions: options?.replaceInstructions,
|
||||
label: options?.label,
|
||||
});
|
||||
return { cancelled: result.cancelled };
|
||||
},
|
||||
switchSession: async (sessionPath: string) => {
|
||||
const success = await session.switchSession(sessionPath);
|
||||
return { cancelled: !success };
|
||||
},
|
||||
reload: async () => {
|
||||
await session.reload();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run in daemon mode.
|
||||
* Stays alive indefinitely unless stopped by signal or extension trigger.
|
||||
*/
|
||||
export async function runDaemonMode(
|
||||
session: AgentSession,
|
||||
options: DaemonModeOptions,
|
||||
): Promise<DaemonModeResult> {
|
||||
const { initialMessage, initialImages, messages = [] } = options;
|
||||
let isShuttingDown = false;
|
||||
let resolveReady: (result: DaemonModeResult) => void = () => {};
|
||||
const ready = new Promise<DaemonModeResult>((resolve) => {
|
||||
resolveReady = resolve;
|
||||
});
|
||||
const gatewayBind =
|
||||
process.env.PI_GATEWAY_BIND ?? options.gateway.bind ?? "127.0.0.1";
|
||||
const gatewayPort =
|
||||
Number.parseInt(process.env.PI_GATEWAY_PORT ?? "", 10) ||
|
||||
options.gateway.port ||
|
||||
8787;
|
||||
const gatewayToken =
|
||||
process.env.PI_GATEWAY_TOKEN ?? options.gateway.bearerToken;
|
||||
const gateway = new GatewayRuntime({
|
||||
config: {
|
||||
bind: gatewayBind,
|
||||
port: gatewayPort,
|
||||
bearerToken: gatewayToken,
|
||||
session: {
|
||||
idleMinutes: options.gateway.session?.idleMinutes ?? 60,
|
||||
maxQueuePerSession: options.gateway.session?.maxQueuePerSession ?? 8,
|
||||
},
|
||||
webhook: {
|
||||
enabled: options.gateway.webhook?.enabled ?? true,
|
||||
basePath: options.gateway.webhook?.basePath ?? "/webhooks",
|
||||
secret:
|
||||
process.env.PI_GATEWAY_WEBHOOK_SECRET ??
|
||||
options.gateway.webhook?.secret,
|
||||
},
|
||||
},
|
||||
primarySessionKey: "web:main",
|
||||
primarySession: session,
|
||||
createSession: options.createSession,
|
||||
log: (message) => {
|
||||
console.error(`[pi-gateway] ${message}`);
|
||||
},
|
||||
});
|
||||
setActiveGatewayRuntime(gateway);
|
||||
|
||||
const shutdown = async (reason: "signal" | "extension"): Promise<void> => {
|
||||
if (isShuttingDown) return;
|
||||
isShuttingDown = true;
|
||||
|
||||
console.error(`[pi-gateway] shutdown requested: ${reason}`);
|
||||
setActiveGatewayRuntime(null);
|
||||
await gateway.stop();
|
||||
|
||||
const runner = session.extensionRunner;
|
||||
if (runner?.hasHandlers("session_shutdown")) {
|
||||
await runner.emit({ type: "session_shutdown" });
|
||||
}
|
||||
|
||||
session.dispose();
|
||||
resolveReady({ reason: "shutdown" });
|
||||
};
|
||||
|
||||
const handleShutdownSignal = (signal: NodeJS.Signals) => {
|
||||
void shutdown("signal").catch((error) => {
|
||||
console.error(
|
||||
`[pi-gateway] shutdown failed for ${signal}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
resolveReady({ reason: "shutdown" });
|
||||
});
|
||||
};
|
||||
const sigintHandler = () => handleShutdownSignal("SIGINT");
|
||||
const sigtermHandler = () => handleShutdownSignal("SIGTERM");
|
||||
const sigquitHandler = () => handleShutdownSignal("SIGQUIT");
|
||||
const sighupHandler = () => handleShutdownSignal("SIGHUP");
|
||||
const unhandledRejectionHandler = (error: unknown) => {
|
||||
console.error(
|
||||
`[pi-gateway] unhandled rejection: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
};
|
||||
|
||||
process.once("SIGINT", sigintHandler);
|
||||
process.once("SIGTERM", sigtermHandler);
|
||||
process.once("SIGQUIT", sigquitHandler);
|
||||
process.once("SIGHUP", sighupHandler);
|
||||
process.on("unhandledRejection", unhandledRejectionHandler);
|
||||
|
||||
await session.bindExtensions({
|
||||
commandContextActions: createCommandContextActions(session),
|
||||
shutdownHandler: () => {
|
||||
void shutdown("extension").catch((error) => {
|
||||
console.error(
|
||||
`[pi-gateway] extension shutdown failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
resolveReady({ reason: "shutdown" });
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
console.error(`Extension error (${err.extensionPath}): ${err.error}`);
|
||||
},
|
||||
});
|
||||
|
||||
// Emit structured events to stderr for supervisor logs.
|
||||
session.subscribe((event) => {
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
type: event.type,
|
||||
sessionId: session.sessionId,
|
||||
messageCount: session.messages.length,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// Startup probes/messages.
|
||||
if (initialMessage) {
|
||||
await session.prompt(initialMessage, { images: initialImages });
|
||||
}
|
||||
for (const message of messages) {
|
||||
await session.prompt(message);
|
||||
}
|
||||
|
||||
await gateway.start();
|
||||
console.error(
|
||||
`[pi-gateway] startup complete (session=${session.sessionId ?? "unknown"}, bind=${gatewayBind}, port=${gatewayPort})`,
|
||||
);
|
||||
|
||||
// Keep process alive forever.
|
||||
const keepAlive = setInterval(() => {
|
||||
// Intentionally keep the daemon event loop active.
|
||||
}, 1000);
|
||||
|
||||
const cleanup = () => {
|
||||
clearInterval(keepAlive);
|
||||
process.removeListener("SIGINT", sigintHandler);
|
||||
process.removeListener("SIGTERM", sigtermHandler);
|
||||
process.removeListener("SIGQUIT", sigquitHandler);
|
||||
process.removeListener("SIGHUP", sighupHandler);
|
||||
process.removeListener("unhandledRejection", unhandledRejectionHandler);
|
||||
};
|
||||
|
||||
try {
|
||||
return await ready;
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
26
packages/coding-agent/src/modes/index.ts
Normal file
26
packages/coding-agent/src/modes/index.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
/**
|
||||
* Run modes for the coding agent.
|
||||
*/
|
||||
|
||||
export {
|
||||
type DaemonModeOptions,
|
||||
type DaemonModeResult,
|
||||
runDaemonMode,
|
||||
} from "./daemon-mode.js";
|
||||
export {
|
||||
InteractiveMode,
|
||||
type InteractiveModeOptions,
|
||||
} from "./interactive/interactive-mode.js";
|
||||
export { type PrintModeOptions, runPrintMode } from "./print-mode.js";
|
||||
export {
|
||||
type ModelInfo,
|
||||
RpcClient,
|
||||
type RpcClientOptions,
|
||||
type RpcEventListener,
|
||||
} from "./rpc/rpc-client.js";
|
||||
export { runRpcMode } from "./rpc/rpc-mode.js";
|
||||
export type {
|
||||
RpcCommand,
|
||||
RpcResponse,
|
||||
RpcSessionState,
|
||||
} from "./rpc/rpc-types.js";
|
||||
422
packages/coding-agent/src/modes/interactive/components/armin.ts
Normal file
422
packages/coding-agent/src/modes/interactive/components/armin.ts
Normal file
|
|
@ -0,0 +1,422 @@
|
|||
/**
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
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 lastMessage?: AssistantMessage;
|
||||
|
||||
constructor(
|
||||
message?: AssistantMessage,
|
||||
hideThinkingBlock = false,
|
||||
markdownTheme: MarkdownTheme = getMarkdownTheme(),
|
||||
) {
|
||||
super();
|
||||
|
||||
this.hideThinkingBlock = hideThinkingBlock;
|
||||
this.markdownTheme = markdownTheme;
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
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", "Thinking...")),
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,241 @@
|
|||
/**
|
||||
* 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 { editorKey, keyHint } 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;
|
||||
private ui: TUI;
|
||||
|
||||
constructor(command: string, ui: TUI, excludeFromContext = false) {
|
||||
super();
|
||||
this.command = command;
|
||||
this.ui = ui;
|
||||
|
||||
// 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... (${editorKey("selectCancel")} 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
|
||||
const styledOutput = previewLogicalLines
|
||||
.map((line) => theme.fg("muted", line))
|
||||
.join("\n");
|
||||
const { visualLines } = truncateToVisualLines(
|
||||
`\n${styledOutput}`,
|
||||
PREVIEW_LINES,
|
||||
this.ui.terminal.columns,
|
||||
1, // padding
|
||||
);
|
||||
this.contentContainer.addChild({
|
||||
render: () => visualLines,
|
||||
invalidate: () => {},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 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("expandTools", "to collapse")})`);
|
||||
} else {
|
||||
statusParts.push(
|
||||
`${theme.fg("muted", `... ${hiddenLineCount} more lines`)} (${keyHint("expandTools", "to expand")})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.status === "cancelled") {
|
||||
statusParts.push(theme.fg("warning", "(cancelled)"));
|
||||
} else if (this.status === "error") {
|
||||
statusParts.push(theme.fg("error", `(exit ${this.exitCode})`));
|
||||
}
|
||||
|
||||
// Add truncation warning (context truncation, not preview truncation)
|
||||
const wasTruncated =
|
||||
this.truncationResult?.truncated || contextTruncation.truncated;
|
||||
if (wasTruncated && this.fullOutputPath) {
|
||||
statusParts.push(
|
||||
theme.fg(
|
||||
"warning",
|
||||
`Output truncated. Full output: ${this.fullOutputPath}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (statusParts.length > 0) {
|
||||
this.contentContainer.addChild(
|
||||
new Text(`\n${statusParts.join("\n")}`, 1, 0),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the raw output for creating BashExecutionMessage.
|
||||
*/
|
||||
getOutput(): string {
|
||||
return this.outputLines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the command that was executed.
|
||||
*/
|
||||
getCommand(): string {
|
||||
return this.command;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
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("selectCancel", "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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
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 { editorKey } 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", editorKey("expandTools")) +
|
||||
theme.fg("customMessageText", " to expand)"),
|
||||
0,
|
||||
0,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
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 { editorKey } 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", editorKey("expandTools")) +
|
||||
theme.fg("customMessageText", " to expand)"),
|
||||
0,
|
||||
0,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,669 @@
|
|||
/**
|
||||
* TUI component for managing package resources (enable/disable)
|
||||
*/
|
||||
|
||||
import { basename, dirname, join, relative } from "node:path";
|
||||
import {
|
||||
type Component,
|
||||
Container,
|
||||
type Focusable,
|
||||
getEditorKeybindings,
|
||||
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 = getEditorKeybindings();
|
||||
|
||||
if (kb.matches(data, "selectUp")) {
|
||||
this.selectedIndex = this.findNextItem(this.selectedIndex, -1);
|
||||
return;
|
||||
}
|
||||
if (kb.matches(data, "selectDown")) {
|
||||
this.selectedIndex = this.findNextItem(this.selectedIndex, 1);
|
||||
return;
|
||||
}
|
||||
if (kb.matches(data, "selectPageUp")) {
|
||||
// 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, "selectPageDown")) {
|
||||
// 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, "selectCancel")) {
|
||||
this.onCancel?.();
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, "ctrl+c")) {
|
||||
this.onExit?.();
|
||||
return;
|
||||
}
|
||||
if (data === " " || kb.matches(data, "selectConfirm")) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
import {
|
||||
Editor,
|
||||
type EditorOptions,
|
||||
type EditorTheme,
|
||||
type TUI,
|
||||
} from "@mariozechner/pi-tui";
|
||||
import type {
|
||||
AppAction,
|
||||
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<AppAction, () => 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: AppAction, 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, "pasteImage")) {
|
||||
this.onPasteImage?.();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check app keybindings first
|
||||
|
||||
// Escape/interrupt - only if autocomplete is NOT active
|
||||
if (this.keybindings.matches(data, "interrupt")) {
|
||||
if (!this.isShowingAutocomplete()) {
|
||||
// Use dynamic onEscape if set, otherwise registered handler
|
||||
const handler = this.onEscape ?? this.actionHandlers.get("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, "exit")) {
|
||||
if (this.getText().length === 0) {
|
||||
const handler = this.onCtrlD ?? this.actionHandlers.get("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 !== "interrupt" &&
|
||||
action !== "exit" &&
|
||||
this.keybindings.matches(data, action)
|
||||
) {
|
||||
handler();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Pass to parent for editor handling
|
||||
super.handleInput(data);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
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),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
179
packages/coding-agent/src/modes/interactive/components/diff.ts
Normal file
179
packages/coding-agent/src/modes/interactive/components/diff.ts
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
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");
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
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)))];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
/**
|
||||
* 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,
|
||||
getEditorKeybindings,
|
||||
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 { appKeyHint, 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("selectConfirm", "submit") +
|
||||
" " +
|
||||
keyHint("newLine", "newline") +
|
||||
" " +
|
||||
keyHint("selectCancel", "cancel") +
|
||||
(hasExternalEditor
|
||||
? ` ${appKeyHint(this.keybindings, "externalEditor", "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 = getEditorKeybindings();
|
||||
// Escape or Ctrl+C to cancel
|
||||
if (kb.matches(keyData, "selectCancel")) {
|
||||
this.onCancelCallback();
|
||||
return;
|
||||
}
|
||||
|
||||
// External editor (app keybinding)
|
||||
if (this.keybindings.matches(keyData, "externalEditor")) {
|
||||
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",
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
/**
|
||||
* Simple text input component for extensions.
|
||||
*/
|
||||
|
||||
import {
|
||||
Container,
|
||||
type Focusable,
|
||||
getEditorKeybindings,
|
||||
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("selectConfirm", "submit")} ${keyHint("selectCancel", "cancel")}`,
|
||||
1,
|
||||
0,
|
||||
),
|
||||
);
|
||||
this.addChild(new Spacer(1));
|
||||
this.addChild(new DynamicBorder());
|
||||
}
|
||||
|
||||
handleInput(keyData: string): void {
|
||||
const kb = getEditorKeybindings();
|
||||
if (kb.matches(keyData, "selectConfirm") || keyData === "\n") {
|
||||
this.onSubmitCallback(this.input.getValue());
|
||||
} else if (kb.matches(keyData, "selectCancel")) {
|
||||
this.onCancelCallback();
|
||||
} else {
|
||||
this.input.handleInput(keyData);
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.countdown?.dispose();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
/**
|
||||
* Generic selector component for extensions.
|
||||
* Displays a list of string options with keyboard navigation.
|
||||
*/
|
||||
|
||||
import {
|
||||
Container,
|
||||
getEditorKeybindings,
|
||||
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("selectConfirm", "select") +
|
||||
" " +
|
||||
keyHint("selectCancel", "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 = getEditorKeybindings();
|
||||
if (kb.matches(keyData, "selectUp") || keyData === "k") {
|
||||
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
||||
this.updateList();
|
||||
} else if (kb.matches(keyData, "selectDown") || keyData === "j") {
|
||||
this.selectedIndex = Math.min(
|
||||
this.options.length - 1,
|
||||
this.selectedIndex + 1,
|
||||
);
|
||||
this.updateList();
|
||||
} else if (kb.matches(keyData, "selectConfirm") || keyData === "\n") {
|
||||
const selected = this.options[this.selectedIndex];
|
||||
if (selected) this.onSelectCallback(selected);
|
||||
} else if (kb.matches(keyData, "selectCancel")) {
|
||||
this.onCancelCallback();
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.countdown?.dispose();
|
||||
}
|
||||
}
|
||||
236
packages/coding-agent/src/modes/interactive/components/footer.ts
Normal file
236
packages/coding-agent/src/modes/interactive/components/footer.ts
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
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,
|
||||
) {}
|
||||
|
||||
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 = process.cwd();
|
||||
const home = process.env.HOME || process.env.USERPROFILE;
|
||||
if (home && pwd.startsWith(home)) {
|
||||
pwd = `~${pwd.slice(home.length)}`;
|
||||
}
|
||||
|
||||
// Add git branch if available
|
||||
const branch = this.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,52 @@
|
|||
// 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 {
|
||||
appKey,
|
||||
appKeyHint,
|
||||
editorKey,
|
||||
keyHint,
|
||||
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";
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
/**
|
||||
* Utilities for formatting keybinding hints in the UI.
|
||||
*/
|
||||
|
||||
import {
|
||||
type EditorAction,
|
||||
getEditorKeybindings,
|
||||
type KeyId,
|
||||
} from "@mariozechner/pi-tui";
|
||||
import type {
|
||||
AppAction,
|
||||
KeybindingsManager,
|
||||
} from "../../../core/keybindings.js";
|
||||
import { theme } from "../theme/theme.js";
|
||||
|
||||
/**
|
||||
* Format keys array as display string (e.g., ["ctrl+c", "escape"] -> "ctrl+c/escape").
|
||||
*/
|
||||
function formatKeys(keys: KeyId[]): string {
|
||||
if (keys.length === 0) return "";
|
||||
if (keys.length === 1) return keys[0]!;
|
||||
return keys.join("/");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display string for an editor action.
|
||||
*/
|
||||
export function editorKey(action: EditorAction): string {
|
||||
return formatKeys(getEditorKeybindings().getKeys(action));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display string for an app action.
|
||||
*/
|
||||
export function appKey(
|
||||
keybindings: KeybindingsManager,
|
||||
action: AppAction,
|
||||
): string {
|
||||
return formatKeys(keybindings.getKeys(action));
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a keybinding hint with consistent styling: dim key, muted description.
|
||||
* Looks up the key from editor keybindings automatically.
|
||||
*
|
||||
* @param action - Editor action name (e.g., "selectConfirm", "expandTools")
|
||||
* @param description - Description text (e.g., "to expand", "cancel")
|
||||
* @returns Formatted string with dim key and muted description
|
||||
*/
|
||||
export function keyHint(action: EditorAction, description: string): string {
|
||||
return (
|
||||
theme.fg("dim", editorKey(action)) + theme.fg("muted", ` ${description}`)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a keybinding hint for app-level actions.
|
||||
* Requires the KeybindingsManager instance.
|
||||
*
|
||||
* @param keybindings - KeybindingsManager instance
|
||||
* @param action - App action name (e.g., "interrupt", "externalEditor")
|
||||
* @param description - Description text
|
||||
* @returns Formatted string with dim key and muted description
|
||||
*/
|
||||
export function appKeyHint(
|
||||
keybindings: KeybindingsManager,
|
||||
action: AppAction,
|
||||
description: string,
|
||||
): string {
|
||||
return (
|
||||
theme.fg("dim", appKey(keybindings, action)) +
|
||||
theme.fg("muted", ` ${description}`)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a raw key string with description (for non-configurable keys like ↑↓).
|
||||
*
|
||||
* @param key - Raw key string
|
||||
* @param description - Description text
|
||||
* @returns Formatted string with dim key and muted description
|
||||
*/
|
||||
export function rawKeyHint(key: string, description: string): string {
|
||||
return theme.fg("dim", key) + theme.fg("muted", ` ${description}`);
|
||||
}
|
||||
|
|
@ -0,0 +1,204 @@
|
|||
import { getOAuthProviders } from "@mariozechner/pi-ai/oauth";
|
||||
import {
|
||||
Container,
|
||||
type Focusable,
|
||||
getEditorKeybindings,
|
||||
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("selectCancel", "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("selectCancel", "to cancel,")} ${keyHint("selectConfirm", "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("selectCancel", "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 = getEditorKeybindings();
|
||||
|
||||
if (kb.matches(data, "selectCancel")) {
|
||||
this.cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
// Pass to input
|
||||
this.input.handleInput(data);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,372 @@
|
|||
import { type Model, modelsAreEqual } from "@mariozechner/pi-ai";
|
||||
import {
|
||||
Container,
|
||||
type Focusable,
|
||||
fuzzyFilter,
|
||||
getEditorKeybindings,
|
||||
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.scopedModelItems = this.sortModels(
|
||||
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;
|
||||
this.selectedIndex = 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("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;
|
||||
this.selectedIndex = 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}`,
|
||||
)
|
||||
: 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 = getEditorKeybindings();
|
||||
if (kb.matches(keyData, "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, "selectUp")) {
|
||||
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, "selectDown")) {
|
||||
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, "selectConfirm")) {
|
||||
const selectedModel = this.filteredModels[this.selectedIndex];
|
||||
if (selectedModel) {
|
||||
this.handleSelect(selectedModel.model);
|
||||
}
|
||||
}
|
||||
// Escape or Ctrl+C
|
||||
else if (kb.matches(keyData, "selectCancel")) {
|
||||
this.onCancelCallback();
|
||||
}
|
||||
// Pass everything else to search input
|
||||
else {
|
||||
this.searchInput.handleInput(keyData);
|
||||
this.filterModels(this.searchInput.getValue());
|
||||
}
|
||||
}
|
||||
|
||||
private handleSelect(model: Model<any>): void {
|
||||
// Save as new default
|
||||
this.settingsManager.setDefaultModelAndProvider(model.provider, model.id);
|
||||
this.onSelectCallback(model);
|
||||
}
|
||||
|
||||
getSearchInput(): Input {
|
||||
return this.searchInput;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
import type { OAuthProviderInterface } from "@mariozechner/pi-ai";
|
||||
import { getOAuthProviders } from "@mariozechner/pi-ai/oauth";
|
||||
import {
|
||||
Container,
|
||||
getEditorKeybindings,
|
||||
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 = getEditorKeybindings();
|
||||
// Up arrow
|
||||
if (kb.matches(keyData, "selectUp")) {
|
||||
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
||||
this.updateList();
|
||||
}
|
||||
// Down arrow
|
||||
else if (kb.matches(keyData, "selectDown")) {
|
||||
this.selectedIndex = Math.min(
|
||||
this.allProviders.length - 1,
|
||||
this.selectedIndex + 1,
|
||||
);
|
||||
this.updateList();
|
||||
}
|
||||
// Enter
|
||||
else if (kb.matches(keyData, "selectConfirm")) {
|
||||
const selectedProvider = this.allProviders[this.selectedIndex];
|
||||
if (selectedProvider) {
|
||||
this.onSelectCallback(selectedProvider.id);
|
||||
}
|
||||
}
|
||||
// Escape or Ctrl+C
|
||||
else if (kb.matches(keyData, "selectCancel")) {
|
||||
this.onCancelCallback();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,444 @@
|
|||
import type { Model } from "@mariozechner/pi-ai";
|
||||
import {
|
||||
Container,
|
||||
type Focusable,
|
||||
fuzzyFilter,
|
||||
getEditorKeybindings,
|
||||
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: Set<string>;
|
||||
/** true if enabledModels setting is defined (empty = all enabled) */
|
||||
hasEnabledModelsFilter: boolean;
|
||||
}
|
||||
|
||||
export interface ModelsCallbacks {
|
||||
/** Called when a model is toggled (session-only, no persist) */
|
||||
onModelToggle: (modelId: string, enabled: boolean) => void;
|
||||
/** Called when user wants to persist current selection to settings */
|
||||
onPersist: (enabledModelIds: string[]) => void;
|
||||
/** Called when user enables all models. Returns list of all model IDs. */
|
||||
onEnableAll: (allModelIds: string[]) => void;
|
||||
/** Called when user clears all models */
|
||||
onClearAll: () => void;
|
||||
/** Called when user toggles all models for a provider. Returns affected model IDs. */
|
||||
onToggleProvider: (
|
||||
provider: string,
|
||||
modelIds: string[],
|
||||
enabled: boolean,
|
||||
) => 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.hasEnabledModelsFilter
|
||||
? [...config.enabledModelIds]
|
||||
: null;
|
||||
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 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 = getEditorKeybindings();
|
||||
|
||||
// Navigation
|
||||
if (kb.matches(data, "selectUp")) {
|
||||
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, "selectDown")) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Toggle on Enter
|
||||
if (matchesKey(data, Key.enter)) {
|
||||
const item = this.filteredItems[this.selectedIndex];
|
||||
if (item) {
|
||||
const wasAllEnabled = this.enabledIds === null;
|
||||
this.enabledIds = toggle(this.enabledIds, item.fullId);
|
||||
this.isDirty = true;
|
||||
if (wasAllEnabled) this.callbacks.onClearAll();
|
||||
this.callbacks.onModelToggle(
|
||||
item.fullId,
|
||||
isEnabled(this.enabledIds, item.fullId),
|
||||
);
|
||||
this.refresh();
|
||||
}
|
||||
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.callbacks.onEnableAll(targetIds ?? this.allIds);
|
||||
this.refresh();
|
||||
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.callbacks.onClearAll();
|
||||
this.refresh();
|
||||
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.callbacks.onToggleProvider(provider, providerIds, !allEnabled);
|
||||
this.refresh();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+S - Save/persist to settings
|
||||
if (matchesKey(data, Key.ctrl("s"))) {
|
||||
this.callbacks.onPersist(this.enabledIds ?? [...this.allIds]);
|
||||
this.isDirty = false;
|
||||
this.footerText.setText(this.getFooterText());
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+C - clear search or cancel if empty
|
||||
if (matchesKey(data, Key.ctrl("c"))) {
|
||||
if (this.searchInput.getValue()) {
|
||||
this.searchInput.setValue("");
|
||||
this.refresh();
|
||||
} else {
|
||||
this.callbacks.onCancel();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Escape - cancel
|
||||
if (matchesKey(data, Key.escape)) {
|
||||
this.callbacks.onCancel();
|
||||
return;
|
||||
}
|
||||
|
||||
// Pass everything else to search input
|
||||
this.searchInput.handleInput(data);
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
getSearchInput(): Input {
|
||||
return this.searchInput;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,199 @@
|
|||
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);
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,453 @@
|
|||
import type { ThinkingLevel } from "@mariozechner/pi-agent-core";
|
||||
import type { Transport } from "@mariozechner/pi-ai";
|
||||
import {
|
||||
Container,
|
||||
getCapabilities,
|
||||
type SelectItem,
|
||||
SelectList,
|
||||
type SettingItem,
|
||||
SettingsList,
|
||||
Spacer,
|
||||
Text,
|
||||
} from "@mariozechner/pi-tui";
|
||||
import {
|
||||
getSelectListTheme,
|
||||
getSettingsListTheme,
|
||||
theme,
|
||||
} from "../theme/theme.js";
|
||||
import { DynamicBorder } from "./dynamic-border.js";
|
||||
|
||||
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;
|
||||
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;
|
||||
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(),
|
||||
);
|
||||
|
||||
// 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: "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 "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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
import { Container, type SelectItem, SelectList } from "@mariozechner/pi-tui";
|
||||
import { getSelectListTheme } from "../theme/theme.js";
|
||||
import { DynamicBorder } from "./dynamic-border.js";
|
||||
|
||||
/**
|
||||
* Component that renders a 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());
|
||||
|
||||
// Preselect current value
|
||||
this.selectList.setSelectedIndex(currentValue ? 0 : 1);
|
||||
|
||||
this.selectList.onSelect = (item) => {
|
||||
onSelect(item.value === "yes");
|
||||
};
|
||||
|
||||
this.selectList.onCancel = () => {
|
||||
onCancel();
|
||||
};
|
||||
|
||||
this.addChild(this.selectList);
|
||||
|
||||
// Add bottom border
|
||||
this.addChild(new DynamicBorder());
|
||||
}
|
||||
|
||||
getSelectList(): SelectList {
|
||||
return this.selectList;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
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 { editorKey } 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", ` (${editorKey("expandTools")} to expand)`);
|
||||
this.addChild(new Text(line, 0, 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import { Container, type SelectItem, SelectList } from "@mariozechner/pi-tui";
|
||||
import { getAvailableThemes, getSelectListTheme } from "../theme/theme.js";
|
||||
import { DynamicBorder } from "./dynamic-border.js";
|
||||
|
||||
/**
|
||||
* Component that renders a theme selector
|
||||
*/
|
||||
export class ThemeSelectorComponent extends Container {
|
||||
private selectList: SelectList;
|
||||
private onPreview: (themeName: string) => void;
|
||||
|
||||
constructor(
|
||||
currentTheme: string,
|
||||
onSelect: (themeName: string) => void,
|
||||
onCancel: () => void,
|
||||
onPreview: (themeName: string) => void,
|
||||
) {
|
||||
super();
|
||||
this.onPreview = onPreview;
|
||||
|
||||
// Get available themes and create select items
|
||||
const themes = getAvailableThemes();
|
||||
const themeItems: SelectItem[] = themes.map((name) => ({
|
||||
value: name,
|
||||
label: name,
|
||||
description: name === currentTheme ? "(current)" : undefined,
|
||||
}));
|
||||
|
||||
// Add top border
|
||||
this.addChild(new DynamicBorder());
|
||||
|
||||
// Create selector
|
||||
this.selectList = new SelectList(themeItems, 10, getSelectListTheme());
|
||||
|
||||
// Preselect current theme
|
||||
const currentIndex = themes.indexOf(currentTheme);
|
||||
if (currentIndex !== -1) {
|
||||
this.selectList.setSelectedIndex(currentIndex);
|
||||
}
|
||||
|
||||
this.selectList.onSelect = (item) => {
|
||||
onSelect(item.value);
|
||||
};
|
||||
|
||||
this.selectList.onCancel = () => {
|
||||
onCancel();
|
||||
};
|
||||
|
||||
this.selectList.onSelectionChange = (item) => {
|
||||
this.onPreview(item.value);
|
||||
};
|
||||
|
||||
this.addChild(this.selectList);
|
||||
|
||||
// Add bottom border
|
||||
this.addChild(new DynamicBorder());
|
||||
}
|
||||
|
||||
getSelectList(): SelectList {
|
||||
return this.selectList;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
import type { ThinkingLevel } from "@mariozechner/pi-agent-core";
|
||||
import { Container, type SelectItem, SelectList } from "@mariozechner/pi-tui";
|
||||
import { getSelectListTheme } from "../theme/theme.js";
|
||||
import { DynamicBorder } from "./dynamic-border.js";
|
||||
|
||||
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(),
|
||||
);
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,179 @@
|
|||
import {
|
||||
type Component,
|
||||
Container,
|
||||
getEditorKeybindings,
|
||||
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 = getEditorKeybindings();
|
||||
// Up arrow - go to previous (older) message, wrap to bottom when at top
|
||||
if (kb.matches(keyData, "selectUp")) {
|
||||
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, "selectDown")) {
|
||||
this.selectedIndex =
|
||||
this.selectedIndex === this.messages.length - 1
|
||||
? 0
|
||||
: this.selectedIndex + 1;
|
||||
}
|
||||
// Enter - select message and branch
|
||||
else if (kb.matches(keyData, "selectConfirm")) {
|
||||
const selected = this.messages[this.selectedIndex];
|
||||
if (selected && this.onSelect) {
|
||||
this.onSelect(selected.id);
|
||||
}
|
||||
}
|
||||
// Escape - cancel
|
||||
else if (kb.matches(keyData, "selectCancel")) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
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";
|
||||
|
||||
/**
|
||||
* 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;
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
/**
|
||||
* Shared utility for truncating text to visual lines (accounting for line wrapping).
|
||||
* Used by both tool-execution.ts and bash-execution.ts for consistent behavior.
|
||||
*/
|
||||
|
||||
import { Text } from "@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 };
|
||||
}
|
||||
4946
packages/coding-agent/src/modes/interactive/interactive-mode.ts
Normal file
4946
packages/coding-agent/src/modes/interactive/interactive-mode.ts
Normal file
File diff suppressed because it is too large
Load diff
85
packages/coding-agent/src/modes/interactive/theme/dark.json
Normal file
85
packages/coding-agent/src/modes/interactive/theme/dark.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
84
packages/coding-agent/src/modes/interactive/theme/light.json
Normal file
84
packages/coding-agent/src/modes/interactive/theme/light.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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)"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
1157
packages/coding-agent/src/modes/interactive/theme/theme.ts
Normal file
1157
packages/coding-agent/src/modes/interactive/theme/theme.ts
Normal file
File diff suppressed because it is too large
Load diff
134
packages/coding-agent/src/modes/print-mode.ts
Normal file
134
packages/coding-agent/src/modes/print-mode.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
/**
|
||||
* Print mode (single-shot): Send prompts, output result, exit.
|
||||
*
|
||||
* Used for:
|
||||
* - `pi -p "prompt"` - text output
|
||||
* - `pi --mode json "prompt"` - JSON event stream
|
||||
*/
|
||||
|
||||
import type { AssistantMessage, ImageContent } from "@mariozechner/pi-ai";
|
||||
import type { AgentSession } from "../core/agent-session.js";
|
||||
|
||||
/**
|
||||
* Options for print mode.
|
||||
*/
|
||||
export interface PrintModeOptions {
|
||||
/** Output mode: "text" for final response only, "json" for all events */
|
||||
mode: "text" | "json";
|
||||
/** Array of additional prompts to send after initialMessage */
|
||||
messages?: string[];
|
||||
/** First message to send (may contain @file content) */
|
||||
initialMessage?: string;
|
||||
/** Images to attach to the initial message */
|
||||
initialImages?: ImageContent[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Run in print (single-shot) mode.
|
||||
* Sends prompts to the agent and outputs the result.
|
||||
*/
|
||||
export async function runPrintMode(
|
||||
session: AgentSession,
|
||||
options: PrintModeOptions,
|
||||
): Promise<void> {
|
||||
const { mode, messages = [], initialMessage, initialImages } = options;
|
||||
if (mode === "json") {
|
||||
const header = session.sessionManager.getHeader();
|
||||
if (header) {
|
||||
console.log(JSON.stringify(header));
|
||||
}
|
||||
}
|
||||
// Set up extensions for print mode (no UI)
|
||||
await session.bindExtensions({
|
||||
commandContextActions: {
|
||||
waitForIdle: () => session.agent.waitForIdle(),
|
||||
newSession: async (options) => {
|
||||
const success = await session.newSession({
|
||||
parentSession: options?.parentSession,
|
||||
});
|
||||
if (success && options?.setup) {
|
||||
await options.setup(session.sessionManager);
|
||||
}
|
||||
return { cancelled: !success };
|
||||
},
|
||||
fork: async (entryId) => {
|
||||
const result = await session.fork(entryId);
|
||||
return { cancelled: result.cancelled };
|
||||
},
|
||||
navigateTree: async (targetId, options) => {
|
||||
const result = await session.navigateTree(targetId, {
|
||||
summarize: options?.summarize,
|
||||
customInstructions: options?.customInstructions,
|
||||
replaceInstructions: options?.replaceInstructions,
|
||||
label: options?.label,
|
||||
});
|
||||
return { cancelled: result.cancelled };
|
||||
},
|
||||
switchSession: async (sessionPath) => {
|
||||
const success = await session.switchSession(sessionPath);
|
||||
return { cancelled: !success };
|
||||
},
|
||||
reload: async () => {
|
||||
await session.reload();
|
||||
},
|
||||
},
|
||||
onError: (err) => {
|
||||
console.error(`Extension error (${err.extensionPath}): ${err.error}`);
|
||||
},
|
||||
});
|
||||
|
||||
// Always subscribe to enable session persistence via _handleAgentEvent
|
||||
session.subscribe((event) => {
|
||||
// In JSON mode, output all events
|
||||
if (mode === "json") {
|
||||
console.log(JSON.stringify(event));
|
||||
}
|
||||
});
|
||||
|
||||
// Send initial message with attachments
|
||||
if (initialMessage) {
|
||||
await session.prompt(initialMessage, { images: initialImages });
|
||||
}
|
||||
|
||||
// Send remaining messages
|
||||
for (const message of messages) {
|
||||
await session.prompt(message);
|
||||
}
|
||||
|
||||
// In text mode, output final response
|
||||
if (mode === "text") {
|
||||
const state = session.state;
|
||||
const lastMessage = state.messages[state.messages.length - 1];
|
||||
|
||||
if (lastMessage?.role === "assistant") {
|
||||
const assistantMsg = lastMessage as AssistantMessage;
|
||||
|
||||
// Check for error/aborted
|
||||
if (
|
||||
assistantMsg.stopReason === "error" ||
|
||||
assistantMsg.stopReason === "aborted"
|
||||
) {
|
||||
console.error(
|
||||
assistantMsg.errorMessage || `Request ${assistantMsg.stopReason}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Output text content
|
||||
for (const content of assistantMsg.content) {
|
||||
if (content.type === "text") {
|
||||
console.log(content.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure stdout is fully flushed before returning
|
||||
// This prevents race conditions where the process exits before all output is written
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
process.stdout.write("", (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
552
packages/coding-agent/src/modes/rpc/rpc-client.ts
Normal file
552
packages/coding-agent/src/modes/rpc/rpc-client.ts
Normal file
|
|
@ -0,0 +1,552 @@
|
|||
/**
|
||||
* RPC Client for programmatic access to the coding agent.
|
||||
*
|
||||
* Spawns the agent in RPC mode and provides a typed API for all operations.
|
||||
*/
|
||||
|
||||
import { type ChildProcess, spawn } from "node:child_process";
|
||||
import * as readline from "node:readline";
|
||||
import type {
|
||||
AgentEvent,
|
||||
AgentMessage,
|
||||
ThinkingLevel,
|
||||
} from "@mariozechner/pi-agent-core";
|
||||
import type { ImageContent } from "@mariozechner/pi-ai";
|
||||
import type { SessionStats } from "../../core/agent-session.js";
|
||||
import type { BashResult } from "../../core/bash-executor.js";
|
||||
import type { CompactionResult } from "../../core/compaction/index.js";
|
||||
import type {
|
||||
RpcCommand,
|
||||
RpcResponse,
|
||||
RpcSessionState,
|
||||
RpcSlashCommand,
|
||||
} from "./rpc-types.js";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
/** Distributive Omit that works with union types */
|
||||
type DistributiveOmit<T, K extends keyof T> = T extends unknown
|
||||
? Omit<T, K>
|
||||
: never;
|
||||
|
||||
/** RpcCommand without the id field (for internal send) */
|
||||
type RpcCommandBody = DistributiveOmit<RpcCommand, "id">;
|
||||
|
||||
export interface RpcClientOptions {
|
||||
/** Path to the CLI entry point (default: searches for dist/cli.js) */
|
||||
cliPath?: string;
|
||||
/** Working directory for the agent */
|
||||
cwd?: string;
|
||||
/** Environment variables */
|
||||
env?: Record<string, string>;
|
||||
/** Provider to use */
|
||||
provider?: string;
|
||||
/** Model ID to use */
|
||||
model?: string;
|
||||
/** Additional CLI arguments */
|
||||
args?: string[];
|
||||
}
|
||||
|
||||
export interface ModelInfo {
|
||||
provider: string;
|
||||
id: string;
|
||||
contextWindow: number;
|
||||
reasoning: boolean;
|
||||
}
|
||||
|
||||
export type RpcEventListener = (event: AgentEvent) => void;
|
||||
|
||||
// ============================================================================
|
||||
// RPC Client
|
||||
// ============================================================================
|
||||
|
||||
export class RpcClient {
|
||||
private process: ChildProcess | null = null;
|
||||
private rl: readline.Interface | null = null;
|
||||
private eventListeners: RpcEventListener[] = [];
|
||||
private pendingRequests: Map<
|
||||
string,
|
||||
{ resolve: (response: RpcResponse) => void; reject: (error: Error) => void }
|
||||
> = new Map();
|
||||
private requestId = 0;
|
||||
private stderr = "";
|
||||
|
||||
constructor(private options: RpcClientOptions = {}) {}
|
||||
|
||||
/**
|
||||
* Start the RPC agent process.
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
if (this.process) {
|
||||
throw new Error("Client already started");
|
||||
}
|
||||
|
||||
const cliPath = this.options.cliPath ?? "dist/cli.js";
|
||||
const args = ["--mode", "rpc"];
|
||||
|
||||
if (this.options.provider) {
|
||||
args.push("--provider", this.options.provider);
|
||||
}
|
||||
if (this.options.model) {
|
||||
args.push("--model", this.options.model);
|
||||
}
|
||||
if (this.options.args) {
|
||||
args.push(...this.options.args);
|
||||
}
|
||||
|
||||
this.process = spawn("node", [cliPath, ...args], {
|
||||
cwd: this.options.cwd,
|
||||
env: { ...process.env, ...this.options.env },
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
// Collect stderr for debugging
|
||||
this.process.stderr?.on("data", (data) => {
|
||||
this.stderr += data.toString();
|
||||
});
|
||||
|
||||
// Set up line reader for stdout
|
||||
this.rl = readline.createInterface({
|
||||
input: this.process.stdout!,
|
||||
terminal: false,
|
||||
});
|
||||
|
||||
this.rl.on("line", (line) => {
|
||||
this.handleLine(line);
|
||||
});
|
||||
|
||||
// Wait a moment for process to initialize
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
if (this.process.exitCode !== null) {
|
||||
throw new Error(
|
||||
`Agent process exited immediately with code ${this.process.exitCode}. Stderr: ${this.stderr}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the RPC agent process.
|
||||
*/
|
||||
async stop(): Promise<void> {
|
||||
if (!this.process) return;
|
||||
|
||||
this.rl?.close();
|
||||
this.process.kill("SIGTERM");
|
||||
|
||||
// Wait for process to exit
|
||||
await new Promise<void>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
this.process?.kill("SIGKILL");
|
||||
resolve();
|
||||
}, 1000);
|
||||
|
||||
this.process?.on("exit", () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
this.process = null;
|
||||
this.rl = null;
|
||||
this.pendingRequests.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to agent events.
|
||||
*/
|
||||
onEvent(listener: RpcEventListener): () => void {
|
||||
this.eventListeners.push(listener);
|
||||
return () => {
|
||||
const index = this.eventListeners.indexOf(listener);
|
||||
if (index !== -1) {
|
||||
this.eventListeners.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get collected stderr output (useful for debugging).
|
||||
*/
|
||||
getStderr(): string {
|
||||
return this.stderr;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Command Methods
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Send a prompt to the agent.
|
||||
* Returns immediately after sending; use onEvent() to receive streaming events.
|
||||
* Use waitForIdle() to wait for completion.
|
||||
*/
|
||||
async prompt(message: string, images?: ImageContent[]): Promise<void> {
|
||||
await this.send({ type: "prompt", message, images });
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a steering message to interrupt the agent mid-run.
|
||||
*/
|
||||
async steer(message: string, images?: ImageContent[]): Promise<void> {
|
||||
await this.send({ type: "steer", message, images });
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a follow-up message to be processed after the agent finishes.
|
||||
*/
|
||||
async followUp(message: string, images?: ImageContent[]): Promise<void> {
|
||||
await this.send({ type: "follow_up", message, images });
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort current operation.
|
||||
*/
|
||||
async abort(): Promise<void> {
|
||||
await this.send({ type: "abort" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a new session, optionally with parent tracking.
|
||||
* @param parentSession - Optional parent session path for lineage tracking
|
||||
* @returns Object with `cancelled: true` if an extension cancelled the new session
|
||||
*/
|
||||
async newSession(parentSession?: string): Promise<{ cancelled: boolean }> {
|
||||
const response = await this.send({ type: "new_session", parentSession });
|
||||
return this.getData(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current session state.
|
||||
*/
|
||||
async getState(): Promise<RpcSessionState> {
|
||||
const response = await this.send({ type: "get_state" });
|
||||
return this.getData(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set model by provider and ID.
|
||||
*/
|
||||
async setModel(
|
||||
provider: string,
|
||||
modelId: string,
|
||||
): Promise<{ provider: string; id: string }> {
|
||||
const response = await this.send({ type: "set_model", provider, modelId });
|
||||
return this.getData(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cycle to next model.
|
||||
*/
|
||||
async cycleModel(): Promise<{
|
||||
model: { provider: string; id: string };
|
||||
thinkingLevel: ThinkingLevel;
|
||||
isScoped: boolean;
|
||||
} | null> {
|
||||
const response = await this.send({ type: "cycle_model" });
|
||||
return this.getData(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of available models.
|
||||
*/
|
||||
async getAvailableModels(): Promise<ModelInfo[]> {
|
||||
const response = await this.send({ type: "get_available_models" });
|
||||
return this.getData<{ models: ModelInfo[] }>(response).models;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set thinking level.
|
||||
*/
|
||||
async setThinkingLevel(level: ThinkingLevel): Promise<void> {
|
||||
await this.send({ type: "set_thinking_level", level });
|
||||
}
|
||||
|
||||
/**
|
||||
* Cycle thinking level.
|
||||
*/
|
||||
async cycleThinkingLevel(): Promise<{ level: ThinkingLevel } | null> {
|
||||
const response = await this.send({ type: "cycle_thinking_level" });
|
||||
return this.getData(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set steering mode.
|
||||
*/
|
||||
async setSteeringMode(mode: "all" | "one-at-a-time"): Promise<void> {
|
||||
await this.send({ type: "set_steering_mode", mode });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set follow-up mode.
|
||||
*/
|
||||
async setFollowUpMode(mode: "all" | "one-at-a-time"): Promise<void> {
|
||||
await this.send({ type: "set_follow_up_mode", mode });
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact session context.
|
||||
*/
|
||||
async compact(customInstructions?: string): Promise<CompactionResult> {
|
||||
const response = await this.send({ type: "compact", customInstructions });
|
||||
return this.getData(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set auto-compaction enabled/disabled.
|
||||
*/
|
||||
async setAutoCompaction(enabled: boolean): Promise<void> {
|
||||
await this.send({ type: "set_auto_compaction", enabled });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set auto-retry enabled/disabled.
|
||||
*/
|
||||
async setAutoRetry(enabled: boolean): Promise<void> {
|
||||
await this.send({ type: "set_auto_retry", enabled });
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort in-progress retry.
|
||||
*/
|
||||
async abortRetry(): Promise<void> {
|
||||
await this.send({ type: "abort_retry" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a bash command.
|
||||
*/
|
||||
async bash(command: string): Promise<BashResult> {
|
||||
const response = await this.send({ type: "bash", command });
|
||||
return this.getData(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort running bash command.
|
||||
*/
|
||||
async abortBash(): Promise<void> {
|
||||
await this.send({ type: "abort_bash" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session statistics.
|
||||
*/
|
||||
async getSessionStats(): Promise<SessionStats> {
|
||||
const response = await this.send({ type: "get_session_stats" });
|
||||
return this.getData(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export session to HTML.
|
||||
*/
|
||||
async exportHtml(outputPath?: string): Promise<{ path: string }> {
|
||||
const response = await this.send({ type: "export_html", outputPath });
|
||||
return this.getData(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to a different session file.
|
||||
* @returns Object with `cancelled: true` if an extension cancelled the switch
|
||||
*/
|
||||
async switchSession(sessionPath: string): Promise<{ cancelled: boolean }> {
|
||||
const response = await this.send({ type: "switch_session", sessionPath });
|
||||
return this.getData(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fork from a specific message.
|
||||
* @returns Object with `text` (the message text) and `cancelled` (if extension cancelled)
|
||||
*/
|
||||
async fork(entryId: string): Promise<{ text: string; cancelled: boolean }> {
|
||||
const response = await this.send({ type: "fork", entryId });
|
||||
return this.getData(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get messages available for forking.
|
||||
*/
|
||||
async getForkMessages(): Promise<Array<{ entryId: string; text: string }>> {
|
||||
const response = await this.send({ type: "get_fork_messages" });
|
||||
return this.getData<{ messages: Array<{ entryId: string; text: string }> }>(
|
||||
response,
|
||||
).messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get text of last assistant message.
|
||||
*/
|
||||
async getLastAssistantText(): Promise<string | null> {
|
||||
const response = await this.send({ type: "get_last_assistant_text" });
|
||||
return this.getData<{ text: string | null }>(response).text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the session display name.
|
||||
*/
|
||||
async setSessionName(name: string): Promise<void> {
|
||||
await this.send({ type: "set_session_name", name });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all messages in the session.
|
||||
*/
|
||||
async getMessages(): Promise<AgentMessage[]> {
|
||||
const response = await this.send({ type: "get_messages" });
|
||||
return this.getData<{ messages: AgentMessage[] }>(response).messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available commands (extension commands, prompt templates, skills).
|
||||
*/
|
||||
async getCommands(): Promise<RpcSlashCommand[]> {
|
||||
const response = await this.send({ type: "get_commands" });
|
||||
return this.getData<{ commands: RpcSlashCommand[] }>(response).commands;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Helpers
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Wait for agent to become idle (no streaming).
|
||||
* Resolves when agent_end event is received.
|
||||
*/
|
||||
waitForIdle(timeout = 60000): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
unsubscribe();
|
||||
reject(
|
||||
new Error(
|
||||
`Timeout waiting for agent to become idle. Stderr: ${this.stderr}`,
|
||||
),
|
||||
);
|
||||
}, timeout);
|
||||
|
||||
const unsubscribe = this.onEvent((event) => {
|
||||
if (event.type === "agent_end") {
|
||||
clearTimeout(timer);
|
||||
unsubscribe();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect events until agent becomes idle.
|
||||
*/
|
||||
collectEvents(timeout = 60000): Promise<AgentEvent[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const events: AgentEvent[] = [];
|
||||
const timer = setTimeout(() => {
|
||||
unsubscribe();
|
||||
reject(new Error(`Timeout collecting events. Stderr: ${this.stderr}`));
|
||||
}, timeout);
|
||||
|
||||
const unsubscribe = this.onEvent((event) => {
|
||||
events.push(event);
|
||||
if (event.type === "agent_end") {
|
||||
clearTimeout(timer);
|
||||
unsubscribe();
|
||||
resolve(events);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send prompt and wait for completion, returning all events.
|
||||
*/
|
||||
async promptAndWait(
|
||||
message: string,
|
||||
images?: ImageContent[],
|
||||
timeout = 60000,
|
||||
): Promise<AgentEvent[]> {
|
||||
const eventsPromise = this.collectEvents(timeout);
|
||||
await this.prompt(message, images);
|
||||
return eventsPromise;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Internal
|
||||
// =========================================================================
|
||||
|
||||
private handleLine(line: string): void {
|
||||
try {
|
||||
const data = JSON.parse(line);
|
||||
|
||||
// Check if it's a response to a pending request
|
||||
if (
|
||||
data.type === "response" &&
|
||||
data.id &&
|
||||
this.pendingRequests.has(data.id)
|
||||
) {
|
||||
const pending = this.pendingRequests.get(data.id)!;
|
||||
this.pendingRequests.delete(data.id);
|
||||
pending.resolve(data as RpcResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise it's an event
|
||||
for (const listener of this.eventListeners) {
|
||||
listener(data as AgentEvent);
|
||||
}
|
||||
} catch {
|
||||
// Ignore non-JSON lines
|
||||
}
|
||||
}
|
||||
|
||||
private async send(command: RpcCommandBody): Promise<RpcResponse> {
|
||||
if (!this.process?.stdin) {
|
||||
throw new Error("Client not started");
|
||||
}
|
||||
|
||||
const id = `req_${++this.requestId}`;
|
||||
const fullCommand = { ...command, id } as RpcCommand;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.pendingRequests.set(id, { resolve, reject });
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
this.pendingRequests.delete(id);
|
||||
reject(
|
||||
new Error(
|
||||
`Timeout waiting for response to ${command.type}. Stderr: ${this.stderr}`,
|
||||
),
|
||||
);
|
||||
}, 30000);
|
||||
|
||||
this.pendingRequests.set(id, {
|
||||
resolve: (response) => {
|
||||
clearTimeout(timeout);
|
||||
resolve(response);
|
||||
},
|
||||
reject: (error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
},
|
||||
});
|
||||
|
||||
this.process!.stdin!.write(`${JSON.stringify(fullCommand)}\n`);
|
||||
});
|
||||
}
|
||||
|
||||
private getData<T>(response: RpcResponse): T {
|
||||
if (!response.success) {
|
||||
const errorResponse = response as Extract<
|
||||
RpcResponse,
|
||||
{ success: false }
|
||||
>;
|
||||
throw new Error(errorResponse.error);
|
||||
}
|
||||
// Type assertion: we trust response.data matches T based on the command sent.
|
||||
// This is safe because each public method specifies the correct T for its command.
|
||||
const successResponse = response as Extract<
|
||||
RpcResponse,
|
||||
{ success: true; data: unknown }
|
||||
>;
|
||||
return successResponse.data as T;
|
||||
}
|
||||
}
|
||||
715
packages/coding-agent/src/modes/rpc/rpc-mode.ts
Normal file
715
packages/coding-agent/src/modes/rpc/rpc-mode.ts
Normal file
|
|
@ -0,0 +1,715 @@
|
|||
/**
|
||||
* RPC mode: Headless operation with JSON stdin/stdout protocol.
|
||||
*
|
||||
* Used for embedding the agent in other applications.
|
||||
* Receives commands as JSON on stdin, outputs events and responses as JSON on stdout.
|
||||
*
|
||||
* Protocol:
|
||||
* - Commands: JSON objects with `type` field, optional `id` for correlation
|
||||
* - Responses: JSON objects with `type: "response"`, `command`, `success`, and optional `data`/`error`
|
||||
* - Events: AgentSessionEvent objects streamed as they occur
|
||||
* - Extension UI: Extension UI requests are emitted, client responds with extension_ui_response
|
||||
*/
|
||||
|
||||
import * as crypto from "node:crypto";
|
||||
import * as readline from "readline";
|
||||
import type { AgentSession } from "../../core/agent-session.js";
|
||||
import type {
|
||||
ExtensionUIContext,
|
||||
ExtensionUIDialogOptions,
|
||||
ExtensionWidgetOptions,
|
||||
} from "../../core/extensions/index.js";
|
||||
import { type Theme, theme } from "../interactive/theme/theme.js";
|
||||
import type {
|
||||
RpcCommand,
|
||||
RpcExtensionUIRequest,
|
||||
RpcExtensionUIResponse,
|
||||
RpcResponse,
|
||||
RpcSessionState,
|
||||
RpcSlashCommand,
|
||||
} from "./rpc-types.js";
|
||||
|
||||
// Re-export types for consumers
|
||||
export type {
|
||||
RpcCommand,
|
||||
RpcExtensionUIRequest,
|
||||
RpcExtensionUIResponse,
|
||||
RpcResponse,
|
||||
RpcSessionState,
|
||||
} from "./rpc-types.js";
|
||||
|
||||
/**
|
||||
* Run in RPC mode.
|
||||
* Listens for JSON commands on stdin, outputs events and responses on stdout.
|
||||
*/
|
||||
export async function runRpcMode(session: AgentSession): Promise<never> {
|
||||
const output = (obj: RpcResponse | RpcExtensionUIRequest | object) => {
|
||||
console.log(JSON.stringify(obj));
|
||||
};
|
||||
|
||||
const success = <T extends RpcCommand["type"]>(
|
||||
id: string | undefined,
|
||||
command: T,
|
||||
data?: object | null,
|
||||
): RpcResponse => {
|
||||
if (data === undefined) {
|
||||
return { id, type: "response", command, success: true } as RpcResponse;
|
||||
}
|
||||
return {
|
||||
id,
|
||||
type: "response",
|
||||
command,
|
||||
success: true,
|
||||
data,
|
||||
} as RpcResponse;
|
||||
};
|
||||
|
||||
const error = (
|
||||
id: string | undefined,
|
||||
command: string,
|
||||
message: string,
|
||||
): RpcResponse => {
|
||||
return { id, type: "response", command, success: false, error: message };
|
||||
};
|
||||
|
||||
// Pending extension UI requests waiting for response
|
||||
const pendingExtensionRequests = new Map<
|
||||
string,
|
||||
{ resolve: (value: any) => void; reject: (error: Error) => void }
|
||||
>();
|
||||
|
||||
// Shutdown request flag
|
||||
let shutdownRequested = false;
|
||||
|
||||
/** Helper for dialog methods with signal/timeout support */
|
||||
function createDialogPromise<T>(
|
||||
opts: ExtensionUIDialogOptions | undefined,
|
||||
defaultValue: T,
|
||||
request: Record<string, unknown>,
|
||||
parseResponse: (response: RpcExtensionUIResponse) => T,
|
||||
): Promise<T> {
|
||||
if (opts?.signal?.aborted) return Promise.resolve(defaultValue);
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
return new Promise((resolve, reject) => {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const cleanup = () => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
opts?.signal?.removeEventListener("abort", onAbort);
|
||||
pendingExtensionRequests.delete(id);
|
||||
};
|
||||
|
||||
const onAbort = () => {
|
||||
cleanup();
|
||||
resolve(defaultValue);
|
||||
};
|
||||
opts?.signal?.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
if (opts?.timeout) {
|
||||
timeoutId = setTimeout(() => {
|
||||
cleanup();
|
||||
resolve(defaultValue);
|
||||
}, opts.timeout);
|
||||
}
|
||||
|
||||
pendingExtensionRequests.set(id, {
|
||||
resolve: (response: RpcExtensionUIResponse) => {
|
||||
cleanup();
|
||||
resolve(parseResponse(response));
|
||||
},
|
||||
reject,
|
||||
});
|
||||
output({
|
||||
type: "extension_ui_request",
|
||||
id,
|
||||
...request,
|
||||
} as RpcExtensionUIRequest);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an extension UI context that uses the RPC protocol.
|
||||
*/
|
||||
const createExtensionUIContext = (): ExtensionUIContext => ({
|
||||
select: (title, options, opts) =>
|
||||
createDialogPromise(
|
||||
opts,
|
||||
undefined,
|
||||
{ method: "select", title, options, timeout: opts?.timeout },
|
||||
(r) =>
|
||||
"cancelled" in r && r.cancelled
|
||||
? undefined
|
||||
: "value" in r
|
||||
? r.value
|
||||
: undefined,
|
||||
),
|
||||
|
||||
confirm: (title, message, opts) =>
|
||||
createDialogPromise(
|
||||
opts,
|
||||
false,
|
||||
{ method: "confirm", title, message, timeout: opts?.timeout },
|
||||
(r) =>
|
||||
"cancelled" in r && r.cancelled
|
||||
? false
|
||||
: "confirmed" in r
|
||||
? r.confirmed
|
||||
: false,
|
||||
),
|
||||
|
||||
input: (title, placeholder, opts) =>
|
||||
createDialogPromise(
|
||||
opts,
|
||||
undefined,
|
||||
{ method: "input", title, placeholder, timeout: opts?.timeout },
|
||||
(r) =>
|
||||
"cancelled" in r && r.cancelled
|
||||
? undefined
|
||||
: "value" in r
|
||||
? r.value
|
||||
: undefined,
|
||||
),
|
||||
|
||||
notify(message: string, type?: "info" | "warning" | "error"): void {
|
||||
// Fire and forget - no response needed
|
||||
output({
|
||||
type: "extension_ui_request",
|
||||
id: crypto.randomUUID(),
|
||||
method: "notify",
|
||||
message,
|
||||
notifyType: type,
|
||||
} as RpcExtensionUIRequest);
|
||||
},
|
||||
|
||||
onTerminalInput(): () => void {
|
||||
// Raw terminal input not supported in RPC mode
|
||||
return () => {};
|
||||
},
|
||||
|
||||
setStatus(key: string, text: string | undefined): void {
|
||||
// Fire and forget - no response needed
|
||||
output({
|
||||
type: "extension_ui_request",
|
||||
id: crypto.randomUUID(),
|
||||
method: "setStatus",
|
||||
statusKey: key,
|
||||
statusText: text,
|
||||
} as RpcExtensionUIRequest);
|
||||
},
|
||||
|
||||
setWorkingMessage(_message?: string): void {
|
||||
// Working message not supported in RPC mode - requires TUI loader access
|
||||
},
|
||||
|
||||
setWidget(
|
||||
key: string,
|
||||
content: unknown,
|
||||
options?: ExtensionWidgetOptions,
|
||||
): void {
|
||||
// Only support string arrays in RPC mode - factory functions are ignored
|
||||
if (content === undefined || Array.isArray(content)) {
|
||||
output({
|
||||
type: "extension_ui_request",
|
||||
id: crypto.randomUUID(),
|
||||
method: "setWidget",
|
||||
widgetKey: key,
|
||||
widgetLines: content as string[] | undefined,
|
||||
widgetPlacement: options?.placement,
|
||||
} as RpcExtensionUIRequest);
|
||||
}
|
||||
// Component factories are not supported in RPC mode - would need TUI access
|
||||
},
|
||||
|
||||
setFooter(_factory: unknown): void {
|
||||
// Custom footer not supported in RPC mode - requires TUI access
|
||||
},
|
||||
|
||||
setHeader(_factory: unknown): void {
|
||||
// Custom header not supported in RPC mode - requires TUI access
|
||||
},
|
||||
|
||||
setTitle(title: string): void {
|
||||
// Fire and forget - host can implement terminal title control
|
||||
output({
|
||||
type: "extension_ui_request",
|
||||
id: crypto.randomUUID(),
|
||||
method: "setTitle",
|
||||
title,
|
||||
} as RpcExtensionUIRequest);
|
||||
},
|
||||
|
||||
async custom() {
|
||||
// Custom UI not supported in RPC mode
|
||||
return undefined as never;
|
||||
},
|
||||
|
||||
pasteToEditor(text: string): void {
|
||||
// Paste handling not supported in RPC mode - falls back to setEditorText
|
||||
this.setEditorText(text);
|
||||
},
|
||||
|
||||
setEditorText(text: string): void {
|
||||
// Fire and forget - host can implement editor control
|
||||
output({
|
||||
type: "extension_ui_request",
|
||||
id: crypto.randomUUID(),
|
||||
method: "set_editor_text",
|
||||
text,
|
||||
} as RpcExtensionUIRequest);
|
||||
},
|
||||
|
||||
getEditorText(): string {
|
||||
// Synchronous method can't wait for RPC response
|
||||
// Host should track editor state locally if needed
|
||||
return "";
|
||||
},
|
||||
|
||||
async editor(title: string, prefill?: string): Promise<string | undefined> {
|
||||
const id = crypto.randomUUID();
|
||||
return new Promise((resolve, reject) => {
|
||||
pendingExtensionRequests.set(id, {
|
||||
resolve: (response: RpcExtensionUIResponse) => {
|
||||
if ("cancelled" in response && response.cancelled) {
|
||||
resolve(undefined);
|
||||
} else if ("value" in response) {
|
||||
resolve(response.value);
|
||||
} else {
|
||||
resolve(undefined);
|
||||
}
|
||||
},
|
||||
reject,
|
||||
});
|
||||
output({
|
||||
type: "extension_ui_request",
|
||||
id,
|
||||
method: "editor",
|
||||
title,
|
||||
prefill,
|
||||
} as RpcExtensionUIRequest);
|
||||
});
|
||||
},
|
||||
|
||||
setEditorComponent(): void {
|
||||
// Custom editor components not supported in RPC mode
|
||||
},
|
||||
|
||||
get theme() {
|
||||
return theme;
|
||||
},
|
||||
|
||||
getAllThemes() {
|
||||
return [];
|
||||
},
|
||||
|
||||
getTheme(_name: string) {
|
||||
return undefined;
|
||||
},
|
||||
|
||||
setTheme(_theme: string | Theme) {
|
||||
// Theme switching not supported in RPC mode
|
||||
return {
|
||||
success: false,
|
||||
error: "Theme switching not supported in RPC mode",
|
||||
};
|
||||
},
|
||||
|
||||
getToolsExpanded() {
|
||||
// Tool expansion not supported in RPC mode - no TUI
|
||||
return false;
|
||||
},
|
||||
|
||||
setToolsExpanded(_expanded: boolean) {
|
||||
// Tool expansion not supported in RPC mode - no TUI
|
||||
},
|
||||
});
|
||||
|
||||
// Set up extensions with RPC-based UI context
|
||||
await session.bindExtensions({
|
||||
uiContext: createExtensionUIContext(),
|
||||
commandContextActions: {
|
||||
waitForIdle: () => session.agent.waitForIdle(),
|
||||
newSession: async (options) => {
|
||||
// Delegate to AgentSession (handles setup + agent state sync)
|
||||
const success = await session.newSession(options);
|
||||
return { cancelled: !success };
|
||||
},
|
||||
fork: async (entryId) => {
|
||||
const result = await session.fork(entryId);
|
||||
return { cancelled: result.cancelled };
|
||||
},
|
||||
navigateTree: async (targetId, options) => {
|
||||
const result = await session.navigateTree(targetId, {
|
||||
summarize: options?.summarize,
|
||||
customInstructions: options?.customInstructions,
|
||||
replaceInstructions: options?.replaceInstructions,
|
||||
label: options?.label,
|
||||
});
|
||||
return { cancelled: result.cancelled };
|
||||
},
|
||||
switchSession: async (sessionPath) => {
|
||||
const success = await session.switchSession(sessionPath);
|
||||
return { cancelled: !success };
|
||||
},
|
||||
reload: async () => {
|
||||
await session.reload();
|
||||
},
|
||||
},
|
||||
shutdownHandler: () => {
|
||||
shutdownRequested = true;
|
||||
},
|
||||
onError: (err) => {
|
||||
output({
|
||||
type: "extension_error",
|
||||
extensionPath: err.extensionPath,
|
||||
event: err.event,
|
||||
error: err.error,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Output all agent events as JSON
|
||||
session.subscribe((event) => {
|
||||
output(event);
|
||||
});
|
||||
|
||||
// Handle a single command
|
||||
const handleCommand = async (command: RpcCommand): Promise<RpcResponse> => {
|
||||
const id = command.id;
|
||||
|
||||
switch (command.type) {
|
||||
// =================================================================
|
||||
// Prompting
|
||||
// =================================================================
|
||||
|
||||
case "prompt": {
|
||||
// Don't await - events will stream
|
||||
// Extension commands are executed immediately, file prompt templates are expanded
|
||||
// If streaming and streamingBehavior specified, queues via steer/followUp
|
||||
session
|
||||
.prompt(command.message, {
|
||||
images: command.images,
|
||||
streamingBehavior: command.streamingBehavior,
|
||||
source: "rpc",
|
||||
})
|
||||
.catch((e) => output(error(id, "prompt", e.message)));
|
||||
return success(id, "prompt");
|
||||
}
|
||||
|
||||
case "steer": {
|
||||
await session.steer(command.message, command.images);
|
||||
return success(id, "steer");
|
||||
}
|
||||
|
||||
case "follow_up": {
|
||||
await session.followUp(command.message, command.images);
|
||||
return success(id, "follow_up");
|
||||
}
|
||||
|
||||
case "abort": {
|
||||
await session.abort();
|
||||
return success(id, "abort");
|
||||
}
|
||||
|
||||
case "new_session": {
|
||||
const options = command.parentSession
|
||||
? { parentSession: command.parentSession }
|
||||
: undefined;
|
||||
const cancelled = !(await session.newSession(options));
|
||||
return success(id, "new_session", { cancelled });
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// State
|
||||
// =================================================================
|
||||
|
||||
case "get_state": {
|
||||
const state: RpcSessionState = {
|
||||
model: session.model,
|
||||
thinkingLevel: session.thinkingLevel,
|
||||
isStreaming: session.isStreaming,
|
||||
isCompacting: session.isCompacting,
|
||||
steeringMode: session.steeringMode,
|
||||
followUpMode: session.followUpMode,
|
||||
sessionFile: session.sessionFile,
|
||||
sessionId: session.sessionId,
|
||||
sessionName: session.sessionName,
|
||||
autoCompactionEnabled: session.autoCompactionEnabled,
|
||||
messageCount: session.messages.length,
|
||||
pendingMessageCount: session.pendingMessageCount,
|
||||
};
|
||||
return success(id, "get_state", state);
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Model
|
||||
// =================================================================
|
||||
|
||||
case "set_model": {
|
||||
const models = await session.modelRegistry.getAvailable();
|
||||
const model = models.find(
|
||||
(m) => m.provider === command.provider && m.id === command.modelId,
|
||||
);
|
||||
if (!model) {
|
||||
return error(
|
||||
id,
|
||||
"set_model",
|
||||
`Model not found: ${command.provider}/${command.modelId}`,
|
||||
);
|
||||
}
|
||||
await session.setModel(model);
|
||||
return success(id, "set_model", model);
|
||||
}
|
||||
|
||||
case "cycle_model": {
|
||||
const result = await session.cycleModel();
|
||||
if (!result) {
|
||||
return success(id, "cycle_model", null);
|
||||
}
|
||||
return success(id, "cycle_model", result);
|
||||
}
|
||||
|
||||
case "get_available_models": {
|
||||
const models = await session.modelRegistry.getAvailable();
|
||||
return success(id, "get_available_models", { models });
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Thinking
|
||||
// =================================================================
|
||||
|
||||
case "set_thinking_level": {
|
||||
session.setThinkingLevel(command.level);
|
||||
return success(id, "set_thinking_level");
|
||||
}
|
||||
|
||||
case "cycle_thinking_level": {
|
||||
const level = session.cycleThinkingLevel();
|
||||
if (!level) {
|
||||
return success(id, "cycle_thinking_level", null);
|
||||
}
|
||||
return success(id, "cycle_thinking_level", { level });
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Queue Modes
|
||||
// =================================================================
|
||||
|
||||
case "set_steering_mode": {
|
||||
session.setSteeringMode(command.mode);
|
||||
return success(id, "set_steering_mode");
|
||||
}
|
||||
|
||||
case "set_follow_up_mode": {
|
||||
session.setFollowUpMode(command.mode);
|
||||
return success(id, "set_follow_up_mode");
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Compaction
|
||||
// =================================================================
|
||||
|
||||
case "compact": {
|
||||
const result = await session.compact(command.customInstructions);
|
||||
return success(id, "compact", result);
|
||||
}
|
||||
|
||||
case "set_auto_compaction": {
|
||||
session.setAutoCompactionEnabled(command.enabled);
|
||||
return success(id, "set_auto_compaction");
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Retry
|
||||
// =================================================================
|
||||
|
||||
case "set_auto_retry": {
|
||||
session.setAutoRetryEnabled(command.enabled);
|
||||
return success(id, "set_auto_retry");
|
||||
}
|
||||
|
||||
case "abort_retry": {
|
||||
session.abortRetry();
|
||||
return success(id, "abort_retry");
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Bash
|
||||
// =================================================================
|
||||
|
||||
case "bash": {
|
||||
const result = await session.executeBash(command.command);
|
||||
return success(id, "bash", result);
|
||||
}
|
||||
|
||||
case "abort_bash": {
|
||||
session.abortBash();
|
||||
return success(id, "abort_bash");
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Session
|
||||
// =================================================================
|
||||
|
||||
case "get_session_stats": {
|
||||
const stats = session.getSessionStats();
|
||||
return success(id, "get_session_stats", stats);
|
||||
}
|
||||
|
||||
case "export_html": {
|
||||
const path = await session.exportToHtml(command.outputPath);
|
||||
return success(id, "export_html", { path });
|
||||
}
|
||||
|
||||
case "switch_session": {
|
||||
const cancelled = !(await session.switchSession(command.sessionPath));
|
||||
return success(id, "switch_session", { cancelled });
|
||||
}
|
||||
|
||||
case "fork": {
|
||||
const result = await session.fork(command.entryId);
|
||||
return success(id, "fork", {
|
||||
text: result.selectedText,
|
||||
cancelled: result.cancelled,
|
||||
});
|
||||
}
|
||||
|
||||
case "get_fork_messages": {
|
||||
const messages = session.getUserMessagesForForking();
|
||||
return success(id, "get_fork_messages", { messages });
|
||||
}
|
||||
|
||||
case "get_last_assistant_text": {
|
||||
const text = session.getLastAssistantText();
|
||||
return success(id, "get_last_assistant_text", { text });
|
||||
}
|
||||
|
||||
case "set_session_name": {
|
||||
const name = command.name.trim();
|
||||
if (!name) {
|
||||
return error(id, "set_session_name", "Session name cannot be empty");
|
||||
}
|
||||
session.setSessionName(name);
|
||||
return success(id, "set_session_name");
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Messages
|
||||
// =================================================================
|
||||
|
||||
case "get_messages": {
|
||||
return success(id, "get_messages", { messages: session.messages });
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Commands (available for invocation via prompt)
|
||||
// =================================================================
|
||||
|
||||
case "get_commands": {
|
||||
const commands: RpcSlashCommand[] = [];
|
||||
|
||||
// Extension commands
|
||||
for (const {
|
||||
command,
|
||||
extensionPath,
|
||||
} of session.extensionRunner?.getRegisteredCommandsWithPaths() ?? []) {
|
||||
commands.push({
|
||||
name: command.name,
|
||||
description: command.description,
|
||||
source: "extension",
|
||||
path: extensionPath,
|
||||
});
|
||||
}
|
||||
|
||||
// Prompt templates (source is always "user" | "project" | "path" in coding-agent)
|
||||
for (const template of session.promptTemplates) {
|
||||
commands.push({
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
source: "prompt",
|
||||
location: template.source as RpcSlashCommand["location"],
|
||||
path: template.filePath,
|
||||
});
|
||||
}
|
||||
|
||||
// Skills (source is always "user" | "project" | "path" in coding-agent)
|
||||
for (const skill of session.resourceLoader.getSkills().skills) {
|
||||
commands.push({
|
||||
name: `skill:${skill.name}`,
|
||||
description: skill.description,
|
||||
source: "skill",
|
||||
location: skill.source as RpcSlashCommand["location"],
|
||||
path: skill.filePath,
|
||||
});
|
||||
}
|
||||
|
||||
return success(id, "get_commands", { commands });
|
||||
}
|
||||
|
||||
default: {
|
||||
const unknownCommand = command as { type: string };
|
||||
return error(
|
||||
undefined,
|
||||
unknownCommand.type,
|
||||
`Unknown command: ${unknownCommand.type}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if shutdown was requested and perform shutdown if so.
|
||||
* Called after handling each command when waiting for the next command.
|
||||
*/
|
||||
async function checkShutdownRequested(): Promise<void> {
|
||||
if (!shutdownRequested) return;
|
||||
|
||||
const currentRunner = session.extensionRunner;
|
||||
if (currentRunner?.hasHandlers("session_shutdown")) {
|
||||
await currentRunner.emit({ type: "session_shutdown" });
|
||||
}
|
||||
|
||||
// Close readline interface to stop waiting for input
|
||||
rl.close();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Listen for JSON input
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
terminal: false,
|
||||
});
|
||||
|
||||
rl.on("line", async (line: string) => {
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
|
||||
// Handle extension UI responses
|
||||
if (parsed.type === "extension_ui_response") {
|
||||
const response = parsed as RpcExtensionUIResponse;
|
||||
const pending = pendingExtensionRequests.get(response.id);
|
||||
if (pending) {
|
||||
pendingExtensionRequests.delete(response.id);
|
||||
pending.resolve(response);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle regular commands
|
||||
const command = parsed as RpcCommand;
|
||||
const response = await handleCommand(command);
|
||||
output(response);
|
||||
|
||||
// Check for deferred shutdown request (idle between commands)
|
||||
await checkShutdownRequested();
|
||||
} catch (e: any) {
|
||||
output(
|
||||
error(undefined, "parse", `Failed to parse command: ${e.message}`),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Keep process alive forever
|
||||
return new Promise(() => {});
|
||||
}
|
||||
388
packages/coding-agent/src/modes/rpc/rpc-types.ts
Normal file
388
packages/coding-agent/src/modes/rpc/rpc-types.ts
Normal file
|
|
@ -0,0 +1,388 @@
|
|||
/**
|
||||
* RPC protocol types for headless operation.
|
||||
*
|
||||
* Commands are sent as JSON lines on stdin.
|
||||
* Responses and events are emitted as JSON lines on stdout.
|
||||
*/
|
||||
|
||||
import type { AgentMessage, ThinkingLevel } from "@mariozechner/pi-agent-core";
|
||||
import type { ImageContent, Model } from "@mariozechner/pi-ai";
|
||||
import type { SessionStats } from "../../core/agent-session.js";
|
||||
import type { BashResult } from "../../core/bash-executor.js";
|
||||
import type { CompactionResult } from "../../core/compaction/index.js";
|
||||
|
||||
// ============================================================================
|
||||
// RPC Commands (stdin)
|
||||
// ============================================================================
|
||||
|
||||
export type RpcCommand =
|
||||
// Prompting
|
||||
| {
|
||||
id?: string;
|
||||
type: "prompt";
|
||||
message: string;
|
||||
images?: ImageContent[];
|
||||
streamingBehavior?: "steer" | "followUp";
|
||||
}
|
||||
| { id?: string; type: "steer"; message: string; images?: ImageContent[] }
|
||||
| { id?: string; type: "follow_up"; message: string; images?: ImageContent[] }
|
||||
| { id?: string; type: "abort" }
|
||||
| { id?: string; type: "new_session"; parentSession?: string }
|
||||
|
||||
// State
|
||||
| { id?: string; type: "get_state" }
|
||||
|
||||
// Model
|
||||
| { id?: string; type: "set_model"; provider: string; modelId: string }
|
||||
| { id?: string; type: "cycle_model" }
|
||||
| { id?: string; type: "get_available_models" }
|
||||
|
||||
// Thinking
|
||||
| { id?: string; type: "set_thinking_level"; level: ThinkingLevel }
|
||||
| { id?: string; type: "cycle_thinking_level" }
|
||||
|
||||
// Queue modes
|
||||
| { id?: string; type: "set_steering_mode"; mode: "all" | "one-at-a-time" }
|
||||
| { id?: string; type: "set_follow_up_mode"; mode: "all" | "one-at-a-time" }
|
||||
|
||||
// Compaction
|
||||
| { id?: string; type: "compact"; customInstructions?: string }
|
||||
| { id?: string; type: "set_auto_compaction"; enabled: boolean }
|
||||
|
||||
// Retry
|
||||
| { id?: string; type: "set_auto_retry"; enabled: boolean }
|
||||
| { id?: string; type: "abort_retry" }
|
||||
|
||||
// Bash
|
||||
| { id?: string; type: "bash"; command: string }
|
||||
| { id?: string; type: "abort_bash" }
|
||||
|
||||
// Session
|
||||
| { id?: string; type: "get_session_stats" }
|
||||
| { id?: string; type: "export_html"; outputPath?: string }
|
||||
| { id?: string; type: "switch_session"; sessionPath: string }
|
||||
| { id?: string; type: "fork"; entryId: string }
|
||||
| { id?: string; type: "get_fork_messages" }
|
||||
| { id?: string; type: "get_last_assistant_text" }
|
||||
| { id?: string; type: "set_session_name"; name: string }
|
||||
|
||||
// Messages
|
||||
| { id?: string; type: "get_messages" }
|
||||
|
||||
// Commands (available for invocation via prompt)
|
||||
| { id?: string; type: "get_commands" };
|
||||
|
||||
// ============================================================================
|
||||
// RPC Slash Command (for get_commands response)
|
||||
// ============================================================================
|
||||
|
||||
/** A command available for invocation via prompt */
|
||||
export interface RpcSlashCommand {
|
||||
/** Command name (without leading slash) */
|
||||
name: string;
|
||||
/** Human-readable description */
|
||||
description?: string;
|
||||
/** What kind of command this is */
|
||||
source: "extension" | "prompt" | "skill";
|
||||
/** Where the command was loaded from (undefined for extensions) */
|
||||
location?: "user" | "project" | "path";
|
||||
/** File path to the command source */
|
||||
path?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// RPC State
|
||||
// ============================================================================
|
||||
|
||||
export interface RpcSessionState {
|
||||
model?: Model<any>;
|
||||
thinkingLevel: ThinkingLevel;
|
||||
isStreaming: boolean;
|
||||
isCompacting: boolean;
|
||||
steeringMode: "all" | "one-at-a-time";
|
||||
followUpMode: "all" | "one-at-a-time";
|
||||
sessionFile?: string;
|
||||
sessionId: string;
|
||||
sessionName?: string;
|
||||
autoCompactionEnabled: boolean;
|
||||
messageCount: number;
|
||||
pendingMessageCount: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// RPC Responses (stdout)
|
||||
// ============================================================================
|
||||
|
||||
// Success responses with data
|
||||
export type RpcResponse =
|
||||
// Prompting (async - events follow)
|
||||
| { id?: string; type: "response"; command: "prompt"; success: true }
|
||||
| { id?: string; type: "response"; command: "steer"; success: true }
|
||||
| { id?: string; type: "response"; command: "follow_up"; success: true }
|
||||
| { id?: string; type: "response"; command: "abort"; success: true }
|
||||
| {
|
||||
id?: string;
|
||||
type: "response";
|
||||
command: "new_session";
|
||||
success: true;
|
||||
data: { cancelled: boolean };
|
||||
}
|
||||
|
||||
// State
|
||||
| {
|
||||
id?: string;
|
||||
type: "response";
|
||||
command: "get_state";
|
||||
success: true;
|
||||
data: RpcSessionState;
|
||||
}
|
||||
|
||||
// Model
|
||||
| {
|
||||
id?: string;
|
||||
type: "response";
|
||||
command: "set_model";
|
||||
success: true;
|
||||
data: Model<any>;
|
||||
}
|
||||
| {
|
||||
id?: string;
|
||||
type: "response";
|
||||
command: "cycle_model";
|
||||
success: true;
|
||||
data: {
|
||||
model: Model<any>;
|
||||
thinkingLevel: ThinkingLevel;
|
||||
isScoped: boolean;
|
||||
} | null;
|
||||
}
|
||||
| {
|
||||
id?: string;
|
||||
type: "response";
|
||||
command: "get_available_models";
|
||||
success: true;
|
||||
data: { models: Model<any>[] };
|
||||
}
|
||||
|
||||
// Thinking
|
||||
| {
|
||||
id?: string;
|
||||
type: "response";
|
||||
command: "set_thinking_level";
|
||||
success: true;
|
||||
}
|
||||
| {
|
||||
id?: string;
|
||||
type: "response";
|
||||
command: "cycle_thinking_level";
|
||||
success: true;
|
||||
data: { level: ThinkingLevel } | null;
|
||||
}
|
||||
|
||||
// Queue modes
|
||||
| {
|
||||
id?: string;
|
||||
type: "response";
|
||||
command: "set_steering_mode";
|
||||
success: true;
|
||||
}
|
||||
| {
|
||||
id?: string;
|
||||
type: "response";
|
||||
command: "set_follow_up_mode";
|
||||
success: true;
|
||||
}
|
||||
|
||||
// Compaction
|
||||
| {
|
||||
id?: string;
|
||||
type: "response";
|
||||
command: "compact";
|
||||
success: true;
|
||||
data: CompactionResult;
|
||||
}
|
||||
| {
|
||||
id?: string;
|
||||
type: "response";
|
||||
command: "set_auto_compaction";
|
||||
success: true;
|
||||
}
|
||||
|
||||
// Retry
|
||||
| { id?: string; type: "response"; command: "set_auto_retry"; success: true }
|
||||
| { id?: string; type: "response"; command: "abort_retry"; success: true }
|
||||
|
||||
// Bash
|
||||
| {
|
||||
id?: string;
|
||||
type: "response";
|
||||
command: "bash";
|
||||
success: true;
|
||||
data: BashResult;
|
||||
}
|
||||
| { id?: string; type: "response"; command: "abort_bash"; success: true }
|
||||
|
||||
// Session
|
||||
| {
|
||||
id?: string;
|
||||
type: "response";
|
||||
command: "get_session_stats";
|
||||
success: true;
|
||||
data: SessionStats;
|
||||
}
|
||||
| {
|
||||
id?: string;
|
||||
type: "response";
|
||||
command: "export_html";
|
||||
success: true;
|
||||
data: { path: string };
|
||||
}
|
||||
| {
|
||||
id?: string;
|
||||
type: "response";
|
||||
command: "switch_session";
|
||||
success: true;
|
||||
data: { cancelled: boolean };
|
||||
}
|
||||
| {
|
||||
id?: string;
|
||||
type: "response";
|
||||
command: "fork";
|
||||
success: true;
|
||||
data: { text: string; cancelled: boolean };
|
||||
}
|
||||
| {
|
||||
id?: string;
|
||||
type: "response";
|
||||
command: "get_fork_messages";
|
||||
success: true;
|
||||
data: { messages: Array<{ entryId: string; text: string }> };
|
||||
}
|
||||
| {
|
||||
id?: string;
|
||||
type: "response";
|
||||
command: "get_last_assistant_text";
|
||||
success: true;
|
||||
data: { text: string | null };
|
||||
}
|
||||
| {
|
||||
id?: string;
|
||||
type: "response";
|
||||
command: "set_session_name";
|
||||
success: true;
|
||||
}
|
||||
|
||||
// Messages
|
||||
| {
|
||||
id?: string;
|
||||
type: "response";
|
||||
command: "get_messages";
|
||||
success: true;
|
||||
data: { messages: AgentMessage[] };
|
||||
}
|
||||
|
||||
// Commands
|
||||
| {
|
||||
id?: string;
|
||||
type: "response";
|
||||
command: "get_commands";
|
||||
success: true;
|
||||
data: { commands: RpcSlashCommand[] };
|
||||
}
|
||||
|
||||
// Error response (any command can fail)
|
||||
| {
|
||||
id?: string;
|
||||
type: "response";
|
||||
command: string;
|
||||
success: false;
|
||||
error: string;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Extension UI Events (stdout)
|
||||
// ============================================================================
|
||||
|
||||
/** Emitted when an extension needs user input */
|
||||
export type RpcExtensionUIRequest =
|
||||
| {
|
||||
type: "extension_ui_request";
|
||||
id: string;
|
||||
method: "select";
|
||||
title: string;
|
||||
options: string[];
|
||||
timeout?: number;
|
||||
}
|
||||
| {
|
||||
type: "extension_ui_request";
|
||||
id: string;
|
||||
method: "confirm";
|
||||
title: string;
|
||||
message: string;
|
||||
timeout?: number;
|
||||
}
|
||||
| {
|
||||
type: "extension_ui_request";
|
||||
id: string;
|
||||
method: "input";
|
||||
title: string;
|
||||
placeholder?: string;
|
||||
timeout?: number;
|
||||
}
|
||||
| {
|
||||
type: "extension_ui_request";
|
||||
id: string;
|
||||
method: "editor";
|
||||
title: string;
|
||||
prefill?: string;
|
||||
}
|
||||
| {
|
||||
type: "extension_ui_request";
|
||||
id: string;
|
||||
method: "notify";
|
||||
message: string;
|
||||
notifyType?: "info" | "warning" | "error";
|
||||
}
|
||||
| {
|
||||
type: "extension_ui_request";
|
||||
id: string;
|
||||
method: "setStatus";
|
||||
statusKey: string;
|
||||
statusText: string | undefined;
|
||||
}
|
||||
| {
|
||||
type: "extension_ui_request";
|
||||
id: string;
|
||||
method: "setWidget";
|
||||
widgetKey: string;
|
||||
widgetLines: string[] | undefined;
|
||||
widgetPlacement?: "aboveEditor" | "belowEditor";
|
||||
}
|
||||
| {
|
||||
type: "extension_ui_request";
|
||||
id: string;
|
||||
method: "setTitle";
|
||||
title: string;
|
||||
}
|
||||
| {
|
||||
type: "extension_ui_request";
|
||||
id: string;
|
||||
method: "set_editor_text";
|
||||
text: string;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Extension UI Commands (stdin)
|
||||
// ============================================================================
|
||||
|
||||
/** Response to an extension UI request */
|
||||
export type RpcExtensionUIResponse =
|
||||
| { type: "extension_ui_response"; id: string; value: string }
|
||||
| { type: "extension_ui_response"; id: string; confirmed: boolean }
|
||||
| { type: "extension_ui_response"; id: string; cancelled: true };
|
||||
|
||||
// ============================================================================
|
||||
// Helper type for extracting command types
|
||||
// ============================================================================
|
||||
|
||||
export type RpcCommandType = RpcCommand["type"];
|
||||
Loading…
Add table
Add a link
Reference in a new issue