Merge main into bash-mode

This commit is contained in:
Mario Zechner 2025-12-08 21:39:01 +01:00
commit 1608da8770
91 changed files with 6083 additions and 1554 deletions

View file

@ -32,9 +32,10 @@ export const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = {
/**
* Calculate total context tokens from usage.
* Uses the native totalTokens field when available, falls back to computing from components.
*/
export function calculateContextTokens(usage: Usage): number {
return usage.input + usage.output + usage.cacheRead + usage.cacheWrite;
return usage.totalTokens || usage.input + usage.output + usage.cacheRead + usage.cacheWrite;
}
/**

View file

@ -12,9 +12,10 @@ const __dirname = dirname(__filename);
/**
* Detect if we're running as a Bun compiled binary.
* Bun binaries have import.meta.url containing "$bunfs" (Bun's virtual filesystem path)
* Bun binaries have import.meta.url containing "$bunfs", "~BUN", or "%7EBUN" (Bun's virtual filesystem path)
*/
export const isBunBinary = import.meta.url.includes("$bunfs");
export const isBunBinary =
import.meta.url.includes("$bunfs") || import.meta.url.includes("~BUN") || import.meta.url.includes("%7EBUN");
// =============================================================================
// Package Asset Paths (shipped with executable)

View file

@ -0,0 +1,92 @@
import { describe, expect, test } from "vitest";
import { fuzzyFilter, fuzzyMatch } from "../src/fuzzy.js";
describe("fuzzyMatch", () => {
test("empty query matches everything with score 0", () => {
const result = fuzzyMatch("", "anything");
expect(result.matches).toBe(true);
expect(result.score).toBe(0);
});
test("query longer than text does not match", () => {
const result = fuzzyMatch("longquery", "short");
expect(result.matches).toBe(false);
});
test("exact match has good score", () => {
const result = fuzzyMatch("test", "test");
expect(result.matches).toBe(true);
expect(result.score).toBeLessThan(0); // Should be negative due to consecutive bonuses
});
test("characters must appear in order", () => {
const matchInOrder = fuzzyMatch("abc", "aXbXc");
expect(matchInOrder.matches).toBe(true);
const matchOutOfOrder = fuzzyMatch("abc", "cba");
expect(matchOutOfOrder.matches).toBe(false);
});
test("case insensitive matching", () => {
const result = fuzzyMatch("ABC", "abc");
expect(result.matches).toBe(true);
const result2 = fuzzyMatch("abc", "ABC");
expect(result2.matches).toBe(true);
});
test("consecutive matches score better than scattered matches", () => {
const consecutive = fuzzyMatch("foo", "foobar");
const scattered = fuzzyMatch("foo", "f_o_o_bar");
expect(consecutive.matches).toBe(true);
expect(scattered.matches).toBe(true);
expect(consecutive.score).toBeLessThan(scattered.score);
});
test("word boundary matches score better", () => {
const atBoundary = fuzzyMatch("fb", "foo-bar");
const notAtBoundary = fuzzyMatch("fb", "afbx");
expect(atBoundary.matches).toBe(true);
expect(notAtBoundary.matches).toBe(true);
expect(atBoundary.score).toBeLessThan(notAtBoundary.score);
});
});
describe("fuzzyFilter", () => {
test("empty query returns all items unchanged", () => {
const items = ["apple", "banana", "cherry"];
const result = fuzzyFilter(items, "", (x) => x);
expect(result).toEqual(items);
});
test("filters out non-matching items", () => {
const items = ["apple", "banana", "cherry"];
const result = fuzzyFilter(items, "an", (x) => x);
expect(result).toContain("banana");
expect(result).not.toContain("apple");
expect(result).not.toContain("cherry");
});
test("sorts results by match quality", () => {
const items = ["a_p_p", "app", "application"];
const result = fuzzyFilter(items, "app", (x) => x);
// "app" should be first (exact consecutive match at start)
expect(result[0]).toBe("app");
});
test("works with custom getText function", () => {
const items = [
{ name: "foo", id: 1 },
{ name: "bar", id: 2 },
{ name: "foobar", id: 3 },
];
const result = fuzzyFilter(items, "foo", (item) => item.name);
expect(result.length).toBe(2);
expect(result.map((r) => r.name)).toContain("foo");
expect(result.map((r) => r.name)).toContain("foobar");
});
});

View file

@ -0,0 +1,83 @@
// Fuzzy search. Matches if all query characters appear in order (not necessarily consecutive).
// Lower score = better match.
export interface FuzzyMatch {
matches: boolean;
score: number;
}
export function fuzzyMatch(query: string, text: string): FuzzyMatch {
const queryLower = query.toLowerCase();
const textLower = text.toLowerCase();
if (queryLower.length === 0) {
return { matches: true, score: 0 };
}
if (queryLower.length > textLower.length) {
return { matches: false, score: 0 };
}
let queryIndex = 0;
let score = 0;
let lastMatchIndex = -1;
let consecutiveMatches = 0;
for (let i = 0; i < textLower.length && queryIndex < queryLower.length; i++) {
if (textLower[i] === queryLower[queryIndex]) {
const isWordBoundary = i === 0 || /[\s\-_./]/.test(textLower[i - 1]!);
// Reward consecutive character matches (e.g., typing "foo" matches "foobar" better than "f_o_o")
if (lastMatchIndex === i - 1) {
consecutiveMatches++;
score -= consecutiveMatches * 5;
} else {
consecutiveMatches = 0;
// Penalize gaps between matched characters
if (lastMatchIndex >= 0) {
score += (i - lastMatchIndex - 1) * 2;
}
}
// Reward matches at word boundaries (start of words are more likely intentional targets)
if (isWordBoundary) {
score -= 10;
}
// Slight penalty for matches later in the string (prefer earlier matches)
score += i * 0.1;
lastMatchIndex = i;
queryIndex++;
}
}
// Not all query characters were found in order
if (queryIndex < queryLower.length) {
return { matches: false, score: 0 };
}
return { matches: true, score };
}
// Filter and sort items by fuzzy match quality (best matches first)
export function fuzzyFilter<T>(items: T[], query: string, getText: (item: T) => string): T[] {
if (!query.trim()) {
return items;
}
const results: { item: T; score: number }[] = [];
for (const item of items) {
const text = getText(item);
const match = fuzzyMatch(query, text);
if (match.matches) {
results.push({ item, score: match.score });
}
}
// Sort ascending by score (lower = better match)
results.sort((a, b) => a.score - b.score);
return results.map((r) => r.item);
}

View file

@ -45,6 +45,7 @@ interface Args {
model?: string;
apiKey?: string;
systemPrompt?: string;
appendSystemPrompt?: string;
thinking?: ThinkingLevel;
continue?: boolean;
resume?: boolean;
@ -88,6 +89,8 @@ function parseArgs(args: string[]): Args {
result.apiKey = args[++i];
} else if (arg === "--system-prompt" && i + 1 < args.length) {
result.systemPrompt = args[++i];
} else if (arg === "--append-system-prompt" && i + 1 < args.length) {
result.appendSystemPrompt = args[++i];
} else if (arg === "--no-session") {
result.noSession = true;
} else if (arg === "--session" && i + 1 < args.length) {
@ -109,12 +112,19 @@ function parseArgs(args: string[]): Args {
result.tools = validTools;
} else if (arg === "--thinking" && i + 1 < args.length) {
const level = args[++i];
if (level === "off" || level === "minimal" || level === "low" || level === "medium" || level === "high") {
if (
level === "off" ||
level === "minimal" ||
level === "low" ||
level === "medium" ||
level === "high" ||
level === "xhigh"
) {
result.thinking = level;
} else {
console.error(
chalk.yellow(
`Warning: Invalid thinking level "${level}". Valid values: off, minimal, low, medium, high`,
`Warning: Invalid thinking level "${level}". Valid values: off, minimal, low, medium, high, xhigh`,
),
);
}
@ -231,22 +241,23 @@ ${chalk.bold("Usage:")}
${APP_NAME} [options] [@files...] [messages...]
${chalk.bold("Options:")}
--provider <name> Provider name (default: google)
--model <id> Model ID (default: gemini-2.5-flash)
--api-key <key> API key (defaults to env vars)
--system-prompt <text> System prompt (default: coding assistant prompt)
--mode <mode> Output mode: text (default), json, or rpc
--print, -p Non-interactive mode: process prompt and exit
--continue, -c Continue previous session
--resume, -r Select a session to resume
--session <path> Use specific session file
--no-session Don't save session (ephemeral)
--models <patterns> Comma-separated model patterns for quick cycling with Ctrl+P
--tools <tools> Comma-separated list of tools to enable (default: read,bash,edit,write)
Available: read, bash, edit, write, grep, find, ls
--thinking <level> Set thinking level: off, minimal, low, medium, high
--export <file> Export session file to HTML and exit
--help, -h Show this help
--provider <name> Provider name (default: google)
--model <id> Model ID (default: gemini-2.5-flash)
--api-key <key> API key (defaults to env vars)
--system-prompt <text> System prompt (default: coding assistant prompt)
--append-system-prompt <text> Append text or file contents to the system prompt
--mode <mode> Output mode: text (default), json, or rpc
--print, -p Non-interactive mode: process prompt and exit
--continue, -c Continue previous session
--resume, -r Select a session to resume
--session <path> Use specific session file
--no-session Don't save session (ephemeral)
--models <patterns> Comma-separated model patterns for quick cycling with Ctrl+P
--tools <tools> Comma-separated list of tools to enable (default: read,bash,edit,write)
Available: read, bash, edit, write, grep, find, ls
--thinking <level> Set thinking level: off, minimal, low, medium, high, xhigh
--export <file> Export session file to HTML and exit
--help, -h Show this help
${chalk.bold("Examples:")}
# Interactive mode
@ -320,32 +331,47 @@ const toolDescriptions: Record<ToolName, string> = {
ls: "List directory contents",
};
function buildSystemPrompt(customPrompt?: string, selectedTools?: ToolName[]): string {
// Check if customPrompt is a file path that exists
if (customPrompt && existsSync(customPrompt)) {
function resolvePromptInput(input: string | undefined, description: string): string | undefined {
if (!input) {
return undefined;
}
if (existsSync(input)) {
try {
customPrompt = readFileSync(customPrompt, "utf-8");
return readFileSync(input, "utf-8");
} catch (error) {
console.error(chalk.yellow(`Warning: Could not read system prompt file ${customPrompt}: ${error}`));
// Fall through to use as literal string
console.error(chalk.yellow(`Warning: Could not read ${description} file ${input}: ${error}`));
return input;
}
}
if (customPrompt) {
// Use custom prompt as base, then add context/datetime
const now = new Date();
const dateTime = now.toLocaleString("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
timeZoneName: "short",
});
return input;
}
let prompt = customPrompt;
function buildSystemPrompt(customPrompt?: string, selectedTools?: ToolName[], appendSystemPrompt?: string): string {
const resolvedCustomPrompt = resolvePromptInput(customPrompt, "system prompt");
const resolvedAppendPrompt = resolvePromptInput(appendSystemPrompt, "append system prompt");
const now = new Date();
const dateTime = now.toLocaleString("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
timeZoneName: "short",
});
const appendSection = resolvedAppendPrompt ? `\n\n${resolvedAppendPrompt}` : "";
if (resolvedCustomPrompt) {
let prompt = resolvedCustomPrompt;
if (appendSection) {
prompt += appendSection;
}
// Append project context files
const contextFiles = loadProjectContextFiles();
@ -364,18 +390,6 @@ function buildSystemPrompt(customPrompt?: string, selectedTools?: ToolName[]): s
return prompt;
}
const now = new Date();
const dateTime = now.toLocaleString("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
timeZoneName: "short",
});
// Get absolute path to README.md
const readmePath = getReadmePath();
@ -453,6 +467,10 @@ Documentation:
- Your own documentation (including custom model setup and theme creation) is at: ${readmePath}
- Read it when users ask about features, configuration, or setup, and especially if the user asks you to add a custom model or provider, or create a custom theme.`;
if (appendSection) {
prompt += appendSection;
}
// Append project context files
const contextFiles = loadProjectContextFiles();
if (contextFiles.length > 0) {
@ -582,7 +600,14 @@ async function resolveModelScope(
if (parts.length > 1) {
const level = parts[1];
if (level === "off" || level === "minimal" || level === "low" || level === "medium" || level === "high") {
if (
level === "off" ||
level === "minimal" ||
level === "low" ||
level === "medium" ||
level === "high" ||
level === "xhigh"
) {
thinkingLevel = level;
} else {
console.warn(
@ -705,8 +730,9 @@ async function runInteractiveMode(
settingsManager: SettingsManager,
version: string,
changelogMarkdown: string | null = null,
collapseChangelog = false,
modelFallbackMessage: string | null = null,
newVersion: string | null = null,
versionCheckPromise: Promise<string | null>,
scopedModels: Array<{ model: Model<Api>; thinkingLevel: ThinkingLevel }> = [],
initialMessages: string[] = [],
initialMessage?: string,
@ -719,7 +745,7 @@ async function runInteractiveMode(
settingsManager,
version,
changelogMarkdown,
newVersion,
collapseChangelog,
scopedModels,
fdPath,
);
@ -727,6 +753,13 @@ async function runInteractiveMode(
// Initialize TUI (subscribes to agent events internally)
await renderer.init();
// Handle version check result when it completes (don't block)
versionCheckPromise.then((newVersion) => {
if (newVersion) {
renderer.showNewVersionNotification(newVersion);
}
});
// Render any existing messages (from --continue mode)
renderer.renderInitialMessages(agent.state);
@ -806,7 +839,15 @@ async function runSingleShotMode(
if (mode === "text") {
const lastMessage = agent.state.messages[agent.state.messages.length - 1];
if (lastMessage.role === "assistant") {
for (const content of lastMessage.content) {
const assistantMsg = lastMessage as AssistantMessage;
// Check for error/aborted and output error message
if (assistantMsg.stopReason === "error" || assistantMsg.stopReason === "aborted") {
console.error(assistantMsg.errorMessage || `Request ${assistantMsg.stopReason}`);
process.exit(1);
}
for (const content of assistantMsg.content) {
if (content.type === "text") {
console.log(content.text);
}
@ -1138,7 +1179,7 @@ export async function main(args: string[]) {
}
}
const systemPrompt = buildSystemPrompt(parsed.systemPrompt, parsed.tools);
const systemPrompt = buildSystemPrompt(parsed.systemPrompt, parsed.tools, parsed.appendSystemPrompt);
// Load previous messages if continuing or resuming
// This may update initialModel if restoring from session
@ -1315,16 +1356,8 @@ export async function main(args: string[]) {
// RPC mode - headless operation
await runRpcMode(agent, sessionManager, settingsManager);
} else if (isInteractive) {
// Check for new version (don't block startup if it takes too long)
let newVersion: string | null = null;
try {
newVersion = await Promise.race([
checkForNewVersion(VERSION),
new Promise<null>((resolve) => setTimeout(() => resolve(null), 1000)), // 1 second timeout
]);
} catch (e) {
// Ignore errors
}
// Check for new version in the background (don't block startup)
const versionCheckPromise = checkForNewVersion(VERSION).catch(() => null);
// Check if we should show changelog (only in interactive mode, only for new sessions)
let changelogMarkdown: string | null = null;
@ -1368,14 +1401,16 @@ export async function main(args: string[]) {
const fdPath = await ensureTool("fd");
// Interactive mode - use TUI (may have initial messages from CLI args)
const collapseChangelog = settingsManager.getCollapseChangelog();
await runInteractiveMode(
agent,
sessionManager,
settingsManager,
VERSION,
changelogMarkdown,
collapseChangelog,
modelFallbackMessage,
newVersion,
versionCheckPromise,
scopedModels,
parsed.messages,
initialMessage,

View file

@ -9,6 +9,14 @@ import { loadOAuthCredentials } from "./oauth/storage.js";
// Handle both default and named exports
const Ajv = (AjvModule as any).default || AjvModule;
// Schema for OpenAI compatibility settings
const OpenAICompatSchema = Type.Object({
supportsStore: Type.Optional(Type.Boolean()),
supportsDeveloperRole: Type.Optional(Type.Boolean()),
supportsReasoningEffort: Type.Optional(Type.Boolean()),
maxTokensField: Type.Optional(Type.Union([Type.Literal("max_completion_tokens"), Type.Literal("max_tokens")])),
});
// Schema for custom model definition
const ModelDefinitionSchema = Type.Object({
id: Type.String({ minLength: 1 }),
@ -32,6 +40,7 @@ const ModelDefinitionSchema = Type.Object({
contextWindow: Type.Number(),
maxTokens: Type.Number(),
headers: Type.Optional(Type.Record(Type.String(), Type.String())),
compat: Type.Optional(OpenAICompatSchema),
});
const ProviderConfigSchema = Type.Object({
@ -46,6 +55,7 @@ const ProviderConfigSchema = Type.Object({
]),
),
headers: Type.Optional(Type.Record(Type.String(), Type.String())),
authHeader: Type.Optional(Type.Boolean()),
models: Type.Array(ModelDefinitionSchema),
});
@ -177,9 +187,17 @@ function parseModels(config: ModelsConfig): Model<Api>[] {
}
// Merge headers: provider headers are base, model headers override
const headers =
let headers =
providerConfig.headers || modelDef.headers ? { ...providerConfig.headers, ...modelDef.headers } : undefined;
// If authHeader is true, add Authorization header with resolved API key
if (providerConfig.authHeader) {
const resolvedKey = resolveApiKey(providerConfig.apiKey);
if (resolvedKey) {
headers = { ...headers, Authorization: `Bearer ${resolvedKey}` };
}
}
models.push({
id: modelDef.id,
name: modelDef.name,
@ -192,7 +210,8 @@ function parseModels(config: ModelsConfig): Model<Api>[] {
contextWindow: modelDef.contextWindow,
maxTokens: modelDef.maxTokens,
headers,
});
compat: modelDef.compat,
} as Model<Api>);
}
}

View file

@ -12,10 +12,13 @@ export interface Settings {
lastChangelogVersion?: string;
defaultProvider?: string;
defaultModel?: string;
defaultThinkingLevel?: "off" | "minimal" | "low" | "medium" | "high";
defaultThinkingLevel?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
queueMode?: "all" | "one-at-a-time";
theme?: string;
compaction?: CompactionSettings;
hideThinkingBlock?: boolean;
shellPath?: string; // Custom shell path (e.g., for Cygwin users on Windows)
collapseChangelog?: boolean; // Show condensed changelog after update (use /changelog for full)
}
export class SettingsManager {
@ -107,11 +110,11 @@ export class SettingsManager {
this.save();
}
getDefaultThinkingLevel(): "off" | "minimal" | "low" | "medium" | "high" | undefined {
getDefaultThinkingLevel(): "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | undefined {
return this.settings.defaultThinkingLevel;
}
setDefaultThinkingLevel(level: "off" | "minimal" | "low" | "medium" | "high"): void {
setDefaultThinkingLevel(level: "off" | "minimal" | "low" | "medium" | "high" | "xhigh"): void {
this.settings.defaultThinkingLevel = level;
this.save();
}
@ -143,4 +146,31 @@ export class SettingsManager {
keepRecentTokens: this.getCompactionKeepRecentTokens(),
};
}
getHideThinkingBlock(): boolean {
return this.settings.hideThinkingBlock ?? false;
}
setHideThinkingBlock(hide: boolean): void {
this.settings.hideThinkingBlock = hide;
this.save();
}
getShellPath(): string | undefined {
return this.settings.shellPath;
}
setShellPath(path: string | undefined): void {
this.settings.shellPath = path;
this.save();
}
getCollapseChangelog(): boolean {
return this.settings.collapseChangelog ?? false;
}
setCollapseChangelog(collapse: boolean): void {
this.settings.collapseChangelog = collapse;
this.save();
}
}

View file

@ -1,30 +0,0 @@
import { existsSync } from "fs";
/**
* Get shell configuration based on platform
*/
export function getShellConfig(): { shell: string; args: string[] } {
if (process.platform === "win32") {
const paths: string[] = [];
const programFiles = process.env.ProgramFiles;
if (programFiles) {
paths.push(`${programFiles}\\Git\\bin\\bash.exe`);
}
const programFilesX86 = process.env["ProgramFiles(x86)"];
if (programFilesX86) {
paths.push(`${programFilesX86}\\Git\\bin\\bash.exe`);
}
for (const path of paths) {
if (existsSync(path)) {
return { shell: path, args: ["-c"] };
}
}
throw new Error(
`Git Bash not found. Please install Git for Windows from https://git-scm.com/download/win\n` +
`Searched in:\n${paths.map((p) => ` ${p}`).join("\n")}`,
);
}
return { shell: "sh", args: ["-c"] };
}

View file

@ -0,0 +1,117 @@
import { existsSync } from "node:fs";
import { spawn, spawnSync } from "child_process";
import { SettingsManager } from "./settings-manager.js";
let cachedShellConfig: { shell: string; args: string[] } | null = null;
/**
* Find bash executable on PATH (Windows)
*/
function findBashOnPath(): string | null {
try {
const result = spawnSync("where", ["bash.exe"], { encoding: "utf-8", timeout: 5000 });
if (result.status === 0 && result.stdout) {
const firstMatch = result.stdout.trim().split(/\r?\n/)[0];
if (firstMatch && existsSync(firstMatch)) {
return firstMatch;
}
}
} catch {
// Ignore errors
}
return null;
}
/**
* Get shell configuration based on platform.
* Resolution order:
* 1. User-specified shellPath in settings.json
* 2. On Windows: Git Bash in known locations
* 3. Fallback: bash on PATH (Windows) or sh (Unix)
*/
export function getShellConfig(): { shell: string; args: string[] } {
if (cachedShellConfig) {
return cachedShellConfig;
}
const settings = new SettingsManager();
const customShellPath = settings.getShellPath();
// 1. Check user-specified shell path
if (customShellPath) {
if (existsSync(customShellPath)) {
cachedShellConfig = { shell: customShellPath, args: ["-c"] };
return cachedShellConfig;
}
throw new Error(
`Custom shell path not found: ${customShellPath}\n` + `Please update shellPath in ~/.pi/agent/settings.json`,
);
}
if (process.platform === "win32") {
// 2. Try Git Bash in known locations
const paths: string[] = [];
const programFiles = process.env.ProgramFiles;
if (programFiles) {
paths.push(`${programFiles}\\Git\\bin\\bash.exe`);
}
const programFilesX86 = process.env["ProgramFiles(x86)"];
if (programFilesX86) {
paths.push(`${programFilesX86}\\Git\\bin\\bash.exe`);
}
for (const path of paths) {
if (existsSync(path)) {
cachedShellConfig = { shell: path, args: ["-c"] };
return cachedShellConfig;
}
}
// 3. Fallback: search bash.exe on PATH (Cygwin, MSYS2, WSL, etc.)
const bashOnPath = findBashOnPath();
if (bashOnPath) {
cachedShellConfig = { shell: bashOnPath, args: ["-c"] };
return cachedShellConfig;
}
throw new Error(
`No bash shell found. Options:\n` +
` 1. Install Git for Windows: https://git-scm.com/download/win\n` +
` 2. Add your bash to PATH (Cygwin, MSYS2, etc.)\n` +
` 3. Set shellPath in ~/.pi/agent/settings.json\n\n` +
`Searched Git Bash in:\n${paths.map((p) => ` ${p}`).join("\n")}`,
);
}
cachedShellConfig = { shell: "sh", args: ["-c"] };
return cachedShellConfig;
}
/**
* Kill a process and all its children (cross-platform)
*/
export function killProcessTree(pid: number): void {
if (process.platform === "win32") {
// Use taskkill on Windows to kill process tree
try {
spawn("taskkill", ["/F", "/T", "/PID", String(pid)], {
stdio: "ignore",
detached: true,
});
} catch {
// Ignore errors if taskkill fails
}
} else {
// Use SIGKILL on Unix/Linux/Mac
try {
process.kill(-pid, "SIGKILL");
} catch {
// Fallback to killing just the child if process group kill fails
try {
process.kill(pid, "SIGKILL");
} catch {
// Process already dead
}
}
}
}

View file

@ -66,6 +66,7 @@
"thinkingLow": "#5f87af",
"thinkingMedium": "#81a2be",
"thinkingHigh": "#b294bb",
"thinkingXhigh": "#d183e8",
"bashMode": "green"
}

View file

@ -65,6 +65,7 @@
"thinkingLow": "#5f87af",
"thinkingMedium": "#5f8787",
"thinkingHigh": "#875f87",
"thinkingXhigh": "#8b008b",
"bashMode": "green"
}

View file

@ -242,6 +242,10 @@
"$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"

View file

@ -66,12 +66,13 @@ const ThemeJsonSchema = Type.Object({
syntaxType: ColorValueSchema,
syntaxOperator: ColorValueSchema,
syntaxPunctuation: ColorValueSchema,
// Thinking Level Borders (5 colors)
// Thinking Level Borders (6 colors)
thinkingOff: ColorValueSchema,
thinkingMinimal: ColorValueSchema,
thinkingLow: ColorValueSchema,
thinkingMedium: ColorValueSchema,
thinkingHigh: ColorValueSchema,
thinkingXhigh: ColorValueSchema,
// Bash Mode (1 color)
bashMode: ColorValueSchema,
}),
@ -122,6 +123,7 @@ export type ThemeColor =
| "thinkingLow"
| "thinkingMedium"
| "thinkingHigh"
| "thinkingXhigh"
| "bashMode";
export type ThemeBg = "userMessageBg" | "toolPendingBg" | "toolSuccessBg" | "toolErrorBg";
@ -298,7 +300,7 @@ export class Theme {
return this.mode;
}
getThinkingBorderColor(level: "off" | "minimal" | "low" | "medium" | "high"): (str: string) => string {
getThinkingBorderColor(level: "off" | "minimal" | "low" | "medium" | "high" | "xhigh"): (str: string) => string {
// Map thinking levels to dedicated theme colors
switch (level) {
case "off":
@ -311,6 +313,8 @@ export class Theme {
return (str: string) => this.fg("thinkingMedium", str);
case "high":
return (str: string) => this.fg("thinkingHigh", str);
case "xhigh":
return (str: string) => this.fg("thinkingXhigh", str);
default:
return (str: string) => this.fg("thinkingOff", str);
}
@ -373,8 +377,31 @@ function loadThemeJson(name: string): ThemeJson {
}
if (!validateThemeJson.Check(json)) {
const errors = Array.from(validateThemeJson.Errors(json));
const errorMessages = errors.map((e) => ` - ${e.path}: ${e.message}`).join("\n");
throw new Error(`Invalid theme ${name}:\n${errorMessages}`);
const missingColors: string[] = [];
const otherErrors: string[] = [];
for (const e of errors) {
// Check for missing required color properties
const match = e.path.match(/^\/colors\/(\w+)$/);
if (match && e.message.includes("Required")) {
missingColors.push(match[1]);
} else {
otherErrors.push(` - ${e.path}: ${e.message}`);
}
}
let errorMessage = `Invalid theme "${name}":\n`;
if (missingColors.length > 0) {
errorMessage += `\nMissing required color tokens:\n`;
errorMessage += missingColors.map((c) => ` - ${c}`).join("\n");
errorMessage += `\n\nPlease add these colors to your theme's "colors" object.`;
errorMessage += `\nSee the built-in themes (dark.json, light.json) for reference values.`;
}
if (otherErrors.length > 0) {
errorMessage += `\n\nOther errors:\n${otherErrors.join("\n")}`;
}
throw new Error(errorMessage);
}
return json as ThemeJson;
}

View file

@ -1,35 +1,19 @@
import { randomBytes } from "node:crypto";
import { createWriteStream } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { AgentTool } from "@mariozechner/pi-ai";
import { Type } from "@sinclair/typebox";
import { spawn } from "child_process";
import { getShellConfig } from "../shell-config.js";
import { getShellConfig, killProcessTree } from "../shell.js";
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateTail } from "./truncate.js";
/**
* Kill a process and all its children
* Generate a unique temp file path for bash output
*/
function killProcessTree(pid: number): void {
if (process.platform === "win32") {
// Use taskkill on Windows to kill process tree
try {
spawn("taskkill", ["/F", "/T", "/PID", String(pid)], {
stdio: "ignore",
detached: true,
});
} catch (e) {
// Ignore errors if taskkill fails
}
} else {
// Use SIGKILL on Unix/Linux/Mac
try {
process.kill(-pid, "SIGKILL");
} catch (e) {
// Fallback to killing just the child if process group kill fails
try {
process.kill(pid, "SIGKILL");
} catch (e2) {
// Process already dead
}
}
}
function getTempFilePath(): string {
const id = randomBytes(8).toString("hex");
return join(tmpdir(), `pi-bash-${id}.log`);
}
const bashSchema = Type.Object({
@ -37,26 +21,39 @@ const bashSchema = Type.Object({
timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (optional, no default timeout)" })),
});
interface BashToolDetails {
truncation?: TruncationResult;
fullOutputPath?: string;
}
export const bashTool: AgentTool<typeof bashSchema> = {
name: "bash",
label: "bash",
description:
"Execute a bash command in the current working directory. Returns stdout and stderr. Optionally provide a timeout in seconds.",
description: `Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.`,
parameters: bashSchema,
execute: async (
_toolCallId: string,
{ command, timeout }: { command: string; timeout?: number },
signal?: AbortSignal,
) => {
return new Promise((resolve, _reject) => {
return new Promise((resolve, reject) => {
const { shell, args } = getShellConfig();
const child = spawn(shell, [...args, command], {
detached: true,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
// We'll stream to a temp file if output gets large
let tempFilePath: string | undefined;
let tempFileStream: ReturnType<typeof createWriteStream> | undefined;
let totalBytes = 0;
// Keep a rolling buffer of the last chunk for tail truncation
const chunks: Buffer[] = [];
let chunksBytes = 0;
// Keep more than we need so we have enough for truncation
const maxChunksBytes = DEFAULT_MAX_BYTES * 2;
let timedOut = false;
// Set timeout if provided
@ -68,26 +65,41 @@ export const bashTool: AgentTool<typeof bashSchema> = {
}, timeout * 1000);
}
// Collect stdout
if (child.stdout) {
child.stdout.on("data", (data) => {
stdout += data.toString();
// Limit buffer size
if (stdout.length > 10 * 1024 * 1024) {
stdout = stdout.slice(0, 10 * 1024 * 1024);
}
});
}
const handleData = (data: Buffer) => {
totalBytes += data.length;
// Collect stderr
if (child.stderr) {
child.stderr.on("data", (data) => {
stderr += data.toString();
// Limit buffer size
if (stderr.length > 10 * 1024 * 1024) {
stderr = stderr.slice(0, 10 * 1024 * 1024);
// Start writing to temp file once we exceed the threshold
if (totalBytes > DEFAULT_MAX_BYTES && !tempFilePath) {
tempFilePath = getTempFilePath();
tempFileStream = createWriteStream(tempFilePath);
// Write all buffered chunks to the file
for (const chunk of chunks) {
tempFileStream.write(chunk);
}
});
}
// Write to temp file if we have one
if (tempFileStream) {
tempFileStream.write(data);
}
// Keep rolling buffer of recent data
chunks.push(data);
chunksBytes += data.length;
// Trim old chunks if buffer is too large
while (chunksBytes > maxChunksBytes && chunks.length > 1) {
const removed = chunks.shift()!;
chunksBytes -= removed.length;
}
};
// Collect stdout and stderr together
if (child.stdout) {
child.stdout.on("data", handleData);
}
if (child.stderr) {
child.stderr.on("data", handleData);
}
// Handle process exit
@ -99,44 +111,64 @@ export const bashTool: AgentTool<typeof bashSchema> = {
signal.removeEventListener("abort", onAbort);
}
// Close temp file stream
if (tempFileStream) {
tempFileStream.end();
}
// Combine all buffered chunks
const fullBuffer = Buffer.concat(chunks);
const fullOutput = fullBuffer.toString("utf-8");
if (signal?.aborted) {
let output = "";
if (stdout) output += stdout;
if (stderr) {
if (output) output += "\n";
output += stderr;
}
let output = fullOutput;
if (output) output += "\n\n";
output += "Command aborted";
_reject(new Error(output));
reject(new Error(output));
return;
}
if (timedOut) {
let output = "";
if (stdout) output += stdout;
if (stderr) {
if (output) output += "\n";
output += stderr;
}
let output = fullOutput;
if (output) output += "\n\n";
output += `Command timed out after ${timeout} seconds`;
_reject(new Error(output));
reject(new Error(output));
return;
}
let output = "";
if (stdout) output += stdout;
if (stderr) {
if (output) output += "\n";
output += stderr;
// Apply tail truncation
const truncation = truncateTail(fullOutput);
let outputText = truncation.content || "(no output)";
// Build details with truncation info
let details: BashToolDetails | undefined;
if (truncation.truncated) {
details = {
truncation,
fullOutputPath: tempFilePath,
};
// Build actionable notice
const startLine = truncation.totalLines - truncation.outputLines + 1;
const endLine = truncation.totalLines;
if (truncation.lastLinePartial) {
// Edge case: last line alone > 30KB
const lastLineSize = formatSize(Buffer.byteLength(fullOutput.split("\n").pop() || "", "utf-8"));
outputText += `\n\n[Showing last ${formatSize(truncation.outputBytes)} of line ${endLine} (line is ${lastLineSize}). Full output: ${tempFilePath}]`;
} else if (truncation.truncatedBy === "lines") {
outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}. Full output: ${tempFilePath}]`;
} else {
outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Full output: ${tempFilePath}]`;
}
}
if (code !== 0 && code !== null) {
if (output) output += "\n\n";
_reject(new Error(`${output}Command exited with code ${code}`));
outputText += `\n\nCommand exited with code ${code}`;
reject(new Error(outputText));
} else {
resolve({ content: [{ type: "text", text: output || "(no output)" }], details: undefined });
resolve({ content: [{ type: "text", text: outputText }], details });
}
});

View file

@ -6,6 +6,7 @@ import { globSync } from "glob";
import { homedir } from "os";
import path from "path";
import { ensureTool } from "../tools-manager.js";
import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateHead } from "./truncate.js";
/**
* Expand ~ to home directory
@ -30,11 +31,15 @@ const findSchema = Type.Object({
const DEFAULT_LIMIT = 1000;
interface FindToolDetails {
truncation?: TruncationResult;
resultLimitReached?: number;
}
export const findTool: AgentTool<typeof findSchema> = {
name: "find",
label: "find",
description:
"Search for files by glob pattern. Returns matching file paths relative to the search directory. Respects .gitignore.",
description: `Search for files by glob pattern. Returns matching file paths relative to the search directory. Respects .gitignore. Output is truncated to ${DEFAULT_LIMIT} results or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first).`,
parameters: findSchema,
execute: async (
_toolCallId: string,
@ -112,7 +117,7 @@ export const findTool: AgentTool<typeof findSchema> = {
return;
}
let output = result.stdout?.trim() || "";
const output = result.stdout?.trim() || "";
if (result.status !== 0) {
const errorMsg = result.stderr?.trim() || `fd exited with code ${result.status}`;
@ -124,41 +129,70 @@ export const findTool: AgentTool<typeof findSchema> = {
}
if (!output) {
output = "No files found matching pattern";
} else {
const lines = output.split("\n");
const relativized: string[] = [];
for (const rawLine of lines) {
const line = rawLine.replace(/\r$/, "").trim();
if (!line) {
continue;
}
const hadTrailingSlash = line.endsWith("/") || line.endsWith("\\");
let relativePath = line;
if (line.startsWith(searchPath)) {
relativePath = line.slice(searchPath.length + 1); // +1 for the /
} else {
relativePath = path.relative(searchPath, line);
}
if (hadTrailingSlash && !relativePath.endsWith("/")) {
relativePath += "/";
}
relativized.push(relativePath);
}
output = relativized.join("\n");
const count = relativized.length;
if (count >= effectiveLimit) {
output += `\n\n(truncated, ${effectiveLimit} results shown)`;
}
resolve({
content: [{ type: "text", text: "No files found matching pattern" }],
details: undefined,
});
return;
}
resolve({ content: [{ type: "text", text: output }], details: undefined });
const lines = output.split("\n");
const relativized: string[] = [];
for (const rawLine of lines) {
const line = rawLine.replace(/\r$/, "").trim();
if (!line) {
continue;
}
const hadTrailingSlash = line.endsWith("/") || line.endsWith("\\");
let relativePath = line;
if (line.startsWith(searchPath)) {
relativePath = line.slice(searchPath.length + 1); // +1 for the /
} else {
relativePath = path.relative(searchPath, line);
}
if (hadTrailingSlash && !relativePath.endsWith("/")) {
relativePath += "/";
}
relativized.push(relativePath);
}
// Check if we hit the result limit
const resultLimitReached = relativized.length >= effectiveLimit;
// Apply byte truncation (no line limit since we already have result limit)
const rawOutput = relativized.join("\n");
const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
let resultOutput = truncation.content;
const details: FindToolDetails = {};
// Build notices
const notices: string[] = [];
if (resultLimitReached) {
notices.push(
`${effectiveLimit} results limit reached. Use limit=${effectiveLimit * 2} for more, or refine pattern`,
);
details.resultLimitReached = effectiveLimit;
}
if (truncation.truncated) {
notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
details.truncation = truncation;
}
if (notices.length > 0) {
resultOutput += `\n\n[${notices.join(". ")}]`;
}
resolve({
content: [{ type: "text", text: resultOutput }],
details: Object.keys(details).length > 0 ? details : undefined,
});
} catch (e: any) {
signal?.removeEventListener("abort", onAbort);
reject(e);

View file

@ -6,6 +6,14 @@ import { readFileSync, type Stats, statSync } from "fs";
import { homedir } from "os";
import path from "path";
import { ensureTool } from "../tools-manager.js";
import {
DEFAULT_MAX_BYTES,
formatSize,
GREP_MAX_LINE_LENGTH,
type TruncationResult,
truncateHead,
truncateLine,
} from "./truncate.js";
/**
* Expand ~ to home directory
@ -36,11 +44,16 @@ const grepSchema = Type.Object({
const DEFAULT_LIMIT = 100;
interface GrepToolDetails {
truncation?: TruncationResult;
matchLimitReached?: number;
linesTruncated?: boolean;
}
export const grepTool: AgentTool<typeof grepSchema> = {
name: "grep",
label: "grep",
description:
"Search file contents for a pattern. Returns matching lines with file paths and line numbers. Respects .gitignore.",
description: `Search file contents for a pattern. Returns matching lines with file paths and line numbers. Respects .gitignore. Output is truncated to ${DEFAULT_LIMIT} matches or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). Long lines are truncated to ${GREP_MAX_LINE_LENGTH} chars.`,
parameters: grepSchema,
execute: async (
_toolCallId: string,
@ -143,7 +156,8 @@ export const grepTool: AgentTool<typeof grepSchema> = {
const rl = createInterface({ input: child.stdout });
let stderr = "";
let matchCount = 0;
let truncated = false;
let matchLimitReached = false;
let linesTruncated = false;
let aborted = false;
let killedDueToLimit = false;
const outputLines: string[] = [];
@ -171,7 +185,7 @@ export const grepTool: AgentTool<typeof grepSchema> = {
stderr += chunk.toString();
});
const formatBlock = (filePath: string, lineNumber: number) => {
const formatBlock = (filePath: string, lineNumber: number): string[] => {
const relativePath = formatPath(filePath);
const lines = getFileLines(filePath);
if (!lines.length) {
@ -187,10 +201,16 @@ export const grepTool: AgentTool<typeof grepSchema> = {
const sanitized = lineText.replace(/\r/g, "");
const isMatchLine = current === lineNumber;
// Truncate long lines
const { text: truncatedText, wasTruncated } = truncateLine(sanitized);
if (wasTruncated) {
linesTruncated = true;
}
if (isMatchLine) {
block.push(`${relativePath}:${current}: ${sanitized}`);
block.push(`${relativePath}:${current}: ${truncatedText}`);
} else {
block.push(`${relativePath}-${current}- ${sanitized}`);
block.push(`${relativePath}-${current}- ${truncatedText}`);
}
}
@ -219,7 +239,7 @@ export const grepTool: AgentTool<typeof grepSchema> = {
}
if (matchCount >= effectiveLimit) {
truncated = true;
matchLimitReached = true;
stopChild(true);
}
}
@ -251,12 +271,45 @@ export const grepTool: AgentTool<typeof grepSchema> = {
return;
}
let output = outputLines.join("\n");
if (truncated) {
output += `\n\n(truncated, limit of ${effectiveLimit} matches reached)`;
// Apply byte truncation (no line limit since we already have match limit)
const rawOutput = outputLines.join("\n");
const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
let output = truncation.content;
const details: GrepToolDetails = {};
// Build notices
const notices: string[] = [];
if (matchLimitReached) {
notices.push(
`${effectiveLimit} matches limit reached. Use limit=${effectiveLimit * 2} for more, or refine pattern`,
);
details.matchLimitReached = effectiveLimit;
}
settle(() => resolve({ content: [{ type: "text", text: output }], details: undefined }));
if (truncation.truncated) {
notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
details.truncation = truncation;
}
if (linesTruncated) {
notices.push(
`Some lines truncated to ${GREP_MAX_LINE_LENGTH} chars. Use read tool to see full lines`,
);
details.linesTruncated = true;
}
if (notices.length > 0) {
output += `\n\n[${notices.join(". ")}]`;
}
settle(() =>
resolve({
content: [{ type: "text", text: output }],
details: Object.keys(details).length > 0 ? details : undefined,
}),
);
});
} catch (err) {
settle(() => reject(err as Error));

View file

@ -3,6 +3,7 @@ import { Type } from "@sinclair/typebox";
import { existsSync, readdirSync, statSync } from "fs";
import { homedir } from "os";
import nodePath from "path";
import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateHead } from "./truncate.js";
/**
* Expand ~ to home directory
@ -24,11 +25,15 @@ const lsSchema = Type.Object({
const DEFAULT_LIMIT = 500;
interface LsToolDetails {
truncation?: TruncationResult;
entryLimitReached?: number;
}
export const lsTool: AgentTool<typeof lsSchema> = {
name: "ls",
label: "ls",
description:
"List directory contents. Returns entries sorted alphabetically, with '/' suffix for directories. Includes dotfiles.",
description: `List directory contents. Returns entries sorted alphabetically, with '/' suffix for directories. Includes dotfiles. Output is truncated to ${DEFAULT_LIMIT} entries or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first).`,
parameters: lsSchema,
execute: async (_toolCallId: string, { path, limit }: { path?: string; limit?: number }, signal?: AbortSignal) => {
return new Promise((resolve, reject) => {
@ -71,11 +76,11 @@ export const lsTool: AgentTool<typeof lsSchema> = {
// Format entries with directory indicators
const results: string[] = [];
let truncated = false;
let entryLimitReached = false;
for (const entry of entries) {
if (results.length >= effectiveLimit) {
truncated = true;
entryLimitReached = true;
break;
}
@ -97,16 +102,39 @@ export const lsTool: AgentTool<typeof lsSchema> = {
signal?.removeEventListener("abort", onAbort);
let output = results.join("\n");
if (truncated) {
const remaining = entries.length - effectiveLimit;
output += `\n\n(truncated, ${remaining} more entries)`;
}
if (results.length === 0) {
output = "(empty directory)";
resolve({ content: [{ type: "text", text: "(empty directory)" }], details: undefined });
return;
}
resolve({ content: [{ type: "text", text: output }], details: undefined });
// Apply byte truncation (no line limit since we already have entry limit)
const rawOutput = results.join("\n");
const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
let output = truncation.content;
const details: LsToolDetails = {};
// Build notices
const notices: string[] = [];
if (entryLimitReached) {
notices.push(`${effectiveLimit} entries limit reached. Use limit=${effectiveLimit * 2} for more`);
details.entryLimitReached = effectiveLimit;
}
if (truncation.truncated) {
notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
details.truncation = truncation;
}
if (notices.length > 0) {
output += `\n\n[${notices.join(". ")}]`;
}
resolve({
content: [{ type: "text", text: output }],
details: Object.keys(details).length > 0 ? details : undefined,
});
} catch (e: any) {
signal?.removeEventListener("abort", onAbort);
reject(e);

View file

@ -4,6 +4,7 @@ import { Type } from "@sinclair/typebox";
import { constants } from "fs";
import { access, readFile } from "fs/promises";
import { extname, resolve as resolvePath } from "path";
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateHead } from "./truncate.js";
/**
* Expand ~ to home directory
@ -43,14 +44,14 @@ const readSchema = Type.Object({
limit: Type.Optional(Type.Number({ description: "Maximum number of lines to read" })),
});
const MAX_LINES = 2000;
const MAX_LINE_LENGTH = 2000;
interface ReadToolDetails {
truncation?: TruncationResult;
}
export const readTool: AgentTool<typeof readSchema> = {
name: "read",
label: "read",
description:
"Read the contents of a file. Supports text files and images (jpg, png, gif, webp). Images are sent as attachments. For text files, defaults to first 2000 lines. Use offset/limit for large files.",
description: `Read the contents of a file. Supports text files and images (jpg, png, gif, webp). Images are sent as attachments. For text files, output is truncated to ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). Use offset/limit for large files.`,
parameters: readSchema,
execute: async (
_toolCallId: string,
@ -60,119 +61,138 @@ export const readTool: AgentTool<typeof readSchema> = {
const absolutePath = resolvePath(expandPath(path));
const mimeType = isImageFile(absolutePath);
return new Promise<{ content: (TextContent | ImageContent)[]; details: undefined }>((resolve, reject) => {
// Check if already aborted
if (signal?.aborted) {
reject(new Error("Operation aborted"));
return;
}
let aborted = false;
// Set up abort handler
const onAbort = () => {
aborted = true;
reject(new Error("Operation aborted"));
};
if (signal) {
signal.addEventListener("abort", onAbort, { once: true });
}
// Perform the read operation
(async () => {
try {
// Check if file exists
await access(absolutePath, constants.R_OK);
// Check if aborted before reading
if (aborted) {
return;
}
// Read the file based on type
let content: (TextContent | ImageContent)[];
if (mimeType) {
// Read as image (binary)
const buffer = await readFile(absolutePath);
const base64 = buffer.toString("base64");
content = [
{ type: "text", text: `Read image file [${mimeType}]` },
{ type: "image", data: base64, mimeType },
];
} else {
// Read as text
const textContent = await readFile(absolutePath, "utf-8");
const lines = textContent.split("\n");
// Apply offset and limit (matching Claude Code Read tool behavior)
const startLine = offset ? Math.max(0, offset - 1) : 0; // 1-indexed to 0-indexed
const maxLines = limit || MAX_LINES;
const endLine = Math.min(startLine + maxLines, lines.length);
// Check if offset is out of bounds
if (startLine >= lines.length) {
throw new Error(`Offset ${offset} is beyond end of file (${lines.length} lines total)`);
}
// Get the relevant lines
const selectedLines = lines.slice(startLine, endLine);
// Truncate long lines and track which were truncated
let hadTruncatedLines = false;
const formattedLines = selectedLines.map((line) => {
if (line.length > MAX_LINE_LENGTH) {
hadTruncatedLines = true;
return line.slice(0, MAX_LINE_LENGTH);
}
return line;
});
let outputText = formattedLines.join("\n");
// Add notices
const notices: string[] = [];
if (hadTruncatedLines) {
notices.push(`Some lines were truncated to ${MAX_LINE_LENGTH} characters for display`);
}
if (endLine < lines.length) {
const remaining = lines.length - endLine;
notices.push(`${remaining} more lines not shown. Use offset=${endLine + 1} to continue reading`);
}
if (notices.length > 0) {
outputText += `\n\n... (${notices.join(". ")})`;
}
content = [{ type: "text", text: outputText }];
}
// Check if aborted after reading
if (aborted) {
return;
}
// Clean up abort handler
if (signal) {
signal.removeEventListener("abort", onAbort);
}
resolve({ content, details: undefined });
} catch (error: any) {
// Clean up abort handler
if (signal) {
signal.removeEventListener("abort", onAbort);
}
if (!aborted) {
reject(error);
}
return new Promise<{ content: (TextContent | ImageContent)[]; details: ReadToolDetails | undefined }>(
(resolve, reject) => {
// Check if already aborted
if (signal?.aborted) {
reject(new Error("Operation aborted"));
return;
}
})();
});
let aborted = false;
// Set up abort handler
const onAbort = () => {
aborted = true;
reject(new Error("Operation aborted"));
};
if (signal) {
signal.addEventListener("abort", onAbort, { once: true });
}
// Perform the read operation
(async () => {
try {
// Check if file exists
await access(absolutePath, constants.R_OK);
// Check if aborted before reading
if (aborted) {
return;
}
// Read the file based on type
let content: (TextContent | ImageContent)[];
let details: ReadToolDetails | undefined;
if (mimeType) {
// Read as image (binary)
const buffer = await readFile(absolutePath);
const base64 = buffer.toString("base64");
content = [
{ type: "text", text: `Read image file [${mimeType}]` },
{ type: "image", data: base64, mimeType },
];
} else {
// Read as text
const textContent = await readFile(absolutePath, "utf-8");
const allLines = textContent.split("\n");
const totalFileLines = allLines.length;
// Apply offset if specified (1-indexed to 0-indexed)
const startLine = offset ? Math.max(0, offset - 1) : 0;
const startLineDisplay = startLine + 1; // For display (1-indexed)
// Check if offset is out of bounds
if (startLine >= allLines.length) {
throw new Error(`Offset ${offset} is beyond end of file (${allLines.length} lines total)`);
}
// If limit is specified by user, use it; otherwise we'll let truncateHead decide
let selectedContent: string;
let userLimitedLines: number | undefined;
if (limit !== undefined) {
const endLine = Math.min(startLine + limit, allLines.length);
selectedContent = allLines.slice(startLine, endLine).join("\n");
userLimitedLines = endLine - startLine;
} else {
selectedContent = allLines.slice(startLine).join("\n");
}
// Apply truncation (respects both line and byte limits)
const truncation = truncateHead(selectedContent);
let outputText: string;
if (truncation.firstLineExceedsLimit) {
// First line at offset exceeds 30KB - tell model to use bash
const firstLineSize = formatSize(Buffer.byteLength(allLines[startLine], "utf-8"));
outputText = `[Line ${startLineDisplay} is ${firstLineSize}, exceeds ${formatSize(DEFAULT_MAX_BYTES)} limit. Use bash: sed -n '${startLineDisplay}p' ${path} | head -c ${DEFAULT_MAX_BYTES}]`;
details = { truncation };
} else if (truncation.truncated) {
// Truncation occurred - build actionable notice
const endLineDisplay = startLineDisplay + truncation.outputLines - 1;
const nextOffset = endLineDisplay + 1;
outputText = truncation.content;
if (truncation.truncatedBy === "lines") {
outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue]`;
} else {
outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Use offset=${nextOffset} to continue]`;
}
details = { truncation };
} else if (userLimitedLines !== undefined && startLine + userLimitedLines < allLines.length) {
// User specified limit, there's more content, but no truncation
const endLineDisplay = startLineDisplay + userLimitedLines - 1;
const remaining = allLines.length - (startLine + userLimitedLines);
const nextOffset = startLine + userLimitedLines + 1;
outputText = truncation.content;
outputText += `\n\n[${remaining} more lines in file. Use offset=${nextOffset} to continue]`;
} else {
// No truncation, no user limit exceeded
outputText = truncation.content;
}
content = [{ type: "text", text: outputText }];
}
// Check if aborted after reading
if (aborted) {
return;
}
// Clean up abort handler
if (signal) {
signal.removeEventListener("abort", onAbort);
}
resolve({ content, details });
} catch (error: any) {
// Clean up abort handler
if (signal) {
signal.removeEventListener("abort", onAbort);
}
if (!aborted) {
reject(error);
}
}
})();
},
);
},
};

View file

@ -0,0 +1,251 @@
/**
* Shared truncation utilities for tool outputs.
*
* Truncation is based on two independent limits - whichever is hit first wins:
* - Line limit (default: 2000 lines)
* - Byte limit (default: 30KB)
*
* Never returns partial lines (except bash tail truncation edge case).
*/
export const DEFAULT_MAX_LINES = 2000;
export const DEFAULT_MAX_BYTES = 50 * 1024; // 50KB
export const GREP_MAX_LINE_LENGTH = 500; // Max chars per grep match line
export interface TruncationResult {
/** The truncated content */
content: string;
/** Whether truncation occurred */
truncated: boolean;
/** Which limit was hit: "lines", "bytes", or null if not truncated */
truncatedBy: "lines" | "bytes" | null;
/** Total number of lines in the original content */
totalLines: number;
/** Total number of bytes in the original content */
totalBytes: number;
/** Number of complete lines in the truncated output */
outputLines: number;
/** Number of bytes in the truncated output */
outputBytes: number;
/** Whether the last line was partially truncated (only for tail truncation edge case) */
lastLinePartial: boolean;
/** Whether the first line exceeded the byte limit (for head truncation) */
firstLineExceedsLimit: boolean;
}
export interface TruncationOptions {
/** Maximum number of lines (default: 2000) */
maxLines?: number;
/** Maximum number of bytes (default: 30KB) */
maxBytes?: number;
}
/**
* Format bytes as human-readable size.
*/
export function formatSize(bytes: number): string {
if (bytes < 1024) {
return `${bytes}B`;
} else if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)}KB`;
} else {
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
}
}
/**
* Truncate content from the head (keep first N lines/bytes).
* Suitable for file reads where you want to see the beginning.
*
* Never returns partial lines. If first line exceeds byte limit,
* returns empty content with firstLineExceedsLimit=true.
*/
export function truncateHead(content: string, options: TruncationOptions = {}): TruncationResult {
const maxLines = options.maxLines ?? DEFAULT_MAX_LINES;
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
const totalBytes = Buffer.byteLength(content, "utf-8");
const lines = content.split("\n");
const totalLines = lines.length;
// Check if no truncation needed
if (totalLines <= maxLines && totalBytes <= maxBytes) {
return {
content,
truncated: false,
truncatedBy: null,
totalLines,
totalBytes,
outputLines: totalLines,
outputBytes: totalBytes,
lastLinePartial: false,
firstLineExceedsLimit: false,
};
}
// Check if first line alone exceeds byte limit
const firstLineBytes = Buffer.byteLength(lines[0], "utf-8");
if (firstLineBytes > maxBytes) {
return {
content: "",
truncated: true,
truncatedBy: "bytes",
totalLines,
totalBytes,
outputLines: 0,
outputBytes: 0,
lastLinePartial: false,
firstLineExceedsLimit: true,
};
}
// Collect complete lines that fit
const outputLinesArr: string[] = [];
let outputBytesCount = 0;
let truncatedBy: "lines" | "bytes" = "lines";
for (let i = 0; i < lines.length && i < maxLines; i++) {
const line = lines[i];
const lineBytes = Buffer.byteLength(line, "utf-8") + (i > 0 ? 1 : 0); // +1 for newline
if (outputBytesCount + lineBytes > maxBytes) {
truncatedBy = "bytes";
break;
}
outputLinesArr.push(line);
outputBytesCount += lineBytes;
}
// If we exited due to line limit
if (outputLinesArr.length >= maxLines && outputBytesCount <= maxBytes) {
truncatedBy = "lines";
}
const outputContent = outputLinesArr.join("\n");
const finalOutputBytes = Buffer.byteLength(outputContent, "utf-8");
return {
content: outputContent,
truncated: true,
truncatedBy,
totalLines,
totalBytes,
outputLines: outputLinesArr.length,
outputBytes: finalOutputBytes,
lastLinePartial: false,
firstLineExceedsLimit: false,
};
}
/**
* Truncate content from the tail (keep last N lines/bytes).
* Suitable for bash output where you want to see the end (errors, final results).
*
* May return partial first line if the last line of original content exceeds byte limit.
*/
export function truncateTail(content: string, options: TruncationOptions = {}): TruncationResult {
const maxLines = options.maxLines ?? DEFAULT_MAX_LINES;
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
const totalBytes = Buffer.byteLength(content, "utf-8");
const lines = content.split("\n");
const totalLines = lines.length;
// Check if no truncation needed
if (totalLines <= maxLines && totalBytes <= maxBytes) {
return {
content,
truncated: false,
truncatedBy: null,
totalLines,
totalBytes,
outputLines: totalLines,
outputBytes: totalBytes,
lastLinePartial: false,
firstLineExceedsLimit: false,
};
}
// Work backwards from the end
const outputLinesArr: string[] = [];
let outputBytesCount = 0;
let truncatedBy: "lines" | "bytes" = "lines";
let lastLinePartial = false;
for (let i = lines.length - 1; i >= 0 && outputLinesArr.length < maxLines; i--) {
const line = lines[i];
const lineBytes = Buffer.byteLength(line, "utf-8") + (outputLinesArr.length > 0 ? 1 : 0); // +1 for newline
if (outputBytesCount + lineBytes > maxBytes) {
truncatedBy = "bytes";
// Edge case: if we haven't added ANY lines yet and this line exceeds maxBytes,
// take the end of the line (partial)
if (outputLinesArr.length === 0) {
const truncatedLine = truncateStringToBytesFromEnd(line, maxBytes);
outputLinesArr.unshift(truncatedLine);
outputBytesCount = Buffer.byteLength(truncatedLine, "utf-8");
lastLinePartial = true;
}
break;
}
outputLinesArr.unshift(line);
outputBytesCount += lineBytes;
}
// If we exited due to line limit
if (outputLinesArr.length >= maxLines && outputBytesCount <= maxBytes) {
truncatedBy = "lines";
}
const outputContent = outputLinesArr.join("\n");
const finalOutputBytes = Buffer.byteLength(outputContent, "utf-8");
return {
content: outputContent,
truncated: true,
truncatedBy,
totalLines,
totalBytes,
outputLines: outputLinesArr.length,
outputBytes: finalOutputBytes,
lastLinePartial,
firstLineExceedsLimit: false,
};
}
/**
* Truncate a string to fit within a byte limit (from the end).
* Handles multi-byte UTF-8 characters correctly.
*/
function truncateStringToBytesFromEnd(str: string, maxBytes: number): string {
const buf = Buffer.from(str, "utf-8");
if (buf.length <= maxBytes) {
return str;
}
// Start from the end, skip maxBytes back
let start = buf.length - maxBytes;
// Find a valid UTF-8 boundary (start of a character)
while (start < buf.length && (buf[start] & 0xc0) === 0x80) {
start++;
}
return buf.slice(start).toString("utf-8");
}
/**
* Truncate a single line to max characters, adding [truncated] suffix.
* Used for grep match lines.
*/
export function truncateLine(
line: string,
maxChars: number = GREP_MAX_LINE_LENGTH,
): { text: string; wasTruncated: boolean } {
if (line.length <= maxChars) {
return { text: line, wasTruncated: false };
}
return { text: line.slice(0, maxChars) + "... [truncated]", wasTruncated: true };
}

View file

@ -7,10 +7,13 @@ import { getMarkdownTheme, theme } from "../theme/theme.js";
*/
export class AssistantMessageComponent extends Container {
private contentContainer: Container;
private hideThinkingBlock: boolean;
constructor(message?: AssistantMessage) {
constructor(message?: AssistantMessage, hideThinkingBlock = false) {
super();
this.hideThinkingBlock = hideThinkingBlock;
// Container for text/thinking content
this.contentContainer = new Container();
this.addChild(this.contentContainer);
@ -20,6 +23,10 @@ export class AssistantMessageComponent extends Container {
}
}
setHideThinkingBlock(hide: boolean): void {
this.hideThinkingBlock = hide;
}
updateContent(message: AssistantMessage): void {
// Clear content container
this.contentContainer.clear();
@ -34,21 +41,33 @@ export class AssistantMessageComponent extends Container {
}
// Render content in order
for (const content of message.content) {
for (let i = 0; i < message.content.length; i++) {
const content = message.content[i];
if (content.type === "text" && content.text.trim()) {
// Assistant text messages with no background - trim the text
// Set paddingY=0 to avoid extra spacing before tool executions
this.contentContainer.addChild(new Markdown(content.text.trim(), 1, 0, getMarkdownTheme()));
} else if (content.type === "thinking" && content.thinking.trim()) {
// Thinking traces in muted color, italic
// Use Markdown component with default text style for consistent styling
this.contentContainer.addChild(
new Markdown(content.thinking.trim(), 1, 0, getMarkdownTheme(), {
color: (text: string) => theme.fg("muted", text),
italic: true,
}),
);
this.contentContainer.addChild(new Spacer(1));
// Check if there's text content after this thinking block
const hasTextAfter = message.content.slice(i + 1).some((c) => c.type === "text" && c.text.trim());
if (this.hideThinkingBlock) {
// Show static "Thinking..." label when hidden
this.contentContainer.addChild(new Text(theme.fg("muted", "Thinking..."), 1, 0));
if (hasTextAfter) {
this.contentContainer.addChild(new Spacer(1));
}
} else {
// Thinking traces in muted color, italic
// Use Markdown component with default text style for consistent styling
this.contentContainer.addChild(
new Markdown(content.thinking.trim(), 1, 0, getMarkdownTheme(), {
color: (text: string) => theme.fg("muted", text),
italic: true,
}),
);
this.contentContainer.addChild(new Spacer(1));
}
}
}

View file

@ -25,10 +25,10 @@ export class CompactionComponent extends Container {
private updateDisplay(): void {
this.clear();
this.addChild(new Spacer(1));
if (this.expanded) {
// Show header + summary as markdown (like user message)
this.addChild(new Spacer(1));
const header = `**Context compacted from ${this.tokensBefore.toLocaleString()} tokens**\n\n`;
this.addChild(
new Markdown(header + this.summary, 1, 1, getMarkdownTheme(), {
@ -36,17 +36,17 @@ export class CompactionComponent extends Container {
color: (text: string) => theme.fg("userMessageText", text),
}),
);
this.addChild(new Spacer(1));
} else {
// Collapsed: just show the header line with user message styling
// Collapsed: simple text in warning color with token count
const tokenStr = this.tokensBefore.toLocaleString();
this.addChild(
new Text(
theme.fg("userMessageText", `--- Earlier messages compacted (CTRL+O to expand) ---`),
theme.fg("warning", `Earlier messages compacted from ${tokenStr} tokens (ctrl+o to expand)`),
1,
1,
(text: string) => theme.bg("userMessageBg", text),
),
);
}
this.addChild(new Spacer(1));
}
}

View file

@ -9,8 +9,15 @@ export class CustomEditor extends Editor {
public onShiftTab?: () => void;
public onCtrlP?: () => void;
public onCtrlO?: () => void;
public onCtrlT?: () => void;
handleInput(data: string): void {
// Intercept Ctrl+T for thinking block visibility toggle
if (data === "\x14" && this.onCtrlT) {
this.onCtrlT();
return;
}
// Intercept Ctrl+O for tool output expansion
if (data === "\x0f" && this.onCtrlO) {
this.onCtrlO();

View file

@ -145,7 +145,9 @@ export class FooterComponent implements Component {
const formatTokens = (count: number): string => {
if (count < 1000) return count.toString();
if (count < 10000) return (count / 1000).toFixed(1) + "k";
return Math.round(count / 1000) + "k";
if (count < 1000000) return Math.round(count / 1000) + "k";
if (count < 10000000) return (count / 1000000).toFixed(1) + "M";
return Math.round(count / 1000000) + "M";
};
// Replace home directory with ~
@ -186,16 +188,17 @@ export class FooterComponent implements Component {
// Colorize context percentage based on usage
let contextPercentStr: string;
const autoIndicator = this.autoCompactEnabled ? " (auto)" : "";
const contextPercentDisplay = `${contextPercent}%/${formatTokens(contextWindow)}${autoIndicator}`;
if (contextPercentValue > 90) {
contextPercentStr = theme.fg("error", `${contextPercent}%${autoIndicator}`);
contextPercentStr = theme.fg("error", contextPercentDisplay);
} else if (contextPercentValue > 70) {
contextPercentStr = theme.fg("warning", `${contextPercent}%${autoIndicator}`);
contextPercentStr = theme.fg("warning", contextPercentDisplay);
} else {
contextPercentStr = `${contextPercent}%${autoIndicator}`;
contextPercentStr = contextPercentDisplay;
}
statsParts.push(contextPercentStr);
const statsLeft = statsParts.join(" ");
let statsLeft = statsParts.join(" ");
// Add model name on the right side, plus thinking level if model supports it
const modelName = this.state.model?.id || "no-model";
@ -209,9 +212,17 @@ export class FooterComponent implements Component {
}
}
const statsLeftWidth = visibleWidth(statsLeft);
let statsLeftWidth = visibleWidth(statsLeft);
const rightSideWidth = visibleWidth(rightSide);
// If statsLeft is too wide, truncate it
if (statsLeftWidth > width) {
// Truncate statsLeft to fit width (no room for right side)
const plainStatsLeft = statsLeft.replace(/\x1b\[[0-9;]*m/g, "");
statsLeft = plainStatsLeft.substring(0, width - 3) + "...";
statsLeftWidth = visibleWidth(statsLeft);
}
// Calculate available space for padding (minimum 2 spaces between stats and model)
const minPadding = 2;
const totalNeeded = statsLeftWidth + minPadding + rightSideWidth;

View file

@ -1,5 +1,6 @@
import type { Model } from "@mariozechner/pi-ai";
import { Container, Input, Spacer, Text, type TUI } from "@mariozechner/pi-tui";
import { fuzzyFilter } from "../fuzzy.js";
import { getAvailableModels } from "../model-config.js";
import type { SettingsManager } from "../settings-manager.js";
import { theme } from "../theme/theme.js";
@ -114,19 +115,7 @@ export class ModelSelectorComponent extends Container {
}
private filterModels(query: string): void {
if (!query.trim()) {
this.filteredModels = this.allModels;
} else {
const searchTokens = query
.toLowerCase()
.split(/\s+/)
.filter((t) => t);
this.filteredModels = this.allModels.filter(({ provider, id, model }) => {
const searchText = `${provider} ${id} ${model.name}`.toLowerCase();
return searchTokens.every((token) => searchText.includes(token));
});
}
this.filteredModels = fuzzyFilter(this.allModels, query, ({ provider, id }) => `${provider} ${id}`);
this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredModels.length - 1));
this.updateList();
}

View file

@ -1,4 +1,5 @@
import { type Component, Container, Input, Spacer, Text, truncateToWidth } from "@mariozechner/pi-tui";
import { fuzzyFilter } from "../fuzzy.js";
import type { SessionManager } from "../session-manager.js";
import { theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
@ -42,20 +43,7 @@ class SessionList implements Component {
}
private filterSessions(query: string): void {
if (!query.trim()) {
this.filteredSessions = this.allSessions;
} else {
const searchTokens = query
.toLowerCase()
.split(/\s+/)
.filter((t) => t);
this.filteredSessions = this.allSessions.filter((session) => {
// Search through all messages in the session
const searchText = session.allMessagesText.toLowerCase();
return searchTokens.every((token) => searchText.includes(token));
});
}
this.filteredSessions = fuzzyFilter(this.allSessions, query, (session) => session.allMessagesText);
this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredSessions.length - 1));
}

View file

@ -85,7 +85,17 @@ export class ToolExecutionComponent extends Container {
// Strip ANSI codes and carriage returns from raw output
// (bash may emit colors/formatting, and Windows may include \r)
let output = textBlocks.map((c: any) => stripAnsi(c.text || "").replace(/\r/g, "")).join("\n");
let output = textBlocks
.map((c: any) => {
let text = stripAnsi(c.text || "").replace(/\r/g, "");
// stripAnsi misses some escape sequences like standalone ESC \ (String Terminator)
// and leaves orphaned fragments from malformed sequences (e.g. TUI output captured to file)
// Clean up: remove ESC + any following char, and control chars except newline/tab
text = text.replace(/\x1b./g, "");
text = text.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g, "");
return text;
})
.join("\n");
// Add indicator for images
if (imageBlocks.length > 0) {
@ -105,7 +115,6 @@ export class ToolExecutionComponent extends Container {
text = theme.fg("toolTitle", theme.bold(`$ ${command || theme.fg("toolOutput", "...")}`));
if (this.result) {
// Show output without code fences - more minimal
const output = this.getTextOutput().trim();
if (output) {
const lines = output.split("\n");
@ -118,17 +127,36 @@ export class ToolExecutionComponent extends Container {
text += theme.fg("toolOutput", `\n... (${remaining} more lines)`);
}
}
// Show truncation warning at the bottom (outside collapsed area)
const truncation = this.result.details?.truncation;
const fullOutputPath = this.result.details?.fullOutputPath;
if (truncation?.truncated || fullOutputPath) {
const warnings: string[] = [];
if (fullOutputPath) {
warnings.push(`Full output: ${fullOutputPath}`);
}
if (truncation?.truncated) {
if (truncation.truncatedBy === "lines") {
warnings.push(`Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines`);
} else {
warnings.push(`Truncated: ${truncation.outputLines} lines shown (30KB limit)`);
}
}
text += "\n" + theme.fg("warning", `[${warnings.join(". ")}]`);
}
}
} else if (this.toolName === "read") {
const path = shortenPath(this.args?.file_path || this.args?.path || "");
const offset = this.args?.offset;
const limit = this.args?.limit;
// Build path display with offset/limit suffix
// Build path display with offset/limit suffix (in warning color if offset/limit used)
let pathDisplay = path ? theme.fg("accent", path) : theme.fg("toolOutput", "...");
if (offset !== undefined) {
const endLine = limit !== undefined ? offset + limit : "";
pathDisplay += theme.fg("toolOutput", `:${offset}${endLine ? `-${endLine}` : ""}`);
if (offset !== undefined || limit !== undefined) {
const startLine = offset ?? 1;
const endLine = limit !== undefined ? startLine + limit - 1 : "";
pathDisplay += theme.fg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`);
}
text = theme.fg("toolTitle", theme.bold("read")) + " " + pathDisplay;
@ -136,6 +164,7 @@ export class ToolExecutionComponent extends Container {
if (this.result) {
const output = this.getTextOutput();
const lines = output.split("\n");
const maxLines = this.expanded ? lines.length : 10;
const displayLines = lines.slice(0, maxLines);
const remaining = lines.length - maxLines;
@ -144,6 +173,23 @@ export class ToolExecutionComponent extends Container {
if (remaining > 0) {
text += theme.fg("toolOutput", `\n... (${remaining} more lines)`);
}
// Show truncation warning at the bottom (outside collapsed area)
const truncation = this.result.details?.truncation;
if (truncation?.truncated) {
if (truncation.firstLineExceedsLimit) {
text += "\n" + theme.fg("warning", `[First line exceeds 30KB limit]`);
} else if (truncation.truncatedBy === "lines") {
text +=
"\n" +
theme.fg(
"warning",
`[Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines]`,
);
} else {
text += "\n" + theme.fg("warning", `[Truncated: ${truncation.outputLines} lines shown (30KB limit)]`);
}
}
}
} else if (this.toolName === "write") {
const path = shortenPath(this.args?.file_path || this.args?.path || "");
@ -221,6 +267,20 @@ export class ToolExecutionComponent extends Container {
text += theme.fg("toolOutput", `\n... (${remaining} more lines)`);
}
}
// Show truncation warning at the bottom (outside collapsed area)
const entryLimit = this.result.details?.entryLimitReached;
const truncation = this.result.details?.truncation;
if (entryLimit || truncation?.truncated) {
const warnings: string[] = [];
if (entryLimit) {
warnings.push(`${entryLimit} entries limit`);
}
if (truncation?.truncated) {
warnings.push("30KB limit");
}
text += "\n" + theme.fg("warning", `[Truncated: ${warnings.join(", ")}]`);
}
}
} else if (this.toolName === "find") {
const pattern = this.args?.pattern || "";
@ -249,6 +309,20 @@ export class ToolExecutionComponent extends Container {
text += theme.fg("toolOutput", `\n... (${remaining} more lines)`);
}
}
// Show truncation warning at the bottom (outside collapsed area)
const resultLimit = this.result.details?.resultLimitReached;
const truncation = this.result.details?.truncation;
if (resultLimit || truncation?.truncated) {
const warnings: string[] = [];
if (resultLimit) {
warnings.push(`${resultLimit} results limit`);
}
if (truncation?.truncated) {
warnings.push("30KB limit");
}
text += "\n" + theme.fg("warning", `[Truncated: ${warnings.join(", ")}]`);
}
}
} else if (this.toolName === "grep") {
const pattern = this.args?.pattern || "";
@ -281,6 +355,24 @@ export class ToolExecutionComponent extends Container {
text += theme.fg("toolOutput", `\n... (${remaining} more lines)`);
}
}
// Show truncation warning at the bottom (outside collapsed area)
const matchLimit = this.result.details?.matchLimitReached;
const truncation = this.result.details?.truncation;
const linesTruncated = this.result.details?.linesTruncated;
if (matchLimit || truncation?.truncated || linesTruncated) {
const warnings: string[] = [];
if (matchLimit) {
warnings.push(`${matchLimit} matches limit`);
}
if (truncation?.truncated) {
warnings.push("30KB limit");
}
if (linesTruncated) {
warnings.push("some lines truncated");
}
text += "\n" + theme.fg("warning", `[Truncated: ${warnings.join(", ")}]`);
}
}
} else {
// Generic tool

View file

@ -32,7 +32,7 @@ import {
SUMMARY_SUFFIX,
} from "../session-manager.js";
import type { SettingsManager } from "../settings-manager.js";
import { getShellConfig } from "../shell-config.js";
import { getShellConfig, killProcessTree } from "../shell.js";
import { expandSlashCommand, type FileSlashCommand, loadSlashCommands } from "../slash-commands.js";
import { getEditorTheme, getMarkdownTheme, onThemeChange, setTheme, theme } from "../theme/theme.js";
import { AssistantMessageComponent } from "./assistant-message.js";
@ -43,6 +43,7 @@ import { FooterComponent } from "./footer.js";
import { ModelSelectorComponent } from "./model-selector.js";
import { OAuthSelectorComponent } from "./oauth-selector.js";
import { QueueModeSelectorComponent } from "./queue-mode-selector.js";
import { SessionSelectorComponent } from "./session-selector.js";
import { ThemeSelectorComponent } from "./theme-selector.js";
import { ThinkingSelectorComponent } from "./thinking-selector.js";
import { ToolExecutionComponent } from "./tool-execution.js";
@ -69,8 +70,9 @@ export class TuiRenderer {
private loadingAnimation: Loader | null = null;
private lastSigintTime = 0;
private lastEscapeTime = 0;
private changelogMarkdown: string | null = null;
private newVersion: string | null = null;
private collapseChangelog = false;
// Message queueing
private queuedMessages: string[] = [];
@ -96,6 +98,9 @@ export class TuiRenderer {
// User message selector (for branching)
private userMessageSelector: UserMessageSelectorComponent | null = null;
// Session selector (for resume)
private sessionSelector: SessionSelectorComponent | null = null;
// OAuth selector
private oauthSelector: any | null = null;
@ -108,6 +113,9 @@ export class TuiRenderer {
// Tool output expansion state
private toolOutputExpanded = false;
// Thinking block visibility state
private hideThinkingBlock = false;
// Agent subscription unsubscribe function
private unsubscribe?: () => void;
@ -126,7 +134,7 @@ export class TuiRenderer {
settingsManager: SettingsManager,
version: string,
changelogMarkdown: string | null = null,
newVersion: string | null = null,
collapseChangelog = false,
scopedModels: Array<{ model: Model<any>; thinkingLevel: ThinkingLevel }> = [],
fdPath: string | null = null,
) {
@ -134,8 +142,8 @@ export class TuiRenderer {
this.sessionManager = sessionManager;
this.settingsManager = settingsManager;
this.version = version;
this.newVersion = newVersion;
this.changelogMarkdown = changelogMarkdown;
this.collapseChangelog = collapseChangelog;
this.scopedModels = scopedModels;
this.ui = new TUI(new ProcessTerminal());
this.chatContainer = new Container();
@ -218,6 +226,14 @@ export class TuiRenderer {
description: "Toggle automatic context compaction",
};
const resumeCommand: SlashCommand = {
name: "resume",
description: "Resume a different session",
};
// Load hide thinking block setting
this.hideThinkingBlock = settingsManager.getHideThinkingBlock();
// Load file-based slash commands
this.fileCommands = loadSlashCommands();
@ -244,6 +260,7 @@ export class TuiRenderer {
clearCommand,
compactCommand,
autocompactCommand,
resumeCommand,
...fileSlashCommands,
],
process.cwd(),
@ -279,6 +296,9 @@ export class TuiRenderer {
theme.fg("dim", "ctrl+o") +
theme.fg("muted", " to expand tools") +
"\n" +
theme.fg("dim", "ctrl+t") +
theme.fg("muted", " to toggle thinking") +
"\n" +
theme.fg("dim", "/") +
theme.fg("muted", " for commands") +
"\n" +
@ -294,29 +314,21 @@ export class TuiRenderer {
this.ui.addChild(header);
this.ui.addChild(new Spacer(1));
// Add new version notification if available
if (this.newVersion) {
this.ui.addChild(new DynamicBorder((text) => theme.fg("warning", text)));
this.ui.addChild(
new Text(
theme.bold(theme.fg("warning", "Update Available")) +
"\n" +
theme.fg("muted", `New version ${this.newVersion} is available. Run: `) +
theme.fg("accent", "npm install -g @mariozechner/pi-coding-agent"),
1,
0,
),
);
this.ui.addChild(new DynamicBorder((text) => theme.fg("warning", text)));
}
// Add changelog if provided
if (this.changelogMarkdown) {
this.ui.addChild(new DynamicBorder());
this.ui.addChild(new Text(theme.bold(theme.fg("accent", "What's New")), 1, 0));
this.ui.addChild(new Spacer(1));
this.ui.addChild(new Markdown(this.changelogMarkdown.trim(), 1, 0, getMarkdownTheme()));
this.ui.addChild(new Spacer(1));
if (this.collapseChangelog) {
// Show condensed version with hint to use /changelog
const versionMatch = this.changelogMarkdown.match(/##\s+\[?(\d+\.\d+\.\d+)\]?/);
const latestVersion = versionMatch ? versionMatch[1] : this.version;
const condensedText = `Updated to v${latestVersion}. Use ${theme.bold("/changelog")} to view full changelog.`;
this.ui.addChild(new Text(condensedText, 1, 0));
} else {
this.ui.addChild(new Text(theme.bold(theme.fg("accent", "What's New")), 1, 0));
this.ui.addChild(new Spacer(1));
this.ui.addChild(new Markdown(this.changelogMarkdown.trim(), 1, 0, getMarkdownTheme()));
this.ui.addChild(new Spacer(1));
}
this.ui.addChild(new DynamicBorder());
}
@ -364,6 +376,15 @@ export class TuiRenderer {
this.editor.setText("");
this.isBashMode = false;
this.updateEditorBorderColor();
} else if (!this.editor.getText().trim()) {
// Double-escape with empty editor triggers /branch
const now = Date.now();
if (now - this.lastEscapeTime < 500) {
this.showUserMessageSelector();
this.lastEscapeTime = 0; // Reset to prevent triple-escape
} else {
this.lastEscapeTime = now;
}
}
};
@ -383,6 +404,10 @@ export class TuiRenderer {
this.toggleToolOutputExpansion();
};
this.editor.onCtrlT = () => {
this.toggleThinkingBlockVisibility();
};
// Handle editor text changes for bash mode detection
this.editor.onChange = (text: string) => {
const wasBashMode = this.isBashMode;
@ -505,6 +530,13 @@ export class TuiRenderer {
return;
}
// Check for /resume command
if (text === "/resume") {
this.showSessionSelector();
this.editor.setText("");
return;
}
// Check for bash command (!<command>)
if (text.startsWith("!")) {
const command = text.slice(1).trim();
@ -559,6 +591,9 @@ export class TuiRenderer {
// Update pending messages display
this.updatePendingMessagesDisplay();
// Add to history for up/down arrow navigation
this.editor.addToHistory(text);
// Clear editor
this.editor.setText("");
this.ui.requestRender();
@ -569,6 +604,9 @@ export class TuiRenderer {
if (this.onInputCallback) {
this.onInputCallback(text);
}
// Add to history for up/down arrow navigation
this.editor.addToHistory(text);
};
// Start the UI
@ -691,7 +729,7 @@ export class TuiRenderer {
this.ui.requestRender();
} else if (event.message.role === "assistant") {
// Create assistant component for streaming
this.streamingComponent = new AssistantMessageComponent();
this.streamingComponent = new AssistantMessageComponent(undefined, this.hideThinkingBlock);
this.chatContainer.addChild(this.streamingComponent);
this.streamingComponent.updateContent(event.message as AssistantMessage);
this.ui.requestRender();
@ -831,7 +869,7 @@ export class TuiRenderer {
const assistantMsg = message;
// Add assistant message component
const assistantComponent = new AssistantMessageComponent(assistantMsg);
const assistantComponent = new AssistantMessageComponent(assistantMsg, this.hideThinkingBlock);
this.chatContainer.addChild(assistantComponent);
}
// Note: tool calls and results are now handled via tool_execution_start/end events
@ -877,7 +915,7 @@ export class TuiRenderer {
}
} else if (message.role === "assistant") {
const assistantMsg = message as AssistantMessage;
const assistantComponent = new AssistantMessageComponent(assistantMsg);
const assistantComponent = new AssistantMessageComponent(assistantMsg, this.hideThinkingBlock);
this.chatContainer.addChild(assistantComponent);
// Create tool execution components for any tool calls
@ -918,6 +956,22 @@ export class TuiRenderer {
}
// Clear pending tools after rendering initial messages
this.pendingTools.clear();
// Populate editor history with user messages from the session (oldest first so newest is at index 0)
for (const message of state.messages) {
if (message.role === "user") {
const textBlocks =
typeof message.content === "string"
? [{ type: "text", text: message.content }]
: message.content.filter((c) => c.type === "text");
const textContent = textBlocks.map((c) => c.text).join("");
// Skip compaction summary messages
if (textContent && !textContent.startsWith(SUMMARY_PREFIX)) {
this.editor.addToHistory(textContent);
}
}
}
this.ui.requestRender();
}
@ -961,7 +1015,7 @@ export class TuiRenderer {
}
} else if (message.role === "assistant") {
const assistantMsg = message;
const assistantComponent = new AssistantMessageComponent(assistantMsg);
const assistantComponent = new AssistantMessageComponent(assistantMsg, this.hideThinkingBlock);
this.chatContainer.addChild(assistantComponent);
for (const content of assistantMsg.content) {
@ -1023,7 +1077,12 @@ export class TuiRenderer {
return;
}
const levels: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high"];
// xhigh is only available for codex-max models
const modelId = this.agent.state.model?.id || "";
const supportsXhigh = modelId.includes("codex-max");
const levels: ThinkingLevel[] = supportsXhigh
? ["off", "minimal", "low", "medium", "high", "xhigh"]
: ["off", "minimal", "low", "medium", "high"];
const currentLevel = this.agent.state.thinkingLevel || "off";
const currentIndex = levels.indexOf(currentLevel);
const nextIndex = (currentIndex + 1) % levels.length;
@ -1168,6 +1227,28 @@ export class TuiRenderer {
this.ui.requestRender();
}
private toggleThinkingBlockVisibility(): void {
this.hideThinkingBlock = !this.hideThinkingBlock;
this.settingsManager.setHideThinkingBlock(this.hideThinkingBlock);
// Update all assistant message components and rebuild their content
for (const child of this.chatContainer.children) {
if (child instanceof AssistantMessageComponent) {
child.setHideThinkingBlock(this.hideThinkingBlock);
}
}
// Rebuild chat to apply visibility change
this.chatContainer.clear();
this.rebuildChatFromMessages();
// Show brief notification
const status = this.hideThinkingBlock ? "hidden" : "visible";
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new Text(theme.fg("dim", `Thinking blocks: ${status}`), 1, 0));
this.ui.requestRender();
}
clearEditor(): void {
this.editor.setText("");
this.ui.requestRender();
@ -1187,12 +1268,21 @@ export class TuiRenderer {
this.ui.requestRender();
}
private showSuccess(message: string, detail?: string): void {
showNewVersionNotification(newVersion: string): void {
// Show new version notification in the chat
this.chatContainer.addChild(new Spacer(1));
const text = detail
? `${theme.fg("success", message)}\n${theme.fg("muted", detail)}`
: theme.fg("success", message);
this.chatContainer.addChild(new Text(text, 1, 1));
this.chatContainer.addChild(new DynamicBorder((text) => theme.fg("warning", text)));
this.chatContainer.addChild(
new Text(
theme.bold(theme.fg("warning", "Update Available")) +
"\n" +
theme.fg("muted", `New version ${newVersion} is available. Run: `) +
theme.fg("accent", "npm install -g @mariozechner/pi-coding-agent"),
1,
0,
),
);
this.chatContainer.addChild(new DynamicBorder((text) => theme.fg("warning", text)));
this.ui.requestRender();
}
@ -1489,6 +1579,95 @@ export class TuiRenderer {
this.ui.setFocus(this.editor);
}
private showSessionSelector(): void {
// Create session selector
this.sessionSelector = new SessionSelectorComponent(
this.sessionManager,
async (sessionPath) => {
this.hideSessionSelector();
await this.handleResumeSession(sessionPath);
},
() => {
// Just hide the selector
this.hideSessionSelector();
this.ui.requestRender();
},
);
// Replace editor with selector
this.editorContainer.clear();
this.editorContainer.addChild(this.sessionSelector);
this.ui.setFocus(this.sessionSelector.getSessionList());
this.ui.requestRender();
}
private async handleResumeSession(sessionPath: string): Promise<void> {
// Unsubscribe first to prevent processing events during transition
this.unsubscribe?.();
// Abort and wait for completion
this.agent.abort();
await this.agent.waitForIdle();
// Stop loading animation
if (this.loadingAnimation) {
this.loadingAnimation.stop();
this.loadingAnimation = null;
}
this.statusContainer.clear();
// Clear UI state
this.queuedMessages = [];
this.pendingMessagesContainer.clear();
this.streamingComponent = null;
this.pendingTools.clear();
// Set the selected session as active
this.sessionManager.setSessionFile(sessionPath);
// Reload the session
const loaded = loadSessionFromEntries(this.sessionManager.loadEntries());
this.agent.replaceMessages(loaded.messages);
// Restore model if saved in session
const savedModel = this.sessionManager.loadModel();
if (savedModel) {
const availableModels = (await getAvailableModels()).models;
const match = availableModels.find((m) => m.provider === savedModel.provider && m.id === savedModel.modelId);
if (match) {
this.agent.setModel(match);
}
}
// Restore thinking level if saved in session
const savedThinking = this.sessionManager.loadThinkingLevel();
if (savedThinking) {
this.agent.setThinkingLevel(savedThinking as ThinkingLevel);
}
// Resubscribe to agent
this.subscribeToAgent();
// Clear and re-render the chat
this.chatContainer.clear();
this.isFirstUserMessage = true;
this.renderInitialMessages(this.agent.state);
// Show confirmation message
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new Text(theme.fg("dim", "Resumed session"), 1, 0));
this.ui.requestRender();
}
private hideSessionSelector(): void {
// Replace selector with editor in the container
this.editorContainer.clear();
this.editorContainer.addChild(this.editor);
this.sessionSelector = null;
this.ui.setFocus(this.editor);
}
private async showOAuthSelector(mode: "login" | "logout"): Promise<void> {
// For logout mode, filter to only show logged-in providers
let providersToShow: string[] = [];
@ -2036,10 +2215,6 @@ export class TuiRenderer {
// Update footer with new state (fixes context % display)
this.footer.updateState(this.agent.state);
// Show success message
const successTitle = isAuto ? "✓ Context auto-compacted" : "✓ Context compacted";
this.showSuccess(successTitle, `Reduced from ${compactionEntry.tokensBefore.toLocaleString()} tokens`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message === "Compaction cancelled" || (error instanceof Error && error.name === "AbortError")) {
@ -2109,32 +2284,3 @@ export class TuiRenderer {
}
}
}
/**
* Kill a process and all its children (cross-platform)
*/
function killProcessTree(pid: number): void {
if (process.platform === "win32") {
// Use taskkill on Windows to kill process tree
try {
spawn("taskkill", ["/F", "/T", "/PID", String(pid)], {
stdio: "ignore",
detached: true,
});
} catch {
// Ignore errors if taskkill fails
}
} else {
// Use SIGKILL on Unix/Linux/Mac
try {
process.kill(-pid, "SIGKILL");
} catch {
// Fallback to killing just the child if process group kill fails
try {
process.kill(pid, "SIGKILL");
} catch {
// Process already dead
}
}
}
}

View file

@ -1,4 +1,4 @@
import { type Component, Container, Spacer, Text } from "@mariozechner/pi-tui";
import { type Component, Container, Spacer, Text, truncateToWidth } from "@mariozechner/pi-tui";
import { theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
@ -54,8 +54,8 @@ class UserMessageList implements Component {
// First line: cursor + message
const cursor = isSelected ? theme.fg("accent", " ") : " ";
const maxMsgWidth = width - 2; // Account for cursor
const truncatedMsg = normalizedMessage.substring(0, maxMsgWidth);
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);