mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 21:03:19 +00:00
Merge main into bash-mode
This commit is contained in:
commit
1608da8770
91 changed files with 6083 additions and 1554 deletions
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
92
packages/coding-agent/src/fuzzy.test.ts
Normal file
92
packages/coding-agent/src/fuzzy.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
83
packages/coding-agent/src/fuzzy.ts
Normal file
83
packages/coding-agent/src/fuzzy.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"] };
|
||||
}
|
||||
117
packages/coding-agent/src/shell.ts
Normal file
117
packages/coding-agent/src/shell.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -66,6 +66,7 @@
|
|||
"thinkingLow": "#5f87af",
|
||||
"thinkingMedium": "#81a2be",
|
||||
"thinkingHigh": "#b294bb",
|
||||
"thinkingXhigh": "#d183e8",
|
||||
|
||||
"bashMode": "green"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@
|
|||
"thinkingLow": "#5f87af",
|
||||
"thinkingMedium": "#5f8787",
|
||||
"thinkingHigh": "#875f87",
|
||||
"thinkingXhigh": "#8b008b",
|
||||
|
||||
"bashMode": "green"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
})();
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
251
packages/coding-agent/src/tools/truncate.ts
Normal file
251
packages/coding-agent/src/tools/truncate.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue